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

fix: adds arrow keys navigation functionality in the Switcher, and increases test coverage #18115

Merged
merged 9 commits into from
Nov 26, 2024
14 changes: 11 additions & 3 deletions packages/react/src/components/UIShell/Switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@ const Switcher = forwardRef<HTMLUListElement, SwitcherProps>(
}) => {
const enabledIndices = React.Children.toArray(children).reduce<number[]>(
(acc, curr, i) => {
if (Object.keys((curr as any).props).length !== 0) {
if (
React.isValidElement(curr) &&
Object.keys((curr as any).props).length !== 0 &&
getDisplayName(curr.type) === 'SwitcherItem'
) {
acc.push(i);
}
return acc;
Expand All @@ -97,7 +101,11 @@ const Switcher = forwardRef<HTMLUListElement, SwitcherProps>(
if (direction === -1) {
return enabledIndices[enabledIndices.length - 1];
}
return 0;
return enabledIndices[0];
case 0:
if (direction === 1) {
return enabledIndices[1];
}
default:
return enabledIndices[nextIndex];
}
Expand All @@ -116,7 +124,7 @@ const Switcher = forwardRef<HTMLUListElement, SwitcherProps>(
if (
React.isValidElement(child) &&
child.type &&
getDisplayName(child.type) === 'Switcher'
getDisplayName(child.type) === 'SwitcherItem'
) {
return React.cloneElement(child as React.ReactElement<any>, {
handleSwitcherItemFocus,
Expand Down
159 changes: 159 additions & 0 deletions packages/react/src/components/UIShell/__tests__/Switcher-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import React from 'react';
import Switcher from '../Switcher';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import HeaderPanel from '../HeaderPanel';
import SwitcherItem from '../SwitcherItem';

describe('Switcher', () => {
Expand Down Expand Up @@ -65,5 +67,162 @@ describe('Switcher', () => {

expect(container.firstChild).toHaveClass('custom-class');
});
it('should correctly merge refs', () => {
const ref1 = React.createRef();
render(
<Switcher ref={ref1} aria-label="test-label">
<SwitcherItem aria-label="test-item">Item 1</SwitcherItem>
<SwitcherItem aria-label="test-item">Item 2</SwitcherItem>
</Switcher>
);

expect(ref1.current).not.toBeNull();
expect(ref1.current.tagName).toBe('UL');
});
it('should apply aria attributes correctly', () => {
render(
<Switcher
aria-label="test-aria-label"
aria-labelledby="test-labelledby">
<SwitcherItem aria-label="item">Item</SwitcherItem>
</Switcher>
);

const switcher = screen.getByRole('list');
expect(switcher).toHaveAttribute('aria-label', 'test-aria-label');
expect(switcher).toHaveAttribute('aria-labelledby', 'test-labelledby');
});
});

describe('Switcher navigation and focus management', () => {
const renderSwitcher = () => {
return (
<Switcher aria-label="test-switcher" expanded>
<SwitcherItem aria-label="test-1" href="#">
Item 1
</SwitcherItem>
<SwitcherItem aria-label="test-2" href="#">
Item 2
</SwitcherItem>
<SwitcherItem aria-label="test-3" href="#">
Item 3
</SwitcherItem>
</Switcher>
);
};

it('should focus the next valid index when moving forward', async () => {
render(renderSwitcher());
const items = screen.getAllByRole('listitem');
const firstLink = items[0].querySelector('a');
const secondLink = items[1].querySelector('a');

await userEvent.keyboard('{Tab}');
expect(document.activeElement).toBe(firstLink);
await userEvent.keyboard('{Tab}');

expect(document.activeElement).toBe(secondLink);
});

it('should focus the next valid index when moving backword', async () => {
render(renderSwitcher());

const items = screen.getAllByRole('listitem');
const firstLink = items[0].querySelector('a');
const secondLink = items[1].querySelector('a');

await userEvent.keyboard('{Tab}');
expect(document.activeElement).toBe(firstLink);
await userEvent.keyboard('Shift+Tab');
expect(document.activeElement).toBe(firstLink);
});
it('should focus next SwitcherItem when pressing ArrowDown from first item', async () => {
render(renderSwitcher());
const focusableItems = screen.getAllByRole('link');
expect(focusableItems).toHaveLength(3);

await userEvent.keyboard('{Tab}');
expect(document.activeElement).toBe(focusableItems[0]);

await userEvent.keyboard('{ArrowDown}');
expect(document.activeElement).toBe(focusableItems[1]);
});
it('should focus previous SwitcherItem when pressing ArrowUp from last item', async () => {
render(renderSwitcher());
const focusableItems = screen.getAllByRole('link');
expect(focusableItems).toHaveLength(3);

focusableItems[2].focus();
expect(document.activeElement).toBe(focusableItems[2]);

await userEvent.keyboard('{ArrowUp}');
expect(document.activeElement).toBe(focusableItems[1]);
});

it('should wrap to first item when pressing ArrowDown from last SwitcherItem', async () => {
render(renderSwitcher());
const focusableItems = screen.getAllByRole('link');
expect(focusableItems).toHaveLength(3);

focusableItems[2].focus();
expect(document.activeElement).toBe(focusableItems[2]);

await userEvent.keyboard('{ArrowDown}');
expect(document.activeElement).toBe(focusableItems[0]);
});

it('should wrap to last item when pressing ArrowUp from first SwitcherItem', async () => {
render(renderSwitcher());
const focusableItems = screen.getAllByRole('link');
expect(focusableItems).toHaveLength(3);

focusableItems[0].focus();
expect(document.activeElement).toBe(focusableItems[0]);

await userEvent.keyboard('{ArrowUp}');
expect(document.activeElement).toBe(focusableItems[2]);
expect(document.activeElement).toHaveTextContent('Item 3');
});
it('should skip non SwitcherItem elements', async () => {
render(renderSwitcher());
const focusableItems = screen.getAllByRole('link');
expect(focusableItems).toHaveLength(3);

focusableItems[0].focus();
expect(document.activeElement).toBe(focusableItems[0]);
expect(document.activeElement).toHaveTextContent('Item 1');

await userEvent.keyboard('{ArrowDown}');
expect(document.activeElement).toBe(focusableItems[1]);
expect(document.activeElement).toHaveTextContent('Item 2');

await userEvent.keyboard('{ArrowDown}');
expect(document.activeElement).toBe(focusableItems[2]);
expect(document.activeElement).toHaveTextContent('Item 3');
});
it('should handle keyboard navigation with mixed child types', async () => {
render(
<Switcher aria-label="test-label">
<SwitcherItem aria-label="test-aria-label-switcheritem">
Item 1
</SwitcherItem>
<div>Non-focusable div</div>
<SwitcherItem aria-label="test-aria-label-switcheritem">
Item 2
</SwitcherItem>
<Switcher aria-label="nested-switcher">
<SwitcherItem aria-label="test-aria-label-switcheritem">
Nested Item
</SwitcherItem>
</Switcher>
</Switcher>
);
const items = screen.getAllByRole('listitem');
const secondItem = items[2].querySelector('a');
secondItem?.focus();
expect(document.activeElement).toBe(secondItem);
await userEvent.keyboard('{ArrowDown}');
expect(document.activeElement).toHaveTextContent('Nested Item');
});
});
});
Loading