Manage paired displays: themed select, reassignment, live list, no revoked clutter
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m28s
CI / image-build (pull_request) Successful in 1m9s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 8m58s

Real-device review of the platform display flow found four defects in
display management:

- Shared SelectMenu in @ekklesia/ui (additive): an Ekklesia-drawn
  single choice following the WAI-ARIA select-only combobox pattern,
  replacing the native select for "Show on this screen". It has a
  themed list, a chevron inside the control, keyboard support (arrows,
  Home/End, type-ahead, Enter/Space/Tab, and Escape that closes only
  the list), pointer and touch support, and focus that stays on the
  control. It is an ek-control, so it keeps the 16px phone size.
- Newly paired displays appear without a reload. Approval doesn't
  create the display; the screen does when it claims its credential a
  few seconds later. The page now shows a "Waiting for the screen to
  connect" placeholder and refetches every second until the new display
  appears (for up to a minute), and refreshes the list every 30
  seconds.
- What a display shows can change after pairing. In the single-target
  Stage 1 model, a display's current target is its one active display
  permission, so no migration is needed.
  PUT /api/v1/devices/:id/display, with core.devices.manage, a trusted
  origin and a bounded body, revokes its other active permission rows
  (kept as history) and grants the chosen module's declared display
  permission, in one transaction on the same device. The screen notices
  within about 30 seconds: a paired device on a module page that is no
  longer set to show it goes to /display/setup instead of re-pairing,
  and /display/setup opens the new target.
- Revoked displays leave the normal list at once. Their rows stay in
  the database as history.

