diff --git a/frontend/src/components/AnchoredPopover.tsx b/frontend/src/components/AnchoredPopover.tsx index 95f5d0c6..ed7c023e 100644 --- a/frontend/src/components/AnchoredPopover.tsx +++ b/frontend/src/components/AnchoredPopover.tsx @@ -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 did for TagsCell). Every popover is a node in the app's 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 ). Non-modal so the rest of the page stays interactive. +// Call sites render their options as — 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; @@ -60,9 +87,17 @@ export type AnchoredPopover = ReturnType; 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 — the hook still works, just without nested-layer awareness. const nodeId = useFloatingNodeId(); + // Item registry for list navigation + typeahead, populated by via useListItem. + const elementsRef = useRef>([]); + const labelsRef = useRef>([]); + const [activeIndex, setActiveIndex] = useState(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 . 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(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, "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 ( + + ); +} + /** 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 = (
); + // Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested + // s can self-register and rove. + const positioned = isNav ? ( + + + {surface} + + + ) : ( + surface + ); + return ( - {focusManaged ? ( - - {surface} + {managed ? ( + + {positioned} ) : ( - surface + positioned )} diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index b2a3cb97..b6c6b9d9 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -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 ( - + ); })} {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);