Watch
0
0
Fork
You've already forked hyperhive
0

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:
iris 2026-10-25 17:15:04 +01:00
commit a2e8a2bda0
9 changed files with 653 additions and 66 deletions

View file

@ -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 |

View file

@ -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) }));
}, []); }, []);

View file

@ -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;
} }

View file

@ -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>
); );
} }

View 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);
}

View 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>
);
}

View file

@ -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>
); );
} }

View 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,
};
}

View 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);
});