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],
);
}
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