swarm-ui: dropdowns open a themed list of their own instead of the browser's
SelectField and the refresh-interval picker are built on a new Listbox, a select-only combobox: a button that keeps focus and a listbox in the top layer, so it works the same in every current browser, is not clipped by a scrolling ancestor and shows above a modal dialog. Keys, type-ahead and placement are pure functions with tests. required becomes aria-required, as a button has no native form validation.
This commit is contained in:
parent
88053f437c
commit
a2e8a2bda0
9 changed files with 653 additions and 66 deletions
|
|
@ -11,6 +11,15 @@ The signed-in user's avatar sits at the right end of the header on every page.
|
||||||
It opens a menu with the user's name and links to authelia's settings and
|
It opens a menu with the user's name and links to authelia's settings and
|
||||||
sign-out pages; the hive dashboard and the per-agent page have the same menu.
|
sign-out pages; the hive dashboard and the per-agent page have the same menu.
|
||||||
|
|
||||||
|
Every dropdown (a form's select, the refresh interval, the history window) opens a
|
||||||
|
list in the swarm UI's own theme, not the browser's, and works the same in every
|
||||||
|
current browser. Open it with a click, or with the arrow keys, **Enter** or
|
||||||
|
**Space** while it has focus. Then the arrow keys, **Home**, **End**, **Page Up**
|
||||||
|
and **Page Down** move through the options, and **Enter**, **Space** or a click take
|
||||||
|
the highlighted one. **Escape**, **Tab** or a click elsewhere shut the list without
|
||||||
|
changing anything. Typing the start of an option's name picks it, also with the list
|
||||||
|
shut. A dropdown inside a dialog opens above the dialog and the dialog doesn't cut it off.
|
||||||
|
|
||||||
| route | what |
|
| route | what |
|
||||||
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `/` | the start page: the swarm's name over a digital-rain backdrop, plus a paperclip offering to set up an agent while no `ruth` exists (it opens the create form with `ruth` filled in); the swarm name in the header links here |
|
| `/` | the start page: the swarm's name over a digital-rain backdrop, plus a paperclip offering to set up an agent while no `ruth` exists (it opens the create form with `ruth` filled in); the swarm name in the header links here |
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
//
|
//
|
||||||
// `hive` field added because agent creation had no way to record which
|
// `hive` field added because agent creation had no way to record which
|
||||||
// hive an agent runs on. `POST /api/agents` now requires it, so the
|
// hive an agent runs on. `POST /api/agents` now requires it, so the
|
||||||
// roster fetched off `GET /api/hives` backs a required select here
|
// roster fetched off `GET /api/hives` backs a required dropdown here
|
||||||
// rather than a free-text field — a spawn target has to be one of the
|
// rather than a free-text field — a spawn target has to be one of the
|
||||||
// swarm's actual hives, same reasoning `Ident.parse` gets client-side
|
// swarm's actual hives, same reasoning `Ident.parse` gets client-side
|
||||||
// pattern validation for `name`.
|
// pattern validation for `name`.
|
||||||
|
|
@ -159,8 +159,8 @@ export function CreateAgentForm({
|
||||||
setHives(data);
|
setHives(data);
|
||||||
// Single-hive swarms are the common case — default it rather than
|
// Single-hive swarms are the common case — default it rather than
|
||||||
// making the operator pick the only option. Multi-hive swarms get
|
// making the operator pick the only option. Multi-hive swarms get
|
||||||
// no default (the placeholder stays selected), so `required` on
|
// no default (the placeholder stays selected), so the identity step
|
||||||
// the select below forces an explicit choice per mara's scope.
|
// cannot be left until one is picked (`stepProblem`), per mara's scope.
|
||||||
if (data.length === 1) setHive(data[0].name);
|
if (data.length === 1) setHive(data[0].name);
|
||||||
})().catch((e: unknown) => setHivesError({ detail: String(e) }));
|
})().catch((e: unknown) => setHivesError({ detail: String(e) }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@
|
||||||
border-radius: 0.4em;
|
border-radius: 0.4em;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.ui-refresh-picker:hover,
|
.ui-refresh-picker:hover,
|
||||||
.ui-refresh-picker:focus-within {
|
.ui-refresh-picker:focus-within {
|
||||||
|
|
@ -21,22 +20,21 @@
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
/* The select drives the interaction (native listbox, keyboard, a11y) but
|
/* The Listbox contributes only its value and chevron: its own box is
|
||||||
contributes no chrome of its own — its own box is invisible, sized to
|
invisible and as wide as the value, so the picker's border is the one
|
||||||
just its selected option's text, and its default arrow is stripped
|
that shows. */
|
||||||
since the chevron span stands in for it. */
|
.ui-refresh-picker .ui-listbox {
|
||||||
.ui-refresh-picker-select {
|
width: auto;
|
||||||
appearance: none;
|
}
|
||||||
|
.ui-refresh-picker-select.ui-form-control {
|
||||||
|
width: auto;
|
||||||
|
min-height: 2.5em;
|
||||||
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font: inherit;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.ui-refresh-picker-icon,
|
.ui-refresh-picker-icon {
|
||||||
.ui-refresh-picker-chevron {
|
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,17 @@
|
||||||
// passive-until-touched setting that lives in a panel's title row
|
// passive-until-touched setting that lives in a panel's title row
|
||||||
// (`Panel`'s `actions` slot), not a form. Visually quiet (no border/
|
// (`Panel`'s `actions` slot), not a form. Visually quiet (no border/
|
||||||
// fill until hovered/focused) the same way `LinksMenu`'s header button
|
// fill until hovered/focused) the same way `LinksMenu`'s header button
|
||||||
// is, so it reads as chrome rather than another input to fill in. A
|
// is, so it reads as chrome rather than another input to fill in. The
|
||||||
// native `<select>` still drives it (no custom dropdown/listbox to
|
// dropdown is the shared `Listbox` (its list is themed, not the browser's),
|
||||||
// reinvent) — `appearance: none` strips its default arrow so the
|
// restyled to have no chrome of its own: the chevron is the Listbox's, and the
|
||||||
// chevron span can stand in for it, and the visible "value" text is
|
// visible value is its selected option.
|
||||||
// just whichever `<option>` the browser is already rendering as
|
|
||||||
// selected, not a second copy of the label kept in sync by hand.
|
|
||||||
//
|
//
|
||||||
// The hook, not the picker, owns re-fetch safety: a caller passes its
|
// The hook, not the picker, owns re-fetch safety: a caller passes its
|
||||||
// own fetch callback, and it's the CALLER's job to make sure that
|
// own fetch callback, and it's the CALLER's job to make sure that
|
||||||
// callback doesn't clobber an input the operator is mid-edit on — this
|
// callback doesn't clobber an input the operator is mid-edit on — this
|
||||||
// hook only decides *when* to call it.
|
// hook only decides *when* to call it.
|
||||||
import { useEffect, useRef } from "preact/hooks";
|
import { useEffect, useRef } from "preact/hooks";
|
||||||
|
import { Listbox } from "../select-field/Listbox.js";
|
||||||
import "./RefreshInterval.css";
|
import "./RefreshInterval.css";
|
||||||
|
|
||||||
// `null` means "off" throughout this module — no separate boolean, so
|
// `null` means "off" throughout this module — no separate boolean, so
|
||||||
|
|
@ -51,37 +50,23 @@ export function RefreshIntervalPicker({
|
||||||
}) {
|
}) {
|
||||||
const current = PRESETS.find((p) => p.value === value) ?? PRESETS[0];
|
const current = PRESETS.find((p) => p.value === value) ?? PRESETS[0];
|
||||||
return (
|
return (
|
||||||
// A `<label>` wrapper (not a bare `<span>`) so the icon/chevron
|
<div class="ui-refresh-picker">
|
||||||
// padding is part of the click/tap target too, not just the native
|
|
||||||
// select box itself — same touch-target-floor reasoning as the rest
|
|
||||||
// of the kit, just without a visible label string to hang it off
|
|
||||||
// of (hence the `aria-label` on the `<select>` for the accessible
|
|
||||||
// name a sighted-only icon+chevron can't supply).
|
|
||||||
<label class="ui-refresh-picker" for={id}>
|
|
||||||
<span class="ui-refresh-picker-icon" aria-hidden="true">
|
<span class="ui-refresh-picker-icon" aria-hidden="true">
|
||||||
🕐
|
🕐
|
||||||
</span>
|
</span>
|
||||||
<select
|
<Listbox
|
||||||
id={id}
|
id={id}
|
||||||
class="ui-refresh-picker-select"
|
class="ui-refresh-picker-select"
|
||||||
aria-label="refresh interval"
|
ariaLabel="refresh interval"
|
||||||
value={current.label}
|
value={current.label}
|
||||||
onChange={(e) => {
|
options={PRESETS.map((p) => ({ value: p.label, label: p.label }))}
|
||||||
const label = (e.target as HTMLSelectElement).value;
|
minListWidth={96}
|
||||||
|
onChange={(label) => {
|
||||||
const preset = PRESETS.find((p) => p.label === label);
|
const preset = PRESETS.find((p) => p.label === label);
|
||||||
if (preset) onChange(preset.value);
|
if (preset) onChange(preset.value);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{PRESETS.map((p) => (
|
</div>
|
||||||
<option key={p.label} value={p.label}>
|
|
||||||
{p.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<span class="ui-refresh-picker-chevron" aria-hidden="true">
|
|
||||||
⌄
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
84
frontend/packages/swarm-ui/src/ui/select-field/Listbox.css
Normal file
84
frontend/packages/swarm-ui/src/ui/select-field/Listbox.css
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
/* <Listbox>: the control is `.ui-form-control`'s box (FormField.css) laid out as
|
||||||
|
value + chevron, and the list is a themed box in the top layer. */
|
||||||
|
.ui-listbox {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.ui-select-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5em;
|
||||||
|
text-align: start;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ui-select-trigger:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.ui-select-trigger[aria-expanded="true"] {
|
||||||
|
border-color: var(--purple);
|
||||||
|
}
|
||||||
|
.ui-select-value {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ui-select-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
/* A chevron drawn with borders, so it takes the text colour and needs no glyph
|
||||||
|
a font might lack. */
|
||||||
|
.ui-select-chevron {
|
||||||
|
flex: none;
|
||||||
|
width: 0.45em;
|
||||||
|
height: 0.45em;
|
||||||
|
border-right: 2px solid currentColor;
|
||||||
|
border-bottom: 2px solid currentColor;
|
||||||
|
transform: translateY(-0.12em) rotate(45deg);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.ui-select-trigger[aria-expanded="true"] .ui-select-chevron {
|
||||||
|
transform: translateY(0.1em) rotate(225deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A popover's own UA styles centre it with `inset: 0; margin: auto`; this puts
|
||||||
|
it where `placePopup` says. */
|
||||||
|
.ui-select-list {
|
||||||
|
position: fixed;
|
||||||
|
inset: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25em;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--purple-dim) transparent;
|
||||||
|
list-style: none;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--purple-dim);
|
||||||
|
border-radius: 0.4em;
|
||||||
|
box-shadow: 0 0.25em 0.75em rgba(0, 0, 0, 0.3);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.ui-select-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75em;
|
||||||
|
min-height: 2.5em;
|
||||||
|
padding: 0.3em 0.6em;
|
||||||
|
border-radius: 0.3em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ui-select-option-active {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.ui-select-option-selected {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ui-select-option-selected::after {
|
||||||
|
content: "✓";
|
||||||
|
color: var(--purple);
|
||||||
|
}
|
||||||
269
frontend/packages/swarm-ui/src/ui/select-field/Listbox.tsx
Normal file
269
frontend/packages/swarm-ui/src/ui/select-field/Listbox.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
// <Listbox> — the dropdown a native `<select>` cannot be: its open list is
|
||||||
|
// drawn by the page, so it wears the theme. `SelectField` and the refresh
|
||||||
|
// picker are built on it. Same job as a select (pick one of a few options), so
|
||||||
|
// it follows the WAI-ARIA "select-only combobox" pattern: the control is a
|
||||||
|
// button with `role="combobox"` that keeps focus, and the list is a
|
||||||
|
// `role="listbox"` whose current option the control names through
|
||||||
|
// `aria-activedescendant`.
|
||||||
|
//
|
||||||
|
// Keyboard. Closed: Arrow Up/Down, Enter and Space open it; typing picks the
|
||||||
|
// option starting with those letters. Open: Arrow Up/Down, Home, End, Page Up
|
||||||
|
// and Page Down move, Enter and Space take the highlighted option, Escape and
|
||||||
|
// Tab close it without changing anything.
|
||||||
|
//
|
||||||
|
// The list is a `popover="manual"` element, so it sits in the browser's top
|
||||||
|
// layer: it is not clipped by a scrolling ancestor, and inside a modal
|
||||||
|
// `<dialog>` it shows above the dialog (it is a descendant of the control, so
|
||||||
|
// the dialog's inertness leaves it alone). `position: fixed` coordinates come
|
||||||
|
// from the control's box (`placePopup`). A scroll or resize elsewhere, or a
|
||||||
|
// click outside, closes it, as a native select's list closes.
|
||||||
|
//
|
||||||
|
// Not a native control, so there is no form validation: `required` is only
|
||||||
|
// `aria-required`, and a caller that needs a choice checks it itself.
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import {
|
||||||
|
indexOfValue,
|
||||||
|
moveIndex,
|
||||||
|
nextQuery,
|
||||||
|
placePopup,
|
||||||
|
typeAhead,
|
||||||
|
type ListboxOption,
|
||||||
|
type Placement,
|
||||||
|
} from "./select-nav.js";
|
||||||
|
import "./Listbox.css";
|
||||||
|
|
||||||
|
export function Listbox({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
required,
|
||||||
|
placeholder,
|
||||||
|
ariaLabel,
|
||||||
|
labelledBy,
|
||||||
|
class: extraClass,
|
||||||
|
minListWidth,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
options: ListboxOption[];
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
/** Shown on the control while `value` matches no option. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** The accessible name when there is no visible label. */
|
||||||
|
ariaLabel?: string;
|
||||||
|
/** The id of the element that is the visible label. */
|
||||||
|
labelledBy?: string;
|
||||||
|
/** Extra class on the control, for a compact look. */
|
||||||
|
class?: string;
|
||||||
|
/** The list is at least this wide in px, when the control is narrower. */
|
||||||
|
minListWidth?: number;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [active, setActive] = useState(-1);
|
||||||
|
const [place, setPlace] = useState<Placement | null>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
|
const typed = useRef({ query: "", at: 0 });
|
||||||
|
const listId = `${id}-list`;
|
||||||
|
const optionId = (i: number) => `${id}-opt-${i}`;
|
||||||
|
const selected = indexOfValue(options, value);
|
||||||
|
const shown = selected >= 0 ? options[selected].label : (placeholder ?? "");
|
||||||
|
|
||||||
|
function openList() {
|
||||||
|
if (disabled || options.length === 0) return;
|
||||||
|
setActive(selected >= 0 ? selected : 0);
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeList() {
|
||||||
|
setOpen(false);
|
||||||
|
setPlace(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function take(i: number) {
|
||||||
|
if (i >= 0 && i < options.length && options[i].value !== value) {
|
||||||
|
onChange(options[i].value);
|
||||||
|
}
|
||||||
|
closeList();
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the list in the top layer, measure it, and place it.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const list = listRef.current;
|
||||||
|
const trigger = triggerRef.current;
|
||||||
|
if (!list || !trigger) return;
|
||||||
|
if (!open) {
|
||||||
|
if (list.matches(":popover-open")) list.hidePopover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!list.matches(":popover-open")) list.showPopover();
|
||||||
|
const r = trigger.getBoundingClientRect();
|
||||||
|
setPlace(
|
||||||
|
placePopup(
|
||||||
|
{ top: r.top, bottom: r.bottom, left: r.left, width: r.width },
|
||||||
|
{ width: window.innerWidth, height: window.innerHeight },
|
||||||
|
// The content, plus the border the box-sizing includes.
|
||||||
|
list.scrollHeight + list.offsetHeight - list.clientHeight,
|
||||||
|
minListWidth,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [open, minListWidth]);
|
||||||
|
|
||||||
|
// Keep the highlighted option in view as the keys move it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && place) {
|
||||||
|
listRef.current?.children[active]?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
}, [open, place, active]);
|
||||||
|
|
||||||
|
// A click elsewhere, a scroll of anything but the list, or a resize closes it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
const t = e.target as Node;
|
||||||
|
if (!triggerRef.current?.contains(t) && !listRef.current?.contains(t))
|
||||||
|
closeList();
|
||||||
|
}
|
||||||
|
function onScroll(e: Event) {
|
||||||
|
if (!listRef.current?.contains(e.target as Node)) closeList();
|
||||||
|
}
|
||||||
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
|
window.addEventListener("scroll", onScroll, true);
|
||||||
|
window.addEventListener("resize", closeList);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
window.removeEventListener("scroll", onScroll, true);
|
||||||
|
window.removeEventListener("resize", closeList);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (disabled || e.altKey || e.ctrlKey || e.metaKey) return;
|
||||||
|
const key = e.key;
|
||||||
|
const typing = key.length === 1 && key !== " ";
|
||||||
|
if (typing) {
|
||||||
|
const now = Date.now();
|
||||||
|
const query = nextQuery(typed.current.query, key, now - typed.current.at);
|
||||||
|
typed.current = { query, at: now };
|
||||||
|
const hit = typeAhead(options, open ? active : selected, query);
|
||||||
|
if (hit >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (open) setActive(hit);
|
||||||
|
else if (options[hit].value !== value) onChange(options[hit].value);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open) {
|
||||||
|
if (["ArrowDown", "ArrowUp", "Enter", " ", "Home", "End"].includes(key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
openList();
|
||||||
|
if (key === "Home") setActive(0);
|
||||||
|
if (key === "End") setActive(options.length - 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
case "ArrowUp":
|
||||||
|
case "Home":
|
||||||
|
case "End":
|
||||||
|
case "PageDown":
|
||||||
|
case "PageUp":
|
||||||
|
e.preventDefault();
|
||||||
|
setActive(moveIndex(active, options.length, key));
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
case " ":
|
||||||
|
e.preventDefault();
|
||||||
|
take(active);
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
e.preventDefault();
|
||||||
|
// Only the list: a dialog around it must not close as well.
|
||||||
|
e.stopPropagation();
|
||||||
|
closeList();
|
||||||
|
break;
|
||||||
|
case "Tab":
|
||||||
|
closeList();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="ui-listbox">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id={id}
|
||||||
|
ref={triggerRef}
|
||||||
|
class={
|
||||||
|
"ui-form-control ui-select-trigger" +
|
||||||
|
(extraClass ? " " + extraClass : "")
|
||||||
|
}
|
||||||
|
role="combobox"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={listId}
|
||||||
|
aria-activedescendant={
|
||||||
|
open && active >= 0 ? optionId(active) : undefined
|
||||||
|
}
|
||||||
|
aria-required={required ? true : undefined}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-labelledby={labelledBy}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => (open ? closeList() : openList())}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class={"ui-select-value" + (selected < 0 ? " ui-select-empty" : "")}
|
||||||
|
>
|
||||||
|
{shown}
|
||||||
|
</span>
|
||||||
|
<span class="ui-select-chevron" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<ul
|
||||||
|
id={listId}
|
||||||
|
ref={listRef}
|
||||||
|
class="ui-select-list"
|
||||||
|
role="listbox"
|
||||||
|
aria-labelledby={labelledBy}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
{...{ popover: "manual" }}
|
||||||
|
style={
|
||||||
|
place
|
||||||
|
? {
|
||||||
|
top: `${place.top}px`,
|
||||||
|
left: `${place.left}px`,
|
||||||
|
width: `${place.width}px`,
|
||||||
|
maxHeight: `${place.maxHeight}px`,
|
||||||
|
}
|
||||||
|
: { visibility: "hidden" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{options.map((o, i) => (
|
||||||
|
<li
|
||||||
|
key={o.value}
|
||||||
|
id={optionId(i)}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === selected}
|
||||||
|
class={
|
||||||
|
"ui-select-option" +
|
||||||
|
(i === active ? " ui-select-option-active" : "") +
|
||||||
|
(i === selected ? " ui-select-option-selected" : "")
|
||||||
|
}
|
||||||
|
// A press must not take focus off the control.
|
||||||
|
onPointerDown={(e) => e.preventDefault()}
|
||||||
|
onPointerMove={() => i !== active && setActive(i)}
|
||||||
|
onClick={() => take(i)}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
// <SelectField> — a labelled `<select>`, `TextField`'s sibling for the
|
// <SelectField> — a labelled dropdown (`Listbox`, which draws its own themed
|
||||||
|
// list instead of the browser's), `TextField`'s sibling for the
|
||||||
// "pick one of these" shape (the hive-choice dropdown the create-agent
|
// "pick one of these" shape (the hive-choice dropdown the create-agent
|
||||||
// form needs is the motivating caller). Options are plain
|
// form needs is the motivating caller). Options are plain
|
||||||
// value/label pairs, not `ComponentChildren` — every real caller so far
|
// value/label pairs, not `ComponentChildren` — every real caller so far
|
||||||
// has flat string options, and generic children would need a second
|
// has flat string options, and generic children would need a second
|
||||||
// primitive (`SelectField.Option`) for zero real benefit today.
|
// primitive (`SelectField.Option`) for zero real benefit today.
|
||||||
import { FormField } from "../form-field/FormField.js";
|
import { FormField } from "../form-field/FormField.js";
|
||||||
|
import { Listbox } from "./Listbox.js";
|
||||||
|
|
||||||
export interface SelectOption {
|
export interface SelectOption {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -26,36 +28,31 @@ export function SelectField({
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
options: SelectOption[];
|
options: SelectOption[];
|
||||||
|
// Only `aria-required`: the control is not a native select, so it has no
|
||||||
|
// form validation. A caller that needs a choice checks it itself.
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
// Rendered as a disabled, always-first `value=""` option — for a
|
// Shown on the control while `value` matches no option (it is not a
|
||||||
// required select with no default (e.g. a multi-hive roster: no
|
// listed option) — for a required select with no default (e.g. a
|
||||||
// single right answer to pre-select), so the control shows something
|
// multi-hive roster: no single right answer to pre-select), so the
|
||||||
// other than silently landing on whatever option happens to be first.
|
// control shows something other than silently landing on whatever
|
||||||
// Omit when the field always has a real default (`value` never `''`).
|
// option happens to be first. Omit when the field always has a real
|
||||||
|
// default (`value` never `''`).
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const labelId = `${id}-label`;
|
||||||
return (
|
return (
|
||||||
<FormField label={label} htmlFor={id}>
|
<FormField label={label} labelId={labelId}>
|
||||||
<select
|
<Listbox
|
||||||
id={id}
|
id={id}
|
||||||
class="ui-form-control"
|
labelledBy={labelId}
|
||||||
value={value}
|
value={value}
|
||||||
|
options={options}
|
||||||
|
onChange={onChange}
|
||||||
required={required}
|
required={required}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
|
placeholder={placeholder}
|
||||||
>
|
/>
|
||||||
{placeholder !== undefined && (
|
|
||||||
<option value="" disabled>
|
|
||||||
{placeholder}
|
|
||||||
</option>
|
|
||||||
)}
|
|
||||||
{options.map((o) => (
|
|
||||||
<option key={o.value} value={o.value}>
|
|
||||||
{o.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
frontend/packages/swarm-ui/src/ui/select-field/select-nav.ts
Normal file
132
frontend/packages/swarm-ui/src/ui/select-field/select-nav.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
// What <Listbox> decides without a DOM: which option the arrow keys, Home and
|
||||||
|
// End land on, which one typing picks, and where the list opens. Plain
|
||||||
|
// functions of their arguments, so `npm test` can run them.
|
||||||
|
|
||||||
|
export interface ListboxOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The index of the option holding `value`, or -1. */
|
||||||
|
export function indexOfValue(options: ListboxOption[], value: string): number {
|
||||||
|
return options.findIndex((o) => o.value === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// How far Page Up and Page Down jump.
|
||||||
|
const PAGE = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The active option after `key`, among `count` options. Arrow keys stop at the
|
||||||
|
* ends, they do not wrap. A key that moves nothing leaves `active` as it was.
|
||||||
|
*/
|
||||||
|
export function moveIndex(active: number, count: number, key: string): number {
|
||||||
|
if (count <= 0) return -1;
|
||||||
|
const last = count - 1;
|
||||||
|
switch (key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
return Math.min(active + 1, last);
|
||||||
|
case "ArrowUp":
|
||||||
|
return Math.max(active - 1, 0);
|
||||||
|
case "PageDown":
|
||||||
|
return Math.min(active + PAGE, last);
|
||||||
|
case "PageUp":
|
||||||
|
return Math.max(active - PAGE, 0);
|
||||||
|
case "Home":
|
||||||
|
return 0;
|
||||||
|
case "End":
|
||||||
|
return last;
|
||||||
|
default:
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a pause ends a typed word, in milliseconds. */
|
||||||
|
export const TYPE_AHEAD_MS = 700;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text typed so far once `key` is pressed: it continues the last word when
|
||||||
|
* the pause since the previous key was short, and starts a new one otherwise.
|
||||||
|
*/
|
||||||
|
export function nextQuery(
|
||||||
|
previous: string,
|
||||||
|
key: string,
|
||||||
|
gapMs: number,
|
||||||
|
): string {
|
||||||
|
return gapMs > TYPE_AHEAD_MS ? key : previous + key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The option typing `query` picks, searching on from `from` and wrapping
|
||||||
|
* round, or -1 when none starts with it. One letter, or that letter typed over
|
||||||
|
* and over, steps through the options that start with it; a longer word stays
|
||||||
|
* on the current option while it still matches.
|
||||||
|
*/
|
||||||
|
export function typeAhead(
|
||||||
|
options: ListboxOption[],
|
||||||
|
from: number,
|
||||||
|
query: string,
|
||||||
|
): number {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
if (q === "" || options.length === 0) return -1;
|
||||||
|
const repeated = q.length > 1 && [...q].every((c) => c === q[0]);
|
||||||
|
const needle = repeated ? q[0] : q;
|
||||||
|
const start = needle.length === 1 ? from + 1 : Math.max(from, 0);
|
||||||
|
for (let step = 0; step < options.length; step++) {
|
||||||
|
const i = (start + step) % options.length;
|
||||||
|
if (options[i].label.toLowerCase().startsWith(needle)) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Box {
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Placement {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
maxHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Space kept between the list and the viewport's edge, and between the list
|
||||||
|
// and the control.
|
||||||
|
const EDGE_PX = 8;
|
||||||
|
const GAP_PX = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a list `contentHeight` tall opens for a control at `trigger`: under it
|
||||||
|
* when it fits, else above it if that has more room, else under it with a
|
||||||
|
* scroll. At least as wide as the control, and kept inside the viewport.
|
||||||
|
*/
|
||||||
|
export function placePopup(
|
||||||
|
trigger: Box,
|
||||||
|
viewport: { width: number; height: number },
|
||||||
|
contentHeight: number,
|
||||||
|
minWidth = 0,
|
||||||
|
): Placement {
|
||||||
|
const below = Math.max(
|
||||||
|
viewport.height - trigger.bottom - EDGE_PX - GAP_PX,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const above = Math.max(trigger.top - EDGE_PX - GAP_PX, 0);
|
||||||
|
const openUp = contentHeight > below && above > below;
|
||||||
|
const maxHeight = Math.min(contentHeight, openUp ? above : below);
|
||||||
|
const width = Math.min(
|
||||||
|
Math.max(trigger.width, minWidth),
|
||||||
|
Math.max(viewport.width - 2 * EDGE_PX, 0),
|
||||||
|
);
|
||||||
|
const left = Math.min(
|
||||||
|
Math.max(trigger.left, EDGE_PX),
|
||||||
|
Math.max(viewport.width - EDGE_PX - width, EDGE_PX),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
top: openUp ? trigger.top - GAP_PX - maxHeight : trigger.bottom + GAP_PX,
|
||||||
|
left,
|
||||||
|
width,
|
||||||
|
maxHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
113
frontend/packages/swarm-ui/test/select-nav.test.mjs
Normal file
113
frontend/packages/swarm-ui/test/select-nav.test.mjs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
// What the styled dropdown's keys and placement do. Run by `npm test`.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import {
|
||||||
|
indexOfValue,
|
||||||
|
moveIndex,
|
||||||
|
nextQuery,
|
||||||
|
placePopup,
|
||||||
|
TYPE_AHEAD_MS,
|
||||||
|
typeAhead,
|
||||||
|
} from "../src/ui/select-field/select-nav.ts";
|
||||||
|
|
||||||
|
const opts = ["alpha", "beta", "bravo", "charlie", "Delta"].map((label) => ({
|
||||||
|
value: label.toLowerCase(),
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("the option holding a value is found, or -1", () => {
|
||||||
|
assert.equal(indexOfValue(opts, "bravo"), 2);
|
||||||
|
assert.equal(indexOfValue(opts, "nope"), -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("arrow keys step and stop at the ends; Home and End jump", () => {
|
||||||
|
assert.equal(moveIndex(1, 5, "ArrowDown"), 2);
|
||||||
|
assert.equal(moveIndex(4, 5, "ArrowDown"), 4, "stops at the last");
|
||||||
|
assert.equal(moveIndex(1, 5, "ArrowUp"), 0);
|
||||||
|
assert.equal(moveIndex(0, 5, "ArrowUp"), 0, "stops at the first");
|
||||||
|
assert.equal(moveIndex(2, 5, "Home"), 0);
|
||||||
|
assert.equal(moveIndex(2, 5, "End"), 4);
|
||||||
|
assert.equal(moveIndex(0, 30, "PageDown"), 10);
|
||||||
|
assert.equal(moveIndex(25, 30, "PageDown"), 29);
|
||||||
|
assert.equal(moveIndex(3, 30, "PageUp"), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a key that moves nothing, or no options, leave the active one alone", () => {
|
||||||
|
assert.equal(moveIndex(2, 5, "a"), 2);
|
||||||
|
assert.equal(moveIndex(0, 0, "ArrowDown"), -1);
|
||||||
|
assert.equal(
|
||||||
|
moveIndex(-1, 3, "ArrowDown"),
|
||||||
|
0,
|
||||||
|
"from nothing active, the first",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("typing a letter steps through the options starting with it, wrapping", () => {
|
||||||
|
assert.equal(typeAhead(opts, 0, "b"), 1);
|
||||||
|
assert.equal(typeAhead(opts, 1, "b"), 2);
|
||||||
|
assert.equal(typeAhead(opts, 2, "b"), 1, "wraps round to the first b");
|
||||||
|
assert.equal(typeAhead(opts, 0, "d"), 4, "case does not matter");
|
||||||
|
assert.equal(typeAhead(opts, 0, "z"), -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the same letter typed again steps on; a longer word narrows in place", () => {
|
||||||
|
assert.equal(typeAhead(opts, 1, "bb"), 2, "bb is b again");
|
||||||
|
assert.equal(typeAhead(opts, 1, "br"), 2);
|
||||||
|
assert.equal(typeAhead(opts, 2, "br"), 2, "stays while it still matches");
|
||||||
|
assert.equal(typeAhead(opts, 0, ""), -1);
|
||||||
|
assert.equal(typeAhead([], 0, "a"), -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a short pause continues the word, a long one starts a new one", () => {
|
||||||
|
assert.equal(nextQuery("b", "r", 100), "br");
|
||||||
|
assert.equal(nextQuery("br", "c", TYPE_AHEAD_MS + 1), "c");
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewport = { width: 1000, height: 800 };
|
||||||
|
|
||||||
|
test("the list opens under the control when it fits", () => {
|
||||||
|
const p = placePopup(
|
||||||
|
{ top: 100, bottom: 140, left: 50, width: 200 },
|
||||||
|
viewport,
|
||||||
|
120,
|
||||||
|
);
|
||||||
|
assert.deepEqual(p, { top: 144, left: 50, width: 200, maxHeight: 120 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it opens above when under is too short and above has more room", () => {
|
||||||
|
const p = placePopup(
|
||||||
|
{ top: 700, bottom: 740, left: 50, width: 200 },
|
||||||
|
viewport,
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
assert.equal(p.maxHeight, 300);
|
||||||
|
assert.equal(p.top, 700 - 4 - 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it scrolls under the control when neither side fits it", () => {
|
||||||
|
const p = placePopup(
|
||||||
|
{ top: 100, bottom: 140, left: 50, width: 200 },
|
||||||
|
{ width: 1000, height: 300 },
|
||||||
|
900,
|
||||||
|
);
|
||||||
|
// 300 - 140 - 8 - 4 under; 100 - 12 above: under has more here.
|
||||||
|
assert.equal(p.top, 144);
|
||||||
|
assert.equal(p.maxHeight, 148);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it is at least the minimum width and stays inside the viewport", () => {
|
||||||
|
const p = placePopup(
|
||||||
|
{ top: 100, bottom: 140, left: 960, width: 30 },
|
||||||
|
viewport,
|
||||||
|
100,
|
||||||
|
120,
|
||||||
|
);
|
||||||
|
assert.equal(p.width, 120);
|
||||||
|
assert.equal(p.left, 1000 - 8 - 120);
|
||||||
|
const q = placePopup(
|
||||||
|
{ top: 0, bottom: 40, left: -20, width: 100 },
|
||||||
|
viewport,
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
assert.equal(q.left, 8);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue