Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Tabs] Fix keyboard navigation involving disabled Tabs #1449

Merged
merged 11 commits into from
Feb 24, 2025
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],
);
}
1 change: 1 addition & 0 deletions packages/react/src/tabs/list/TabsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ const TabsList = React.forwardRef(function TabsList(
onHighlightedIndexChange={setHighlightedTabIndex}
onMapChange={setTabMap}
render={renderElement()}
disabledIndices={[]}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not recreate the array on each render.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

/>
</TabsListContext.Provider>
);
Expand Down
Loading