Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/ui/select-field/Listbox.tsx
iris a2e8a2bda0 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.
2026-10-25 17:15:04 +01:00

269 lines
8.4 KiB
TypeScript

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