Skip to content

Commit 143e54a

Browse files
committed
Fix tab focus
1 parent aa9b810 commit 143e54a

File tree

5 files changed

+66
-90
lines changed

5 files changed

+66
-90
lines changed

packages/circuit-ui/components/Tabs/Tabs.spec.tsx

+2-3
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ describe('Tabs', () => {
7373
it('should go to the previous tab on left arrow press', async () => {
7474
render(
7575
<Tabs
76-
initialSelectedIndex={1}
76+
initialSelectedId="b"
7777
items={[
7878
{ id: 'a', tab: 'tab-a', panel: 'panel-a' },
7979
{ id: 'b', tab: 'tab-b', panel: 'panel-b' },
@@ -85,7 +85,7 @@ describe('Tabs', () => {
8585
await userEvent.keyboard('{Tab}');
8686

8787
const tabs = screen.getAllByRole('tab');
88-
expect(tabs[0]).toHaveFocus();
88+
expect(tabs[1]).toHaveFocus();
8989

9090
const panelB = screen.getByRole('tabpanel');
9191
expect(panelB).toHaveAccessibleName('tab-b');
@@ -100,7 +100,6 @@ describe('Tabs', () => {
100100
it('should focus the current panel on down arrow press', async () => {
101101
render(
102102
<Tabs
103-
initialSelectedIndex={1}
104103
items={[
105104
{ id: 'a', tab: 'tab-a', panel: 'panel-a' },
106105
{ id: 'b', tab: 'tab-b', panel: 'panel-b' },

packages/circuit-ui/components/Tabs/Tabs.tsx

+31-33
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,7 @@
1515

1616
'use client';
1717

18-
import {
19-
createRef,
20-
Fragment,
21-
useState,
22-
type KeyboardEvent,
23-
type ReactNode,
24-
} from 'react';
18+
import { Fragment, useState, type KeyboardEvent, type ReactNode } from 'react';
2519

2620
import {
2721
isArrowLeft,
@@ -35,9 +29,11 @@ import { TabPanel } from './components/TabPanel/index.js';
3529

3630
export interface TabsProps extends TabListProps {
3731
/**
38-
* The index of the initially selected tab.
32+
* The id of the initially selected tab.
33+
*
34+
* @default items[0].id
3935
*/
40-
initialSelectedIndex?: number;
36+
initialSelectedId?: string;
4137
/**
4238
* A collection of tabs with an id, the tab label, and panel content.
4339
*/
@@ -48,34 +44,43 @@ export interface TabsProps extends TabListProps {
4844
}[];
4945
}
5046

51-
export function Tabs({ initialSelectedIndex = 0, items, ...props }: TabsProps) {
52-
const [selectedIndex, setSelectedIndex] = useState(initialSelectedIndex);
53-
54-
const tabPanelsRefs = createRefs(items.length);
47+
export function Tabs({
48+
items,
49+
initialSelectedId = items[0].id,
50+
...props
51+
}: TabsProps) {
52+
const [selectedId, setSelectedId] = useState(initialSelectedId);
5553

5654
const handleTabKeyDown = (event: KeyboardEvent) => {
55+
const selectedIndex = items.findIndex((item) => item.id === selectedId);
56+
5757
if (isArrowLeft(event)) {
58-
const previousTab = Math.max(0, selectedIndex - 1);
59-
setSelectedIndex(previousTab);
58+
const previousIndex = selectedIndex - 1;
59+
if (previousIndex >= 0) {
60+
const previousId = items[previousIndex].id;
61+
setSelectedId(previousId);
62+
document.getElementById(`tab-${previousId}`)?.focus();
63+
}
6064
} else if (isArrowRight(event)) {
61-
const nextTab = Math.min(items.length - 1, selectedIndex + 1);
62-
setSelectedIndex(nextTab);
63-
} else if (isArrowDown(event)) {
64-
const panelRef = tabPanelsRefs[selectedIndex].current;
65-
if (panelRef) {
66-
panelRef.focus();
65+
const nextIndex = selectedIndex + 1;
66+
if (nextIndex <= items.length - 1) {
67+
const nextId = items[nextIndex].id;
68+
setSelectedId(nextId);
69+
document.getElementById(`tab-${nextId}`)?.focus();
6770
}
71+
} else if (isArrowDown(event)) {
72+
document.getElementById(`panel-${selectedId}`)?.focus();
6873
}
6974
};
7075

7176
return (
7277
<Fragment>
7378
<TabList {...props}>
74-
{items.map(({ id, tab }, index) => (
79+
{items.map(({ id, tab }) => (
7580
<Tab
7681
key={id}
77-
selected={selectedIndex === index}
78-
onClick={() => setSelectedIndex(index)}
82+
selected={selectedId === id}
83+
onClick={() => setSelectedId(id)}
7984
id={`tab-${id}`}
8085
aria-controls={`panel-${id}`}
8186
onKeyDown={handleTabKeyDown}
@@ -84,23 +89,16 @@ export function Tabs({ initialSelectedIndex = 0, items, ...props }: TabsProps) {
8489
</Tab>
8590
))}
8691
</TabList>
87-
{items.map(({ id, panel }, index) => (
92+
{items.map(({ id, panel }) => (
8893
<TabPanel
8994
key={id}
9095
id={`panel-${id}`}
9196
aria-labelledby={`tab-${id}`}
92-
hidden={selectedIndex !== index}
93-
ref={tabPanelsRefs[index]}
97+
hidden={selectedId !== id}
9498
>
9599
{panel}
96100
</TabPanel>
97101
))}
98102
</Fragment>
99103
);
100104
}
101-
102-
function createRefs(length: number) {
103-
return Array.from(Array(length).keys()).map(() =>
104-
createRef<HTMLDivElement>(),
105-
);
106-
}

packages/circuit-ui/components/Tabs/components/Tab/Tab.spec.tsx

+8-17
Original file line numberDiff line numberDiff line change
@@ -13,34 +13,25 @@
1313
* limitations under the License.
1414
*/
1515

16-
import { describe, expect, it, vi } from 'vitest';
16+
import { describe, expect, it } from 'vitest';
1717
import { createRef } from 'react';
1818

19-
import { render } from '../../../../util/test-utils.js';
19+
import { render, screen } from '../../../../util/test-utils.js';
2020

2121
import { Tab } from './Tab.js';
2222

2323
describe('Tab', () => {
2424
it('should merge a custom class name with the default ones', () => {
2525
const className = 'foo';
26-
const { container } = render(<Tab className={className} />);
27-
const element = container.querySelector('button');
28-
expect(element?.className).toContain(className);
26+
render(<Tab className={className} />);
27+
const element = screen.getByRole('tab');
28+
expect(element.className).toContain(className);
2929
});
3030

3131
it('should forward a ref', () => {
3232
const ref = createRef<HTMLButtonElement>();
33-
const { container } = render(<Tab ref={ref} />);
34-
const button = container.querySelector('button');
35-
expect(ref.current).toBe(button);
36-
});
37-
38-
it('should be focused when selected', () => {
39-
const ref = createRef<HTMLButtonElement>();
40-
const { container, rerender } = render(<Tab ref={ref} selected={false} />);
41-
const button = container.querySelector('button');
42-
vi.spyOn(button, 'focus');
43-
rerender(<Tab ref={ref} selected />);
44-
expect(button?.focus).toHaveBeenCalledOnce();
33+
render(<Tab ref={ref} />);
34+
const tab = screen.getByRole('tab');
35+
expect(ref.current).toBe(tab);
4536
});
4637
});

packages/circuit-ui/components/Tabs/components/Tab/Tab.tsx

+1-10
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,11 @@ import {
1919
forwardRef,
2020
type AnchorHTMLAttributes,
2121
type ButtonHTMLAttributes,
22-
useEffect,
23-
useRef,
2422
} from 'react';
2523

2624
import { useComponents } from '../../../ComponentsContext/index.js';
2725
import type { EmotionAsPropType } from '../../../../types/prop-types.js';
2826
import { clsx } from '../../../../styles/clsx.js';
29-
import { applyMultipleRefs } from '../../../../util/refs.js';
3027

3128
import classes from './Tab.module.css';
3229

@@ -49,19 +46,13 @@ const tabIndex = (selected: boolean) => (selected ? undefined : -1);
4946
*/
5047
export const Tab = forwardRef<HTMLButtonElement, TabProps>(
5148
({ selected = false, className, ...props }, ref) => {
52-
const tabRef = useRef<HTMLButtonElement>(null);
5349
const components = useComponents();
5450
const Link = components.Link as EmotionAsPropType;
5551
const Element = props.href ? Link : 'button';
5652

57-
useEffect(() => {
58-
if (selected) {
59-
tabRef?.current?.focus({ preventScroll: true });
60-
}
61-
}, [selected]);
6253
return (
6354
<Element
64-
ref={applyMultipleRefs(tabRef, ref)}
55+
ref={ref}
6556
role="tab"
6657
aria-selected={selected}
6758
tabIndex={tabIndex(selected)}

packages/circuit-ui/components/Tabs/components/TabList/TabList.tsx

+24-27
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* limitations under the License.
1414
*/
1515

16-
import { Children, type HTMLAttributes } from 'react';
16+
import { Children, forwardRef, type HTMLAttributes } from 'react';
1717

1818
import { clsx } from '../../../../styles/clsx.js';
1919
import { utilClasses } from '../../../../styles/utility.js';
@@ -29,32 +29,29 @@ const MOBILE_AUTOSTRETCH_ITEMS_MAX = 3;
2929
/**
3030
* TabList component that wraps the Tab components
3131
*/
32-
export function TabList({
33-
className,
34-
style = {},
35-
stretched,
36-
children,
37-
...props
38-
}: TabListProps) {
39-
const numberOfTabs = Children.toArray(children).length;
40-
const tabWidth = Math.floor(100 / numberOfTabs);
41-
const stretchOnMobile = numberOfTabs <= MOBILE_AUTOSTRETCH_ITEMS_MAX;
42-
return (
43-
<div
44-
className={clsx(classes.wrapper, utilClasses.hideScrollbar, className)}
45-
style={{ ...style, '--tab-list-width': tabWidth }}
46-
>
32+
export const TabList = forwardRef<HTMLDivElement, TabListProps>(
33+
({ className, style = {}, stretched, children, ...props }, ref) => {
34+
const numberOfTabs = Children.toArray(children).length;
35+
const tabWidth = Math.floor(100 / numberOfTabs);
36+
const stretchOnMobile = numberOfTabs <= MOBILE_AUTOSTRETCH_ITEMS_MAX;
37+
return (
4738
<div
48-
className={clsx(
49-
classes.base,
50-
stretched && classes.stretched,
51-
stretchOnMobile && classes['stretched-mobile'],
52-
)}
53-
{...props}
54-
role="tablist"
39+
ref={ref}
40+
className={clsx(classes.wrapper, utilClasses.hideScrollbar, className)}
41+
style={{ ...style, '--tab-list-width': tabWidth }}
5542
>
56-
{children}
43+
<div
44+
className={clsx(
45+
classes.base,
46+
stretched && classes.stretched,
47+
stretchOnMobile && classes['stretched-mobile'],
48+
)}
49+
{...props}
50+
role="tablist"
51+
>
52+
{children}
53+
</div>
5754
</div>
58-
</div>
59-
);
60-
}
55+
);
56+
},
57+
);

0 commit comments

Comments
 (0)