feat(popover): keyboard + focus + ARIA layer for the popover platform (R8 S2)

Add the ViewSwitcher-grade menu model to the R8 popover platform, via
Floating UI's idioms rather than a second hand-rolled roving engine:
useListNavigation (↑/↓/Home/End roving, focusItemOnOpen so both click- and
key-opens land on the first item), useTypeahead, and FloatingFocusManager
(trap into the surface, return focus to the trigger on close). A new
<PopoverItem> self-registers for navigation and carries the right role
(menuitem / menuitemradio+aria-checked / option+aria-selected); menu/listbox
surfaces are focus-managed by default.

Wire the ChannelTags editor as the pilot: tag toggles become menuitemradio,
and the inline create-input stopPropagations non-Escape keys so arrows and
typed chars stay in the field (typeahead off for that menu).
This commit is contained in:
2026-07-30 23:33:50 +02:00
parent f091247624
commit 5d1854b808
2 changed files with 179 additions and 16 deletions
+164 -10
View File
@@ -1,4 +1,13 @@
import { type CSSProperties, type ReactNode } from "react";
import {
createContext,
useContext,
useEffect,
useRef,
useState,
type ButtonHTMLAttributes,
type CSSProperties,
type ReactNode,
} from "react";
import {
useFloating,
autoUpdate,
@@ -12,10 +21,14 @@ import {
useFocus,
useDismiss,
useRole,
useListNavigation,
useTypeahead,
useInteractions,
useFloatingNodeId,
useListItem,
safePolygon,
FloatingNode,
FloatingList,
FloatingPortal,
FloatingFocusManager,
type Placement,
@@ -33,7 +46,18 @@ import {
// Positioning is portalled through FloatingPortal (escapes ancestor stacking contexts — same job the
// house <Overlay> did for TagsCell). Every popover is a node in the app's <FloatingTree> so Escape /
// outside-press dispatch is topmost-only across nested layers (S3 folds the Modal + players in too).
// Focus management (trap + return-to-trigger, roving nav) arrives in S2 via `focusManaged`.
//
// S2 (this layer) adds the keyboard/ARIA model the roles promise, mirroring `ViewSwitcher` (the house's
// textbook menu) but via Floating UI's idioms rather than a second hand-rolled roving engine:
// - useListNavigation → roving focus over the registered items (↑/↓/Home/End, loop), menu/listbox only
// - useTypeahead → type-to-focus a matching item by its label
// - FloatingFocusManager → focus moves into the surface on open and RETURNS to the trigger on close
// (a body-portaled popover sits at the end of the document; without this, Tab can't reach it and
// Escape strands focus on <body>). Non-modal so the rest of the page stays interactive.
// Call sites render their options as <PopoverItem> — it self-registers (useListItem) and carries the
// right role (menuitem / menuitemradio+aria-checked / option+aria-selected). A menu that also holds a
// free-text input (the tag creator) must stopPropagation non-Escape keys on that input so ↑/↓ and
// typeahead act on the text, not the list — see ChannelTags.
// Internal to the platform; call sites pass the string literal via `mode`, they don't name the type.
type PopoverMode = "menu" | "listbox" | "hover" | "dialog";
@@ -53,6 +77,9 @@ export interface AnchoredPopoverOptions {
/** Cap the panel height to the available space (adds the `size` middleware). Off by default so a
* caller's own max-height class wins; turn on for long lists that should shrink to fit. */
fitHeight?: boolean;
/** Type-to-focus a matching item by label (menu/listbox only). Default on; turn OFF for menus that
* contain a free-text input, where character keys belong to the text field. */
typeahead?: boolean;
}
export type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;
@@ -60,9 +87,17 @@ export type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;
export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
const mode = opts.mode ?? "menu";
const isHover = mode === "hover";
// Roving-keyboard list surfaces: a menu of actions or a listbox of options. A "dialog" popover holds
// arbitrary focusable content (its own Tab order), and "hover" is a passive tooltip — neither roves.
const isNav = mode === "menu" || mode === "listbox";
// null when not inside a <FloatingTree> — the hook still works, just without nested-layer awareness.
const nodeId = useFloatingNodeId();
// Item registry for list navigation + typeahead, populated by <PopoverItem> via useListItem.
const elementsRef = useRef<Array<HTMLElement | null>>([]);
const labelsRef = useRef<Array<string | null>>([]);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const { refs, floatingStyles, context, middlewareData, isPositioned } = useFloating({
nodeId,
open: opts.open,
@@ -87,6 +122,12 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
],
});
// A stale active item would highlight (and steal the next open's initial focus) the second time a
// menu opens; clear it whenever the surface closes.
useEffect(() => {
if (!opts.open) setActiveIndex(null);
}, [opts.open]);
const click = useClick(context, { enabled: !isHover });
const hover = useHover(context, {
enabled: isHover,
@@ -97,31 +138,119 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
const focus = useFocus(context, { enabled: isHover });
const dismiss = useDismiss(context);
const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : "menu") });
const listNav = useListNavigation(context, {
enabled: isNav,
listRef: elementsRef,
activeIndex,
onNavigate: setActiveIndex,
loop: true,
// Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens,
// which strands focus on the menu container — arrows then can't enter the roving list at all).
focusItemOnOpen: true,
});
const typeahead = useTypeahead(context, {
enabled: isNav && (opts.typeahead ?? true),
listRef: labelsRef,
activeIndex,
onMatch: setActiveIndex,
});
const interactions = useInteractions([click, hover, focus, dismiss, role]);
const interactions = useInteractions([click, hover, focus, dismiss, role, listNav, typeahead]);
return {
nodeId,
mode,
isNav,
open: opts.open,
refs,
floatingStyles,
context,
isPositioned,
referenceHidden: middlewareData.hide?.referenceHidden ?? false,
elementsRef,
labelsRef,
activeIndex,
...interactions,
};
}
// Carries the active index + item-props getter down to <PopoverItem>. FloatingList (in PopoverSurface)
// separately owns the elementsRef/labelsRef registration; this context is the active-state channel.
interface PopoverListContextValue {
activeIndex: number | null;
getItemProps: AnchoredPopover["getItemProps"];
}
const PopoverListContext = createContext<PopoverListContextValue | null>(null);
type ItemRole = "menuitem" | "menuitemradio" | "option";
/** One keyboard-navigable option inside a menu/listbox `PopoverSurface`. Self-registers for roving
* focus + typeahead and applies the correct ARIA. Use `menuitemradio`+`checked` for a toggle,
* `option`+`selected` for a listbox choice, plain `menuitem` for an action. */
export function PopoverItem({
children,
onSelect,
role = "menuitem",
checked,
selected,
disabled,
className,
/** Typeahead match string; defaults to the text child. Set explicitly when children aren't plain text. */
label,
...rest
}: {
children: ReactNode;
onSelect?: () => void;
role?: ItemRole;
checked?: boolean;
selected?: boolean;
disabled?: boolean;
className?: string;
label?: string;
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "role" | "children" | "className">) {
const ctx = useContext(PopoverListContext);
const textLabel = label ?? (typeof children === "string" ? children : undefined);
const { ref, index } = useListItem({ label: textLabel });
const active = ctx?.activeIndex === index;
const ariaProps =
role === "menuitemradio"
? { role, "aria-checked": !!checked }
: role === "option"
? { role, "aria-selected": !!selected }
: { role };
const handlers = ctx
? ctx.getItemProps({ onClick: () => !disabled && onSelect?.() })
: { onClick: () => !disabled && onSelect?.() };
return (
<button
ref={ref}
type="button"
// Roving tabindex: only the active item is in the Tab order; arrows move focus between them.
tabIndex={active ? 0 : -1}
disabled={disabled}
className={className}
{...ariaProps}
{...rest}
{...handlers}
>
{children}
</button>
);
}
/** Renders the popover panel: the FloatingTree node + portal + the positioned surface. Merges the
* library's floating ref/props with the caller's className/style (and an optional extra ref, e.g. a
* scroll-fade callback ref). `focusManaged` wraps it in a FloatingFocusManager (S2). */
* library's floating ref/props with the caller's className/style. Menu/listbox surfaces trap+return
* focus and host the list-navigation registry by default; pass `focusManaged={false}` to opt a
* surface out (e.g. a hover card). */
export function PopoverSurface({
popover,
className,
style,
children,
focusManaged = false,
focusManaged,
initialFocus,
}: {
popover: AnchoredPopover;
@@ -136,11 +265,16 @@ export function PopoverSurface({
refs,
floatingStyles,
getFloatingProps,
getItemProps,
nodeId,
referenceHidden,
isPositioned,
open,
mode,
isNav,
activeIndex,
elementsRef,
labelsRef,
} = popover;
const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as {
style?: CSSProperties;
@@ -149,6 +283,9 @@ export function PopoverSurface({
if (!open) return null;
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
// Everything but a passive hover card manages focus by default (trap into the surface, return to
// the trigger on close).
const managed = focusManaged ?? mode !== "hover";
const surface = (
<div
@@ -175,15 +312,32 @@ export function PopoverSurface({
</div>
);
// Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested
// <PopoverItem>s can self-register and rove.
const positioned = isNav ? (
<PopoverListContext.Provider value={{ activeIndex, getItemProps }}>
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
{surface}
</FloatingList>
</PopoverListContext.Provider>
) : (
surface
);
return (
<FloatingNode id={nodeId}>
<FloatingPortal>
{focusManaged ? (
<FloatingFocusManager context={context} modal={false} initialFocus={initialFocus}>
{surface}
{managed ? (
<FloatingFocusManager
context={context}
modal={false}
initialFocus={initialFocus}
returnFocus
>
{positioned}
</FloatingFocusManager>
) : (
surface
positioned
)}
</FloatingPortal>
</FloatingNode>
+15 -6
View File
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Check, Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
@@ -66,11 +66,13 @@ function TagEditorMenu({
{userTags.map((tg) => {
const on = attached.has(tg.id);
return (
<button
<PopoverItem
key={tg.id}
type="button"
onClick={() => onToggle(tg.id)}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
role="menuitemradio"
checked={on}
onSelect={() => onToggle(tg.id)}
label={tg.name}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-none transition focus-visible:bg-card ${
on ? "text-fg" : "text-muted hover:bg-card"
}`}
>
@@ -82,7 +84,7 @@ function TagEditorMenu({
{on && <Check className="w-2.5 h-2.5" />}
</span>
<span className="truncate">{tg.name}</span>
</button>
</PopoverItem>
);
})}
{userTags.length === 0 && (
@@ -94,6 +96,11 @@ function TagEditorMenu({
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Escape bubbles to the popover's dismiss (close + refocus the trigger); every other key —
// arrows, Home/End, typed characters — is text editing here, so keep it out of the menu's
// list-navigation/typeahead handlers on the surrounding surface.
if (e.key === "Escape") return;
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
submit();
@@ -194,6 +201,8 @@ export function ChannelTagsControl({
onOpenChange: setEditorOpen,
mode: "menu",
placement: "bottom-start",
// The menu contains a free-text "create tag" input — type-to-focus would fight the text field.
typeahead: false,
});
const [overflowOpen, setOverflowOpen] = useState(false);