Each display card now shows its name, Online/Offline/Not connected yet,
"Showing: ...", last seen and paired dates, and Change and Revoke.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-16 18:29:15 -05:00
co-authored by Claude Opus 5.5
parent 300d9eab51
commit d9dafcb0f7
18 changed files with 1892 additions and 197 deletions
+1
View File
@@ -49,6 +49,7 @@ export {
type ConfirmationTone,
} from "./confirmation.js";
export { EkklesiaUiStyles } from "./EkklesiaUiStyles.js";
export { SelectMenu, type SelectMenuOption, type SelectMenuProps } from "./select-menu.js";
export {
Badge,
type BadgeProps,
+254
View File
@@ -0,0 +1,254 @@
import {
type FocusEvent,
type KeyboardEvent,
type ReactElement,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { classNames } from "./class-names.js";
/** One choice in a {@link SelectMenu}. */
export interface SelectMenuOption<Value extends string> {
readonly value: Value;
readonly label: string;
}
export interface SelectMenuProps<Value extends string> {
readonly value: Value;
readonly options: readonly SelectMenuOption<Value>[];
readonly onChange: (value: Value) => void;
/** The control's id, so a `Field` label names it. */
readonly id?: string;
readonly disabled?: boolean | undefined;
readonly "aria-describedby"?: string | undefined;
readonly "aria-invalid"?: boolean | undefined;
readonly className?: string;
readonly "data-testid"?: string;
}
/**
* A single choice from a short list, drawn by Ekklesia rather than the browser.
*
* A native `<select>` opens a menu the browser draws itself — a square popup, the platform's own
* selection colour, an arrow against the edge — that matches neither theme. This is the WAI-ARIA
* select-only combobox: a `role="combobox"` button showing the current choice, which opens a
* `role="listbox"` of `role="option"`s. It renders in place, like every element here (never
* portalled), so it works inside a module's isolated root and inside a `Dialog`.
*
* - Closed: Enter, Space, ArrowDown, or ArrowUp opens it on the current choice; Home and End open it
* on the first or last.
* - Open: the arrows, Home, and End move; typing a letter moves to the next choice starting with it;
* Enter or Space chooses and closes; Escape closes without choosing; Tab chooses and moves on.
* - Pointer and touch: pressing the control toggles it, pressing a choice chooses it, and pressing
* anywhere else closes it.
*
* Focus stays on the control throughout — the active choice is `aria-activedescendant` — so closing
* never has to move focus back. Escape stops at the control, so a dialog it sits in stays open. Its
* size and type follow the shared control (`ek-control`), including the 16px phone size that keeps
* iOS from zooming (ADR 0065).
*/
export function SelectMenu<Value extends string>(props: SelectMenuProps<Value>): ReactElement {
const { options, value, onChange } = props;
const generatedId = useId();
const triggerId = props.id ?? `${generatedId}-trigger`;
const listId = `${generatedId}-list`;
const optionId = (index: number): string => `${generatedId}-option-${index}`;
const rootRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const selectedIndex = Math.max(
0,
options.findIndex((option) => option.value === value),
);
const [active, setActive] = useState(selectedIndex);
const current = options[selectedIndex];
const close = useCallback(() => setOpen(false), []);
const openAt = (index: number): void => {
setActive(Math.min(Math.max(index, 0), options.length - 1));
setOpen(true);
};
const choose = (index: number): void => {
const option = options[index];
if (option !== undefined && option.value !== value) {
onChange(option.value);
}
setOpen(false);
};
// A press anywhere outside closes it. Listened for on the control's own root — its shadow root
// inside an isolated module artifact, or the document — never on a global.
useEffect(() => {
const element = rootRef.current;
if (!open || !element) {
return;
}
const root = element.getRootNode() as Document | ShadowRoot;
const onPointerDown = (event: Event): void => {
const target = event.composedPath()[0];
if (!(target instanceof Node) || !element.contains(target)) {
setOpen(false);
}
};
root.addEventListener("pointerdown", onPointerDown);
return () => root.removeEventListener("pointerdown", onPointerDown);
}, [open]);
// Keeps the active choice visible by scrolling the list itself, never the page.
useEffect(() => {
const list = listRef.current;
const item = list?.children.item(active) as HTMLElement | null | undefined;
if (!open || !list || !item) {
return;
}
if (item.offsetTop < list.scrollTop) {
list.scrollTop = item.offsetTop;
} else if (item.offsetTop + item.offsetHeight > list.scrollTop + list.clientHeight) {
list.scrollTop = item.offsetTop + item.offsetHeight - list.clientHeight;
}
}, [active, open]);
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
if (props.disabled) {
return;
}
const last = options.length - 1;
if (!open) {
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
event.preventDefault();
openAt(selectedIndex);
} else if (event.key === "Home") {
event.preventDefault();
openAt(0);
} else if (event.key === "End") {
event.preventDefault();
openAt(last);
}
return;
}
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setActive((index) => Math.min(index + 1, last));
return;
case "ArrowUp":
event.preventDefault();
if (event.altKey) {
choose(active);
} else {
setActive((index) => Math.max(index - 1, 0));
}
return;
case "Home":
event.preventDefault();
setActive(0);
return;
case "End":
event.preventDefault();
setActive(last);
return;
case "Enter":
case " ":
event.preventDefault();
choose(active);
return;
case "Escape":
// Closes the list only: a dialog this sits in stays open.
event.preventDefault();
event.stopPropagation();
close();
return;
case "Tab":
choose(active);
return;
default:
if (event.key.length === 1 && /\S/u.test(event.key)) {
const letter = event.key.toLocaleLowerCase();
const order = [...options.keys()].map((offset) => (active + 1 + offset) % options.length);
const match = order.find((index) =>
options[index]?.label.toLocaleLowerCase().startsWith(letter),
);
if (match !== undefined) {
setActive(match);
}
}
}
};
const onBlur = (event: FocusEvent<HTMLButtonElement>): void => {
const next = event.relatedTarget;
if (!(next instanceof Node) || !rootRef.current?.contains(next)) {
close();
}
};
return (
<div
className={classNames("ek-select-menu", props.className)}
data-open={open ? "true" : undefined}
ref={rootRef}
>
<button
aria-activedescendant={open ? optionId(active) : undefined}
aria-controls={listId}
aria-describedby={props["aria-describedby"]}
aria-expanded={open}
aria-haspopup="listbox"
aria-invalid={props["aria-invalid"]}
className="ek-control ek-focusable ek-select-menu__trigger"
data-testid={props["data-testid"]}
disabled={props.disabled}
id={triggerId}
onBlur={onBlur}
onClick={(event) => {
// Safari does not focus a pressed button; the keyboard (and Escape) belong to the control
// once it is open, so it takes focus itself — without scrolling the page.
event.currentTarget.focus({ preventScroll: true });
if (open) {
close();
} else {
openAt(selectedIndex);
}
}}
onKeyDown={onKeyDown}
role="combobox"
type="button"
>
<span className="ek-select-menu__value">{current?.label ?? ""}</span>
<span aria-hidden="true" className="ek-select-menu__chevron" />
</button>
<div
aria-labelledby={triggerId}
className="ek-select-menu__list"
hidden={!open}
id={listId}
ref={listRef}
role="listbox"
>
{options.map((option, index) => (
// biome-ignore lint/a11y/useFocusableInteractive: in the select-only combobox pattern focus stays on the combobox and the active option is aria-activedescendant, so options are deliberately not focusable.
// biome-ignore lint/a11y/useKeyWithClickEvents: the keyboard is handled on the combobox, which owns focus; this click is the pointer and touch path.
<div
aria-selected={option.value === value}
className="ek-select-menu__option"
data-active={open && index === active ? "true" : undefined}
data-value={option.value}
id={optionId(index)}
key={option.value}
// The choice is taken on click; pressing must not move focus off the control first.
onMouseDown={(event) => event.preventDefault()}
onClick={() => choose(index)}
onPointerEnter={() => setActive(index)}
role="option"
>
<span className="ek-select-menu__label">{option.label}</span>
<span aria-hidden="true" className="ek-select-menu__check" />
</div>
))}
</div>
</div>
);
}
+125
View File
@@ -190,6 +190,131 @@ export const EKKLESIA_UI_STYLESHEET = `
}
}
/*
* SelectMenu: a single choice drawn by Ekklesia, never the browser's own menu. The control is an
* ek-control, so it shares the field's border, radius, height, and the 16px phone size; the chevron
* sits inside its padding, and the list is a raised, themed surface below it.
*/
.ek-select-menu {
position: relative;
width: 100%;
}
.ek-select-menu__trigger {
align-items: center;
cursor: pointer;
display: flex;
gap: ${designToken("spacing.sm")};
justify-content: space-between;
text-align: start;
}
.ek-select-menu__trigger:hover:not(:disabled) {
border-color: ${designToken("border.strong")};
}
.ek-select-menu__trigger:disabled {
cursor: not-allowed;
opacity: ${designToken("state.disabled-opacity")};
}
.ek-select-menu__value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ek-select-menu__chevron {
border-bottom: calc(2 * ${designToken("border.width")}) solid ${designToken("text.secondary")};
border-right: calc(2 * ${designToken("border.width")}) solid ${designToken("text.secondary")};
box-sizing: border-box;
flex: 0 0 auto;
height: ${designToken("spacing.sm")};
margin-inline: ${designToken("spacing.xs")} ${designToken("spacing.2xs")};
transform: translateY(-25%) rotate(45deg);
transition: transform ${designToken("motion.duration-short")} ease;
width: ${designToken("spacing.sm")};
}
.ek-select-menu[data-open] .ek-select-menu__chevron {
transform: translateY(25%) rotate(225deg);
}
.ek-select-menu__list {
background: ${designToken("background.raised")};
border: ${designToken("border.width")} solid ${designToken("border.base")};
border-radius: ${designToken("radius.md")};
box-shadow: ${designToken("shadow.md")};
box-sizing: border-box;
display: grid;
gap: ${designToken("spacing.2xs")};
inset-inline: 0;
list-style: none;
margin: ${designToken("spacing.2xs")} 0 0;
max-height: 16rem;
overflow-y: auto;
overscroll-behavior: contain;
padding: ${designToken("spacing.xs")};
position: absolute;
top: 100%;
z-index: 30;
}
.ek-select-menu__list[hidden] {
display: none;
}
.ek-select-menu__option {
align-items: center;
border-radius: ${designToken("radius.sm")};
color: ${designToken("text.primary")};
cursor: pointer;
display: flex;
font-size: ${designToken("typography.size-sm")};
gap: ${designToken("spacing.sm")};
justify-content: space-between;
min-height: ${designToken("control.height-md")};
padding: 0 ${designToken("control.padding-inline")};
}
.ek-select-menu__option[data-active] {
background: ${designToken("state.hover-background")};
}
.ek-select-menu__option[aria-selected="true"] {
background: ${designToken("state.selected-background")};
color: ${designToken("state.selected-foreground")};
font-weight: ${designToken("typography.weight-bold")};
}
/* The keyboard's active choice is outlined, so it reads apart from the selected one. */
.ek-select-menu__option[data-active][aria-selected="true"],
.ek-select-menu__option[data-active] {
box-shadow: inset 0 0 0 ${designToken("state.focus-visible-width")} ${designToken("accent.focus")};
}
.ek-select-menu__check {
border-bottom: calc(2 * ${designToken("border.width")}) solid currentColor;
border-right: calc(2 * ${designToken("border.width")}) solid currentColor;
box-sizing: border-box;
flex: 0 0 auto;
height: ${designToken("spacing.md")};
transform: translateY(-15%) rotate(45deg);
visibility: hidden;
width: ${designToken("spacing.xs")};
}
.ek-select-menu__option[aria-selected="true"] .ek-select-menu__check {
visibility: visible;
}
@media (forced-colors: active) {
.ek-select-menu__option[data-active] {
outline: ${designToken("state.focus-visible-width")} solid Highlight;
}
}
.ek-control::placeholder {
color: ${designToken("text.muted")};
}
+239
View File
@@ -0,0 +1,239 @@
import { act, type ReactElement, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Dialog, Field, SelectMenu } from "../src/index.js";
import { EKKLESIA_UI_STYLESHEET } from "../src/styles.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
/**
* SelectMenu (ADR 0065): a single choice drawn by Ekklesia — the WAI-ARIA select-only combobox —
* rather than the browser's native menu.
*/
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const OPTIONS = [
{ label: "Nothing yet", value: "" },
{ label: "Signage", value: "signage" },
{ label: "Bulletin", value: "bulletin" },
{ label: "Parent Call", value: "parent-call" },
];
function Harness(props: {
readonly onChange?: (value: string) => void;
readonly disabled?: boolean;
}) {
const [value, setValue] = useState("");
return (
<>
<Field label="Show on this screen">
{(control) => (
<SelectMenu
{...control}
data-testid="menu"
disabled={props.disabled}
onChange={(next) => {
setValue(next);
props.onChange?.(next);
}}
options={OPTIONS}
value={value}
/>
)}
</Field>
<button data-testid="elsewhere" type="button">
Elsewhere
</button>
</>
);
}
function render(node: ReactElement): void {
act(() => root.render(node));
}
function trigger(): HTMLButtonElement {
return container.querySelector('[data-testid="menu"]') as HTMLButtonElement;
}
function list(): HTMLElement {
return container.querySelector('[role="listbox"]') as HTMLElement;
}
function press(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key, ...init });
act(() => {
trigger().dispatchEvent(event);
});
return event;
}
function activeLabel(): string | undefined {
const id = trigger().getAttribute("aria-activedescendant");
return id === null
? undefined
: (container.querySelector(`#${CSS.escape(id)}`)?.textContent ?? "");
}
describe("SelectMenu", () => {
it("is a labelled combobox controlling a listbox of options, never a native select", () => {
render(<Harness />);
expect(container.querySelector("select")).toBeNull();
const control = trigger();
expect(control.tagName).toBe("BUTTON");
// The shared control, so its border, height, and 16px phone size (no iOS zoom) are the field's.
expect(control.classList.contains("ek-control")).toBe(true);
expect(control.getAttribute("role")).toBe("combobox");
expect(control.getAttribute("aria-haspopup")).toBe("listbox");
expect(control.getAttribute("aria-expanded")).toBe("false");
expect(control.getAttribute("aria-controls")).toBe(list().id);
// The Field's label names it.
expect(container.querySelector("label")?.getAttribute("for")).toBe(control.id);
expect(control.textContent).toBe("Nothing yet");
expect(list().hidden).toBe(true);
const options = [...list().querySelectorAll('[role="option"]')];
expect(options.map((option) => option.textContent)).toEqual(OPTIONS.map((o) => o.label));
expect(options.map((option) => option.getAttribute("aria-selected"))).toEqual([
"true",
"false",
"false",
"false",
]);
});
it("opens, moves, and chooses from the keyboard", () => {
const onChange = vi.fn();
render(<Harness onChange={onChange} />);
act(() => trigger().focus());
expect(press("ArrowDown").defaultPrevented).toBe(true);
expect(trigger().getAttribute("aria-expanded")).toBe("true");
expect(list().hidden).toBe(false);
// It opens on the current choice.
expect(activeLabel()).toBe("Nothing yet");
press("ArrowDown");
press("ArrowDown");
expect(activeLabel()).toBe("Bulletin");
press("ArrowUp");
expect(activeLabel()).toBe("Signage");
press("End");
expect(activeLabel()).toBe("Parent Call");
press("Home");
expect(activeLabel()).toBe("Nothing yet");
// Typing a letter moves to the next choice that starts with it.
press("b");
expect(activeLabel()).toBe("Bulletin");
press("Enter");
expect(onChange).toHaveBeenCalledWith("bulletin");
expect(trigger().getAttribute("aria-expanded")).toBe("false");
expect(trigger().textContent).toBe("Bulletin");
// Focus never left the control.
expect(document.activeElement).toBe(trigger());
press(" ");
expect(activeLabel()).toBe("Bulletin");
press("ArrowDown");
press(" ");
expect(onChange).toHaveBeenLastCalledWith("parent-call");
});
it("closes on Escape without choosing, and without closing a dialog it sits in", () => {
const onChange = vi.fn();
const onDismiss = vi.fn();
render(
<Dialog onDismiss={onDismiss} open title="Change">
<Harness onChange={onChange} />
</Dialog>,
);
act(() => trigger().focus());
press("ArrowDown");
press("ArrowDown");
const escapeKey = press("Escape");
expect(escapeKey.defaultPrevented).toBe(true);
expect(trigger().getAttribute("aria-expanded")).toBe("false");
expect(onChange).not.toHaveBeenCalled();
expect(onDismiss).not.toHaveBeenCalled();
expect(document.activeElement).toBe(trigger());
// Closed, Escape is the dialog's again.
press("Escape");
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it("chooses the active option on Tab, as a native select does", () => {
const onChange = vi.fn();
render(<Harness onChange={onChange} />);
act(() => trigger().focus());
press("ArrowDown");
press("ArrowDown");
const tab = press("Tab");
expect(tab.defaultPrevented).toBe(false);
expect(onChange).toHaveBeenCalledWith("signage");
expect(trigger().getAttribute("aria-expanded")).toBe("false");
});
it("works with a pointer: press to open, press an option to choose, press outside to close", () => {
const onChange = vi.fn();
render(<Harness onChange={onChange} />);
act(() => trigger().click());
expect(trigger().getAttribute("aria-expanded")).toBe("true");
// Pressed, it takes focus (Safari would not), so the keyboard and Escape work from here.
expect(document.activeElement).toBe(trigger());
const signage = [...list().querySelectorAll<HTMLElement>('[role="option"]')][1] as HTMLElement;
act(() => signage.click());
expect(onChange).toHaveBeenCalledWith("signage");
expect(trigger().getAttribute("aria-expanded")).toBe("false");
act(() => trigger().click());
act(() => {
(container.querySelector('[data-testid="elsewhere"]') as HTMLElement).dispatchEvent(
new Event("pointerdown", { bubbles: true, composed: true }),
);
});
expect(trigger().getAttribute("aria-expanded")).toBe("false");
expect(onChange).toHaveBeenCalledTimes(1);
});
it("does nothing while disabled", () => {
const onChange = vi.fn();
render(<Harness disabled onChange={onChange} />);
expect(trigger().disabled).toBe(true);
press("ArrowDown");
expect(trigger().getAttribute("aria-expanded")).toBe("false");
});
it("is drawn from tokens: a themed list, and a chevron inside the control's own padding", () => {
expect(EKKLESIA_UI_STYLESHEET).toContain(".ek-select-menu__chevron {");
expect(EKKLESIA_UI_STYLESHEET).toMatch(
/\.ek-select-menu__list \{[^}]*background: var\(--ekklesia-background-raised\)/u,
);
expect(EKKLESIA_UI_STYLESHEET).toMatch(
/\.ek-select-menu__option\[aria-selected="true"\] \{[^}]*var\(--ekklesia-state-selected-background\)/u,
);
// The chevron's turn is a motion token, so reduced motion stills it.
expect(EKKLESIA_UI_STYLESHEET).toMatch(
/\.ek-select-menu__chevron \{[^}]*transition: transform var\(--ekklesia-motion-duration-short\)/u,
);
});
});