Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 19 additions & 50 deletions packages/core/src/TabList/TabList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,14 +17,15 @@
* - /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';
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';
Expand Down Expand Up @@ -135,7 +136,16 @@ export function TabList({
...restProps
}: TabListProps) {
const size = useSize(sizeProp, 'md');
const navRef = useRef<HTMLElement>(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<HTMLElement>({
itemSelector: TAB_STOP_SELECTOR,
orientation: 'both',
});

const contextValue = useMemo(
() => ({value, onChange, size, layout}),
Expand All @@ -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;
}
Expand All @@ -166,53 +178,10 @@ export function TabList({
}
});

const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLElement>) => {
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<HTMLElement>(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 (
<TabListContext value={contextValue}>
<nav
ref={mergeRefs(ref, navRef)}
ref={mergeRefs(ref, listRef)}
aria-label="Tabs"
aria-orientation={orientation}
onKeyDown={handleKeyDown}
Expand Down
Loading