feat(popover): migrate the click menus onto the platform (R8 S4b)
AddToPlaylist, LanguageSwitcher, the Plex DetailCustomizeMenu and ViewSwitcher each had their own anchoring, dismissal and (for ViewSwitcher) keyboard engine. All four now use the platform, so their items are PopoverItems with real menuitemradio/aria-checked, roving focus, typeahead and flip/shift anchoring. Two platform additions this needed: - `selectedIndex`: opening a single-choice menu must land on the choice already in effect, which is what ViewSwitcher's hand-rolled engine did by seeding its roving index to the current option. - `ariaLabel` on PopoverSurface: an icon-only trigger leaves the panel with no name. Fixed while wiring `selectedIndex` up: it stranded focus on the menu container. That path focuses its item synchronously in a layout effect, and FloatingFocusManager's own initial focus then ran afterwards and took focus back; the no-selectedIndex path only escaped this because it defers focus to a microtask while waiting for the item list to populate. On a roving list the list navigation owns initial focus, so the manager is now told to take none (initialFocus -1). Behaviour changes worth naming: - The header LanguageSwitcher no longer opens on hover. The platform focuses the first item on open, so a hover-open would yank focus off whatever the user was doing. Both variants are now click-only, like every other menu in the app. - PrefToggle became a PopoverItem, so the rows callers inject through the menu's `extra` prop join the same roving list as the built-in ones. - AddToPlaylist fetches its list on open, so on the first paint there are no items to focus and focus stays on the trigger (as it did before the migration); arrows move into the list once it lands. Verified in-browser: ViewSwitcher opens on the checked mode, arrows rove, Escape closes and refocuses the trigger; the ChannelTags pilot is unregressed; AddToPlaylist anchors bottom-start without the click reaching the feed card underneath; the rail language menu keeps its right-edge, bottom-aligned anchoring.
This commit is contained in:
@@ -82,6 +82,10 @@ export interface AnchoredPopoverOptions {
|
||||
/** 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;
|
||||
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
|
||||
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
|
||||
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
|
||||
selectedIndex?: number | null;
|
||||
}
|
||||
|
||||
export type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;
|
||||
@@ -149,6 +153,7 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
listRef: elementsRef,
|
||||
activeIndex,
|
||||
onNavigate: setActiveIndex,
|
||||
selectedIndex: opts.selectedIndex ?? null,
|
||||
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).
|
||||
@@ -273,11 +278,15 @@ export function PopoverSurface({
|
||||
focusManaged,
|
||||
initialFocus,
|
||||
passthrough,
|
||||
ariaLabel,
|
||||
}: {
|
||||
popover: AnchoredPopover;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
children: ReactNode;
|
||||
/** Accessible name for the panel itself — a menu whose trigger is icon-only has nothing else to
|
||||
* take its name from. */
|
||||
ariaLabel?: string;
|
||||
focusManaged?: boolean;
|
||||
initialFocus?: number | React.MutableRefObject<HTMLElement | null>;
|
||||
/** The surface never receives pointer events — for a passive caption that must not swallow a click
|
||||
@@ -321,6 +330,7 @@ export function PopoverSurface({
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
{...floatingProps}
|
||||
aria-label={ariaLabel}
|
||||
className={zClass}
|
||||
style={{
|
||||
...floatingPropStyle,
|
||||
@@ -366,7 +376,13 @@ export function PopoverSurface({
|
||||
<FloatingFocusManager
|
||||
context={context}
|
||||
modal={false}
|
||||
initialFocus={initialFocus}
|
||||
// On a roving list, useListNavigation owns the initial focus — so tell the focus manager
|
||||
// NOT to also focus the panel itself (-1). Without this, a `selectedIndex` menu ends up
|
||||
// with focus stranded on the container: that path focuses its item SYNCHRONOUSLY in a
|
||||
// layout effect, and the manager's own initial focus then runs after it and wins. The
|
||||
// no-selectedIndex path only escapes the same fate because it defers its focus to a
|
||||
// microtask/rAF while it waits for the item list to populate.
|
||||
initialFocus={initialFocus ?? (isNav ? -1 : undefined)}
|
||||
returnFocus
|
||||
>
|
||||
{positioned}
|
||||
|
||||
Reference in New Issue
Block a user