From 513edcb9cf36fe72a7d8e05a7e98fce3ac2b567d Mon Sep 17 00:00:00 2001 From: cixzhang Date: Fri, 3 Jul 2026 04:21:27 +0000 Subject: [PATCH] refactor(tablist): use shared useListFocus for roving keyboard nav Migrate TabList to the shared useListFocus hook with orientation:'both' instead of its own inline keyboard handler. useListFocus already supports both-axes navigation (Arrow keys + Home/End, disabled-skip, wrap), so the TabList strip becomes a single tab stop driven by the shared hook. --- packages/core/src/TabList/TabList.tsx | 69 ++++++++------------------- 1 file changed, 19 insertions(+), 50 deletions(-) diff --git a/packages/core/src/TabList/TabList.tsx b/packages/core/src/TabList/TabList.tsx index 2b9efca57c7d..060f22ece73e 100644 --- a/packages/core/src/TabList/TabList.tsx +++ b/packages/core/src/TabList/TabList.tsx @@ -4,11 +4,11 @@ /** * @file TabList.tsx - * @input Uses React, StyleX, TabListContext + * @input Uses React, StyleX, TabListContext, useListFocus * @output Exports TabList component and TabListProps type * @position Nav wrapper; provides TabListContext to Tab and TabMenu children. * Owns roving-tabindex keyboard navigation (Arrow/Home/End) across the tab - * strip so it is a single Tab stop. + * strip via the shared useListFocus hook so it is a single Tab stop. * * SYNC: When modified, update: * - /packages/core/src/TabList/TabList.doc.mjs @@ -17,7 +17,7 @@ * - /packages/cli/templates/blocks/components/TabList/ (showcase blocks) */ -import React, {useCallback, useMemo, useRef, type ReactNode} from 'react'; +import React, {useMemo, type ReactNode} from 'react'; import * as stylex from '@stylexjs/stylex'; import {borderVars, colorVars, spacingVars} from '../theme/tokens.stylex'; import type {BaseProps} from '../BaseProps'; @@ -25,6 +25,7 @@ import {TabListContext} from './TabListContext'; import type {TabListOrientation, TabListSize} from './TabListContext'; import {useSize} from '../SizeContext/SizeContext'; import {mergeProps, mergeRefs} from '../utils'; +import {useListFocus} from '../hooks/useListFocus'; import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect'; import {EDGE_COMP_ATTR} from '../Layout/edgeCompensation.stylex'; import {themeProps} from '../utils/themeProps'; @@ -135,7 +136,16 @@ export function TabList({ ...restProps }: TabListProps) { const size = useSize(sizeProp, 'md'); - const navRef = useRef(null); + + // Roving-tabindex keyboard navigation across the tab strip via the shared + // hook. `orientation: 'both'` accepts both arrow axes per the WAI-ARIA APG + // allowance for tab strips (ArrowRight/ArrowDown advance, ArrowLeft/ArrowUp + // retreat) regardless of the component's `orientation` prop, which only + // drives the reported `aria-orientation`. + const {listRef, handleKeyDown} = useListFocus({ + itemSelector: TAB_STOP_SELECTOR, + orientation: 'both', + }); const contextValue = useMemo( () => ({value, onChange, size, layout}), @@ -147,9 +157,11 @@ export function TabList({ // selected value doesn't correspond to any focusable stop (e.g. selection // lives in a collapsed TabMenu that renders no matching stop, or there is no // selection at all), no stop would be tabbable — this effect repairs that by - // making the first stop tabbable. Same pattern as the SegmentedControl fix. + // making the first stop tabbable. useListFocus handles arrow navigation but + // not the initial tabbable stop, so this stays. Same pattern as the + // SegmentedControl fix. useIsomorphicLayoutEffect(() => { - const nav = navRef.current; + const nav = listRef.current; if (nav == null) { return; } @@ -166,53 +178,10 @@ export function TabList({ } }); - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - const nav = navRef.current; - if (nav == null) { - return; - } - - // Accept both axes' arrows per the WAI-ARIA APG so keyboard users don't - // have to know the orientation: forward keys advance, backward keys - // retreat. Orientation only affects the reported aria-orientation. - const isForward = e.key === 'ArrowRight' || e.key === 'ArrowDown'; - const isBackward = e.key === 'ArrowLeft' || e.key === 'ArrowUp'; - - if (!isForward && !isBackward && e.key !== 'Home' && e.key !== 'End') { - return; - } - - const stops = Array.from( - nav.querySelectorAll(TAB_STOP_SELECTOR), - ).filter(el => !isDisabledStop(el)); - if (stops.length === 0) { - return; - } - - const currentIndex = stops.findIndex(el => el === document.activeElement); - let nextIndex: number; - - if (e.key === 'Home') { - nextIndex = 0; - } else if (e.key === 'End') { - nextIndex = stops.length - 1; - } else if (isForward) { - nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % stops.length; - } else { - nextIndex = - currentIndex === -1 - ? stops.length - 1 - : (currentIndex - 1 + stops.length) % stops.length; - } - - e.preventDefault(); - stops[nextIndex].focus(); - }, []); - return (