Add Badge + Dropdown Preact components, demo on swarm-ui /components
New shared Preact primitives for the per-agent terminal redesign: - Badge (@hive/shared/badge.js): a labelled status pill. Plain <span> when static (e.g. "alive"), a real <button> with a disclosure caret when given onClick (e.g. opens a Dropdown, or toggles itself in place). Same component either way so a status row reads as one consistent set of badges regardless of which are interactive. - Dropdown (@hive/shared/dropdown.js): a small option list anchored directly under whatever opened it (no portal, no native <dialog> — see the file comment for why). Closes on outside click or Escape. This is the fix for the design guide's own named anti-example: the agent page's model/effort pickers live in the overflow menu while the current model/effort only show as a disconnected chip. Badge+Dropdown composed together is that control moved inline, next to what it shows. Demoed on swarm-ui's /components page: all Badge tones, a label-prefixed badge, an interactive badge that opens a Dropdown (model-picker shape), and an interactive badge that toggles itself (pause/resume shape). Both packages build + tsc --noEmit clean.
This commit is contained in:
parent
c4a573d91d
commit
62cc0620c8
7 changed files with 365 additions and 1 deletions
79
frontend/packages/shared/src/dropdown/Dropdown.tsx
Normal file
79
frontend/packages/shared/src/dropdown/Dropdown.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// <Dropdown> — a small anchored option list, meant to sit directly under
|
||||
// the `Badge` that opened it (../badge/Badge.tsx) so the control is
|
||||
// visually attached to the thing it affects, instead of living three
|
||||
// clicks away in a catch-all menu (the junk-drawer pattern
|
||||
// `docs/web-ui/design-guide.md` calls out by name against the old
|
||||
// per-agent terminal's model picker). Not a native `<dialog>` — a
|
||||
// `showModal()` dialog's backdrop would visually detach it from its
|
||||
// anchor badge, and a non-modal `show()` gets no click-outside-to-close
|
||||
// for free, so this hand-rolls the same "outside click or Escape
|
||||
// closes" contract `Dialog` gets from the platform.
|
||||
//
|
||||
// Positioning is the caller's job: render `<Dropdown>` as a child of a
|
||||
// `position: relative` wrapper (the badge + dropdown pair) and it anchors
|
||||
// to that box's bottom-left via CSS. No portal — a badge in a normal
|
||||
// document-flow header never needs one, and skipping it keeps focus
|
||||
// management simple (no re-parenting to `<body>` to reason about).
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import './Dropdown.css';
|
||||
|
||||
export interface DropdownOption {
|
||||
value: string;
|
||||
label: ComponentChildren;
|
||||
/** Optional dim secondary text, e.g. "sonnet (balanced)". */
|
||||
description?: ComponentChildren;
|
||||
}
|
||||
|
||||
export interface DropdownProps {
|
||||
open: boolean;
|
||||
options: DropdownOption[];
|
||||
activeValue?: string;
|
||||
onSelect: (value: string) => void;
|
||||
onClose: () => void;
|
||||
/** `aria-label` for the option list (e.g. "select model"). */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function Dropdown({ open, options, activeValue, onSelect, onClose, label }: DropdownProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (ref.current && e.target instanceof Node && !ref.current.contains(e.target)) onClose();
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
// `pointerdown` (not `click`) so a drag-to-select that ends outside
|
||||
// still closes; capture phase so this sees the event before a
|
||||
// stopPropagation() elsewhere in the tree could swallow it.
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div class="ui-dropdown" role="menu" aria-label={label} ref={ref}>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
type="button"
|
||||
key={opt.value}
|
||||
role="menuitemradio"
|
||||
aria-checked={opt.value === activeValue}
|
||||
class={'ui-dropdown-item' + (opt.value === activeValue ? ' ui-dropdown-item-active' : '')}
|
||||
onClick={() => onSelect(opt.value)}
|
||||
>
|
||||
<span class="ui-dropdown-item-label">{opt.label}</span>
|
||||
{opt.description ? <span class="ui-dropdown-item-desc">{opt.description}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue