diff --git a/packages/react/src/composite/root/CompositeRoot.test.tsx b/packages/react/src/composite/root/CompositeRoot.test.tsx index 8d17bb8250..e704aa04c4 100644 --- a/packages/react/src/composite/root/CompositeRoot.test.tsx +++ b/packages/react/src/composite/root/CompositeRoot.test.tsx @@ -437,4 +437,109 @@ describe('Composite', () => { }); }); }); + + describe('prop: disabledIndices', () => { + it('disables navigating item when their index is included', async () => { + function App() { + const [highlightedIndex, setHighlightedIndex] = React.useState(0); + return ( + + + + + + ); + } + + const { getByTestId } = render(); + + const item1 = getByTestId('1'); + const item3 = getByTestId('3'); + + act(() => item1.focus()); + + expect(item1).to.have.attribute('data-highlighted'); + + fireEvent.keyDown(item1, { key: 'ArrowDown' }); + await flushMicrotasks(); + expect(item3).to.have.attribute('data-highlighted'); + expect(item3).to.have.attribute('tabindex', '0'); + expect(item3).toHaveFocus(); + + fireEvent.keyDown(item3, { key: 'ArrowUp' }); + await flushMicrotasks(); + expect(item1).to.have.attribute('data-highlighted'); + expect(item1).to.have.attribute('tabindex', '0'); + expect(item1).toHaveFocus(); + }); + + it('allows navigating items disabled in the DOM when their index is excluded', async () => { + function App() { + const [highlightedIndex, setHighlightedIndex] = React.useState(0); + return ( + + } + /> + } + /> + } + /> + + ); + } + + const { getByTestId } = await render(); + + const item1 = getByTestId('1'); + const item2 = getByTestId('2'); + const item3 = getByTestId('3'); + + act(() => item1.focus()); + + expect(item1).to.have.attribute('data-highlighted'); + + fireEvent.keyDown(item1, { key: 'ArrowDown' }); + await flushMicrotasks(); + expect(item2).to.have.attribute('data-highlighted'); + expect(item2).to.have.attribute('tabindex', '0'); + expect(item2).toHaveFocus(); + + fireEvent.keyDown(item2, { key: 'ArrowDown' }); + await flushMicrotasks(); + expect(item3).to.have.attribute('data-highlighted'); + expect(item3).to.have.attribute('tabindex', '0'); + expect(item3).toHaveFocus(); + + fireEvent.keyDown(item3, { key: 'ArrowDown' }); + await flushMicrotasks(); + expect(item1).to.have.attribute('data-highlighted'); + expect(item1).to.have.attribute('tabindex', '0'); + expect(item1).toHaveFocus(); + + fireEvent.keyDown(item1, { key: 'ArrowUp' }); + await flushMicrotasks(); + expect(item3).to.have.attribute('data-highlighted'); + expect(item3).to.have.attribute('tabindex', '0'); + expect(item3).toHaveFocus(); + }); + }); }); diff --git a/packages/react/src/composite/root/CompositeRoot.tsx b/packages/react/src/composite/root/CompositeRoot.tsx index d23f8de6f4..5572bcb13e 100644 --- a/packages/react/src/composite/root/CompositeRoot.tsx +++ b/packages/react/src/composite/root/CompositeRoot.tsx @@ -29,6 +29,7 @@ function CompositeRoot(props: CompositeRoot.Props onMapChange, stopEventPropagation, rootRef, + disabledIndices, ...otherProps } = props; @@ -45,6 +46,7 @@ function CompositeRoot(props: CompositeRoot.Props stopEventPropagation, enableHomeAndEndKeys, direction, + disabledIndices, }); const { renderElement } = useComponentRenderer({ @@ -85,6 +87,7 @@ namespace CompositeRoot { onMapChange?: (newMap: Map | null>) => void; stopEventPropagation?: boolean; rootRef?: React.RefObject; + disabledIndices?: number[]; } } @@ -116,6 +119,10 @@ CompositeRoot.propTypes /* remove-proptypes */ = { * @ignore */ direction: PropTypes.oneOf(['ltr', 'rtl']), + /** + * @ignore + */ + disabledIndices: PropTypes.arrayOf(PropTypes.number), /** * @ignore */ diff --git a/packages/react/src/composite/root/useCompositeRoot.ts b/packages/react/src/composite/root/useCompositeRoot.ts index 28583d9457..eb6eb77dee 100644 --- a/packages/react/src/composite/root/useCompositeRoot.ts +++ b/packages/react/src/composite/root/useCompositeRoot.ts @@ -52,11 +52,13 @@ export interface UseCompositeRootParameters { * @default false */ stopEventPropagation?: boolean; + /** + * Array of item indices to be considered disabled. + * Used for composite items that are focusable when disabled. + */ + disabledIndices?: number[]; } -// Advanced options of Composite, to be implemented later if needed. -const disabledIndices = undefined; - /** * @ignore - internal hook. */ @@ -73,6 +75,7 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { rootRef: externalRef, enableHomeAndEndKeys = false, stopEventPropagation = false, + disabledIndices, } = params; const [internalHighlightedIndex, internalSetHighlightedIndex] = React.useState(0); @@ -249,18 +252,19 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { }, }), [ - highlightedIndex, - stopEventPropagation, cols, dense, + disabledIndices, elementsRef, + enableHomeAndEndKeys, + highlightedIndex, isGrid, itemSizes, loop, mergedRef, onHighlightedIndexChange, orientation, - enableHomeAndEndKeys, + stopEventPropagation, ], ); @@ -270,7 +274,8 @@ export function useCompositeRoot(params: UseCompositeRootParameters) { highlightedIndex, onHighlightedIndexChange, elementsRef, + disabledIndices, }), - [getRootProps, highlightedIndex, onHighlightedIndexChange, elementsRef], + [getRootProps, highlightedIndex, onHighlightedIndexChange, elementsRef, disabledIndices], ); } diff --git a/packages/react/src/tabs/list/TabsList.tsx b/packages/react/src/tabs/list/TabsList.tsx index 0ecd3cf333..f6a8664309 100644 --- a/packages/react/src/tabs/list/TabsList.tsx +++ b/packages/react/src/tabs/list/TabsList.tsx @@ -11,6 +11,8 @@ import { type TabMetadata } from '../tab/useTabsTab'; import { useTabsList } from './useTabsList'; import { TabsListContext } from './TabsListContext'; +const EMPTY_ARRAY: number[] = []; + /** * Groups the individual tab buttons. * Renders a `
` element. @@ -94,6 +96,7 @@ const TabsList = React.forwardRef(function TabsList( onHighlightedIndexChange={setHighlightedTabIndex} onMapChange={setTabMap} render={renderElement()} + disabledIndices={EMPTY_ARRAY} /> ); diff --git a/packages/react/src/tabs/root/TabsRoot.test.tsx b/packages/react/src/tabs/root/TabsRoot.test.tsx index 46eeae21e2..1685fb928c 100644 --- a/packages/react/src/tabs/root/TabsRoot.test.tsx +++ b/packages/react/src/tabs/root/TabsRoot.test.tsx @@ -431,9 +431,39 @@ describe('', () => { expect(handleKeyDown.callCount).to.equal(1); expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); }); + + it('moves focus to a disabled tab without activating it', async () => { + const handleKeyDown = spy(); + const { getAllByRole } = await render( + + + + + + + + + , + ); + const [, disabledTab, lastTab] = getAllByRole('tab'); + await act(async () => { + lastTab.focus(); + }); + + fireEvent.keyDown(lastTab, { key: previousItemKey }); + await flushMicrotasks(); + + expect(disabledTab).toHaveFocus(); + expect(handleKeyDown.callCount).to.equal(1); + expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); + }); }); - describe('with `activateOnFocus = true`', () => { + describe('with `activateOnFocus = true`', async () => { it('moves focus to the last tab while activating it if focus is on the first tab', async () => { const handleChange = spy(); const handleKeyDown = spy(); @@ -503,7 +533,7 @@ describe('', () => { }); }); - it('skips over disabled tabs', async () => { + it('moves focus to a disabled tab without activating it', async () => { const handleKeyDown = spy(); const { getAllByRole } = await render( @@ -520,7 +550,7 @@ describe('', () => { , ); - const [firstTab, , lastTab] = getAllByRole('tab'); + const [, disabledTab, lastTab] = getAllByRole('tab'); await act(async () => { lastTab.focus(); }); @@ -528,7 +558,7 @@ describe('', () => { fireEvent.keyDown(lastTab, { key: previousItemKey }); await flushMicrotasks(); - expect(firstTab).toHaveFocus(); + expect(disabledTab).toHaveFocus(); expect(handleKeyDown.callCount).to.equal(1); expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); }); @@ -601,6 +631,43 @@ describe('', () => { expect(handleKeyDown.callCount).to.equal(1); expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); }); + + it('moves focus to a disabled tab without activating it', async () => { + const handleChange = spy(); + const handleKeyDown = spy(); + const { getAllByRole } = await render( + + + + + + + + + , + ); + const [firstTab, disabledTab, thirdTab] = getAllByRole('tab'); + await act(async () => { + firstTab.focus(); + }); + + fireEvent.keyDown(firstTab, { key: nextItemKey }); + await flushMicrotasks(); + + expect(disabledTab).toHaveFocus(); + expect(handleChange.callCount).to.equal(0); + expect(handleKeyDown.callCount).to.equal(1); + expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); + + fireEvent.keyDown(disabledTab, { key: nextItemKey }); + await flushMicrotasks(); + expect(thirdTab).toHaveFocus(); + }); }); describe('with `activateOnFocus = true`', () => { @@ -673,11 +740,13 @@ describe('', () => { }); }); - it('skips over disabled tabs', async () => { + it('moves focus to a disabled tab without activating it', async () => { + const handleChange = spy(); const handleKeyDown = spy(); const { getAllByRole } = await render( ', () => { , ); - const [firstTab, , lastTab] = getAllByRole('tab'); + const [firstTab, disabledTab, thirdTab] = getAllByRole('tab'); await act(async () => { firstTab.focus(); }); @@ -698,9 +767,14 @@ describe('', () => { fireEvent.keyDown(firstTab, { key: nextItemKey }); await flushMicrotasks(); - expect(lastTab).toHaveFocus(); + expect(disabledTab).toHaveFocus(); + expect(handleChange.callCount).to.equal(0); expect(handleKeyDown.callCount).to.equal(1); expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); + + fireEvent.keyDown(disabledTab, { key: nextItemKey }); + await flushMicrotasks(); + expect(thirdTab).toHaveFocus(); }); }); }, @@ -762,28 +836,32 @@ describe('', () => { expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); }); - it('moves focus to first non-disabled tab', async () => { - const handleKeyDown = spy(); - const { getAllByRole } = await render( - - - - - - - , - ); - const [, secondTab, lastTab] = getAllByRole('tab'); - await act(async () => { - lastTab.focus(); - }); + [false, true].forEach((activateOnFocusProp) => { + it(`when \`activateOnFocus = ${activateOnFocusProp}\`, moves focus to a disabled tab without activating it`, async () => { + const handleChange = spy(); + const handleKeyDown = spy(); + const { getAllByRole } = await render( + + + + + + + , + ); + const [disabledTab, , lastTab] = getAllByRole('tab'); + await act(async () => { + lastTab.focus(); + }); - fireEvent.keyDown(lastTab, { key: 'Home' }); - await flushMicrotasks(); + fireEvent.keyDown(lastTab, { key: 'Home' }); + await flushMicrotasks(); - expect(secondTab).toHaveFocus(); - expect(handleKeyDown.callCount).to.equal(1); - expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); + expect(disabledTab).toHaveFocus(); + expect(handleChange.callCount).to.equal(0); + expect(handleKeyDown.callCount).to.equal(1); + expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); + }); }); }); @@ -841,28 +919,33 @@ describe('', () => { expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); }); - it('moves focus to first non-disabled tab', async () => { - const handleKeyDown = spy(); - const { getAllByRole } = await render( - - - - - - - , - ); - const [firstTab, secondTab] = getAllByRole('tab'); - await act(async () => { - firstTab.focus(); - }); + [false, true].forEach((activateOnFocusProp) => { + it(`when \`activateOnFocus = ${activateOnFocusProp}\`, moves focus to a disabled tab without activating it`, async () => { + const handleChange = spy(); + const handleKeyDown = spy(); + const { getAllByRole } = await render( + + + + + + + , + ); + + const [firstTab, , disabledTab] = getAllByRole('tab'); + await act(async () => { + firstTab.focus(); + }); - fireEvent.keyDown(firstTab, { key: 'End' }); - await flushMicrotasks(); + fireEvent.keyDown(firstTab, { key: 'End' }); + await flushMicrotasks(); - expect(secondTab).toHaveFocus(); - expect(handleKeyDown.callCount).to.equal(1); - expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); + expect(disabledTab).toHaveFocus(); + expect(handleChange.callCount).to.equal(0); + expect(handleKeyDown.callCount).to.equal(1); + expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true); + }); }); }); }); diff --git a/packages/react/src/tabs/tab/useTabsTab.ts b/packages/react/src/tabs/tab/useTabsTab.ts index 0a9ead673a..e0e4ff09a7 100644 --- a/packages/react/src/tabs/tab/useTabsTab.ts +++ b/packages/react/src/tabs/tab/useTabsTab.ts @@ -58,11 +58,15 @@ function useTabsTab(parameters: useTabsTab.Parameters): useTabsTab.ReturnValue { return valueParam === selectedTabValue; }, [index, selectedTabValue, valueParam]); - // when activateOnFocus is `true`, ensure the active item in Composite's roving - // focus group matches the selected Tab + const isSelectionSyncedWithHighlightRef = React.useRef(false); + useEnhancedEffect(() => { + if (isSelectionSyncedWithHighlightRef.current === true) { + return; + } if (activateOnFocus && selected && index > -1 && highlightedTabIndex !== index) { setHighlightedTabIndex(index); + isSelectionSyncedWithHighlightRef.current = true; } }, [activateOnFocus, highlightedTabIndex, index, selected, setHighlightedTabIndex]); @@ -97,11 +101,22 @@ function useTabsTab(parameters: useTabsTab.Parameters): useTabsTab.ReturnValue { onTabActivation(tabValue, event.nativeEvent); }, onFocus(event) { - if (!activateOnFocus || selected || disabled) { + if (selected) { + return; + } + + if (index > 1 && index !== highlightedTabIndex) { + setHighlightedTabIndex(index); + } + + if (disabled) { return; } - if (!isPressingRef.current || (isPressingRef.current && isMainButtonRef.current)) { + if ( + (activateOnFocus && !isPressingRef.current) || // keyboard focus + (isPressingRef.current && isMainButtonRef.current) // focus caused by pointerdown + ) { onTabActivation(tabValue, event.nativeEvent); } }, @@ -139,6 +154,9 @@ function useTabsTab(parameters: useTabsTab.Parameters): useTabsTab.ReturnValue { tabPanelId, tabValue, disabled, + index, + setHighlightedTabIndex, + highlightedTabIndex, ], );