Skip to content

Commit

Permalink
[Tabs] Fix keyboard navigation involving disabled Tabs (#1449)
Browse files Browse the repository at this point in the history
  • Loading branch information
mj12albert authored Feb 24, 2025
1 parent 20bf89e commit 05e3fd9
Show file tree
Hide file tree
Showing 6 changed files with 279 additions and 58 deletions.
105 changes: 105 additions & 0 deletions packages/react/src/composite/root/CompositeRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<CompositeRoot
highlightedIndex={highlightedIndex}
onHighlightedIndexChange={setHighlightedIndex}
disabledIndices={[1]}
>
<CompositeItem data-testid="1" />
<CompositeItem data-testid="2" />
<CompositeItem data-testid="3" />
</CompositeRoot>
);
}

const { getByTestId } = render(<App />);

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 (
<CompositeRoot
highlightedIndex={highlightedIndex}
onHighlightedIndexChange={setHighlightedIndex}
disabledIndices={[]}
>
<CompositeItem
data-testid="1"
// TS doesn't like the disabled attribute on non-interactive elements
// but testing library refuses to focus disabled interactive elements
// @ts-ignore
render={<span data-disabled aria-disabled="true" disabled />}
/>
<CompositeItem
data-testid="2"
// @ts-ignore
render={<span data-disabled aria-disabled="true" disabled />}
/>
<CompositeItem
data-testid="3"
// @ts-ignore
render={<span data-disabled aria-disabled="true" disabled />}
/>
</CompositeRoot>
);
}

const { getByTestId } = await render(<App />);

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();
});
});
});
7 changes: 7 additions & 0 deletions packages/react/src/composite/root/CompositeRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function CompositeRoot<Metadata extends {}>(props: CompositeRoot.Props<Metadata>
onMapChange,
stopEventPropagation,
rootRef,
disabledIndices,
...otherProps
} = props;

Expand All @@ -45,6 +46,7 @@ function CompositeRoot<Metadata extends {}>(props: CompositeRoot.Props<Metadata>
stopEventPropagation,
enableHomeAndEndKeys,
direction,
disabledIndices,
});

const { renderElement } = useComponentRenderer({
Expand Down Expand Up @@ -85,6 +87,7 @@ namespace CompositeRoot {
onMapChange?: (newMap: Map<Node, CompositeMetadata<Metadata> | null>) => void;
stopEventPropagation?: boolean;
rootRef?: React.RefObject<HTMLElement | null>;
disabledIndices?: number[];
}
}

Expand Down Expand Up @@ -116,6 +119,10 @@ CompositeRoot.propTypes /* remove-proptypes */ = {
* @ignore
*/
direction: PropTypes.oneOf(['ltr', 'rtl']),
/**
* @ignore
*/
disabledIndices: PropTypes.arrayOf(PropTypes.number),
/**
* @ignore
*/
Expand Down
19 changes: 12 additions & 7 deletions packages/react/src/composite/root/useCompositeRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -73,6 +75,7 @@ export function useCompositeRoot(params: UseCompositeRootParameters) {
rootRef: externalRef,
enableHomeAndEndKeys = false,
stopEventPropagation = false,
disabledIndices,
} = params;

const [internalHighlightedIndex, internalSetHighlightedIndex] = React.useState(0);
Expand Down Expand Up @@ -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,
],
);

Expand All @@ -270,7 +274,8 @@ export function useCompositeRoot(params: UseCompositeRootParameters) {
highlightedIndex,
onHighlightedIndexChange,
elementsRef,
disabledIndices,
}),
[getRootProps, highlightedIndex, onHighlightedIndexChange, elementsRef],
[getRootProps, highlightedIndex, onHighlightedIndexChange, elementsRef, disabledIndices],
);
}
3 changes: 3 additions & 0 deletions packages/react/src/tabs/list/TabsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<div>` element.
Expand Down Expand Up @@ -94,6 +96,7 @@ const TabsList = React.forwardRef(function TabsList(
onHighlightedIndexChange={setHighlightedTabIndex}
onMapChange={setTabMap}
render={renderElement()}
disabledIndices={EMPTY_ARRAY}
/>
</TabsListContext.Provider>
);
Expand Down
Loading

0 comments on commit 05e3fd9

Please sign in to comment.