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.
269 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
}
|