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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user