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
|
|
@ -14,7 +14,7 @@
|
|||
//
|
||||
// `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
|
||||
// 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
|
||||
// swarm's actual hives, same reasoning `Ident.parse` gets client-side
|
||||
// pattern validation for `name`.
|
||||
|
|
@ -159,8 +159,8 @@ export function CreateAgentForm({
|
|||
setHives(data);
|
||||
// Single-hive swarms are the common case — default it rather than
|
||||
// making the operator pick the only option. Multi-hive swarms get
|
||||
// no default (the placeholder stays selected), so `required` on
|
||||
// the select below forces an explicit choice per mara's scope.
|
||||
// no default (the placeholder stays selected), so the identity step
|
||||
// cannot be left until one is picked (`stepProblem`), per mara's scope.
|
||||
if (data.length === 1) setHive(data[0].name);
|
||||
})().catch((e: unknown) => setHivesError({ detail: String(e) }));
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@
|
|||
border-radius: 0.4em;
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ui-refresh-picker:hover,
|
||||
.ui-refresh-picker:focus-within {
|
||||
|
|
@ -21,22 +20,21 @@
|
|||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
/* The select drives the interaction (native listbox, keyboard, a11y) but
|
||||
contributes no chrome of its own — its own box is invisible, sized to
|
||||
just its selected option's text, and its default arrow is stripped
|
||||
since the chevron span stands in for it. */
|
||||
.ui-refresh-picker-select {
|
||||
appearance: none;
|
||||
/* The Listbox contributes only its value and chevron: its own box is
|
||||
invisible and as wide as the value, so the picker's border is the one
|
||||
that shows. */
|
||||
.ui-refresh-picker .ui-listbox {
|
||||
width: auto;
|
||||
}
|
||||
.ui-refresh-picker-select.ui-form-control {
|
||||
width: auto;
|
||||
min-height: 2.5em;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ui-refresh-picker-icon,
|
||||
.ui-refresh-picker-chevron {
|
||||
.ui-refresh-picker-icon {
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,18 +14,17 @@
|
|||
// passive-until-touched setting that lives in a panel's title row
|
||||
// (`Panel`'s `actions` slot), not a form. Visually quiet (no border/
|
||||
// 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
|
||||
// native `<select>` still drives it (no custom dropdown/listbox to
|
||||
// reinvent) — `appearance: none` strips its default arrow so the
|
||||
// chevron span can stand in for it, and the visible "value" text is
|
||||
// just whichever `<option>` the browser is already rendering as
|
||||
// selected, not a second copy of the label kept in sync by hand.
|
||||
// is, so it reads as chrome rather than another input to fill in. The
|
||||
// dropdown is the shared `Listbox` (its list is themed, not the browser's),
|
||||
// restyled to have no chrome of its own: the chevron is the Listbox's, and the
|
||||
// visible value is its selected option.
|
||||
//
|
||||
// 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
|
||||
// callback doesn't clobber an input the operator is mid-edit on — this
|
||||
// hook only decides *when* to call it.
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import { Listbox } from "../select-field/Listbox.js";
|
||||
import "./RefreshInterval.css";
|
||||
|
||||
// `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];
|
||||
return (
|
||||
// A `<label>` wrapper (not a bare `<span>`) so the icon/chevron
|
||||
// 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}>
|
||||
<div class="ui-refresh-picker">
|
||||
<span class="ui-refresh-picker-icon" aria-hidden="true">
|
||||
🕐
|
||||
</span>
|
||||
<select
|
||||
<Listbox
|
||||
id={id}
|
||||
class="ui-refresh-picker-select"
|
||||
aria-label="refresh interval"
|
||||
ariaLabel="refresh interval"
|
||||
value={current.label}
|
||||
onChange={(e) => {
|
||||
const label = (e.target as HTMLSelectElement).value;
|
||||
options={PRESETS.map((p) => ({ value: p.label, label: p.label }))}
|
||||
minListWidth={96}
|
||||
onChange={(label) => {
|
||||
const preset = PRESETS.find((p) => p.label === label);
|
||||
if (preset) onChange(preset.value);
|
||||
}}
|
||||
>
|
||||
{PRESETS.map((p) => (
|
||||
<option key={p.label} value={p.label}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span class="ui-refresh-picker-chevron" aria-hidden="true">
|
||||
⌄
|
||||
</span>
|
||||
</label>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
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
|
||||
// form needs is the motivating caller). Options are plain
|
||||
// value/label pairs, not `ComponentChildren` — every real caller so far
|
||||
// has flat string options, and generic children would need a second
|
||||
// primitive (`SelectField.Option`) for zero real benefit today.
|
||||
import { FormField } from "../form-field/FormField.js";
|
||||
import { Listbox } from "./Listbox.js";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
|
|
@ -26,36 +28,31 @@ export function SelectField({
|
|||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
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;
|
||||
disabled?: boolean;
|
||||
// Rendered as a disabled, always-first `value=""` option — for a
|
||||
// required select with no default (e.g. a multi-hive roster: no
|
||||
// single right answer to pre-select), so the control shows something
|
||||
// other than silently landing on whatever option happens to be first.
|
||||
// Omit when the field always has a real default (`value` never `''`).
|
||||
// Shown on the control while `value` matches no option (it is not a
|
||||
// listed option) — for a required select with no default (e.g. a
|
||||
// multi-hive roster: no single right answer to pre-select), so the
|
||||
// control shows something other than silently landing on whatever
|
||||
// option happens to be first. Omit when the field always has a real
|
||||
// default (`value` never `''`).
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const labelId = `${id}-label`;
|
||||
return (
|
||||
<FormField label={label} htmlFor={id}>
|
||||
<select
|
||||
<FormField label={label} labelId={labelId}>
|
||||
<Listbox
|
||||
id={id}
|
||||
class="ui-form-control"
|
||||
labelledBy={labelId}
|
||||
value={value}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
{placeholder !== undefined && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</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