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
48
frontend/packages/shared/src/dropdown/Dropdown.css
Normal file
48
frontend/packages/shared/src/dropdown/Dropdown.css
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/* <Dropdown> — anchored option list. `position: absolute` against the
|
||||
caller's `position: relative` wrapper (see Dropdown.tsx's file-top
|
||||
comment) so it hangs directly under the badge that opened it.
|
||||
`--bg-elev` is the same elevated-surface slot the badge uses when
|
||||
open (../badge/Badge.css), so the pair reads as one continuous panel. */
|
||||
.ui-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.25em);
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 12em;
|
||||
padding: 0.25em;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5em;
|
||||
box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.ui-dropdown-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.1em;
|
||||
min-height: 2.75em;
|
||||
padding: 0.4em 0.7em;
|
||||
border: none;
|
||||
border-radius: 0.35em;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ui-dropdown-item:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
.ui-dropdown-item-active {
|
||||
background: var(--purple-dim);
|
||||
}
|
||||
.ui-dropdown-item-active .ui-dropdown-item-label::before {
|
||||
content: '✓ ';
|
||||
color: var(--purple);
|
||||
}
|
||||
.ui-dropdown-item-desc {
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
}
|
||||
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