Skip to content

Commit 313b5b4

Browse files
committed
feat(ui-tabs): add tabIndex prop to the Panel for WCAG-compliant focus control (defaults to 0 for backward compatibility)
1 parent 73af976 commit 313b5b4

File tree

4 files changed

+94
-2
lines changed

4 files changed

+94
-2
lines changed

packages/ui-tabs/src/Tabs/Panel/__tests__/Panel.test.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,37 @@ describe('<Tabs.Panel />', () => {
4949

5050
expect(tabPanel).toHaveAttribute('role', 'tabpanel')
5151
})
52+
53+
it('should have tabIndex 0 by default', async () => {
54+
const { container } = render(
55+
<Panel isSelected renderTitle="Panel Title">
56+
Panel contents
57+
</Panel>
58+
)
59+
const tabPanel = container.querySelector('[role="tabpanel"]')
60+
61+
expect(tabPanel).toHaveAttribute('tabIndex', '0')
62+
})
63+
64+
it('should allow custom tabIndex', async () => {
65+
const { container } = render(
66+
<Panel isSelected renderTitle="Panel Title" tabIndex={-1}>
67+
Panel contents
68+
</Panel>
69+
)
70+
const tabPanel = container.querySelector('[role="tabpanel"]')
71+
72+
expect(tabPanel).toHaveAttribute('tabIndex', '-1')
73+
})
74+
75+
it('should allow tabIndex to be null to omit the attribute', async () => {
76+
const { container } = render(
77+
<Panel isSelected renderTitle="Panel Title" tabIndex={null}>
78+
Panel contents
79+
</Panel>
80+
)
81+
const tabPanel = container.querySelector('[role="tabpanel"]')
82+
83+
expect(tabPanel).not.toHaveAttribute('tabIndex')
84+
})
5285
})

packages/ui-tabs/src/Tabs/Panel/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,18 @@ class Panel extends Component<TabsPanelProps> {
9898
styles,
9999
active,
100100
unmountOnExit,
101+
tabIndex,
101102
...props
102103
} = this.props
103104

105+
const shouldSetTabIndex = tabIndex !== undefined ? tabIndex : 0
106+
104107
return (
105108
<div
106109
{...passthroughProps(props)}
107110
css={styles?.panel}
108111
role="tabpanel"
109-
tabIndex={0}
112+
{...(shouldSetTabIndex !== null && { tabIndex: shouldSetTabIndex })}
110113
id={id}
111114
aria-labelledby={labelledBy}
112115
aria-hidden={this.isHidden ? 'true' : undefined}

packages/ui-tabs/src/Tabs/Panel/props.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ type TabsPanelOwnProps = {
6262
* When set to false, the tabPanel only will be hidden, but not dismounted when not active
6363
*/
6464
unmountOnExit?: boolean
65+
/**
66+
* The tabIndex of the tabpanel element. According to WCAG guidelines, this should only be
67+
* set to 0 when the panel doesn't contain any focusable elements. If the panel contains
68+
* focusable elements (like buttons, inputs, etc.), this should be set to -1 or null to
69+
* omit the attribute entirely. Defaults to 0 for backwards compatibility.
70+
*/
71+
tabIndex?: number | null
6572
}
6673

6774
type PropKeys = keyof TabsPanelOwnProps
@@ -87,7 +94,8 @@ const allowedProps: AllowedPropKeys = [
8794
'textAlign',
8895
'elementRef',
8996
'active',
90-
'unmountOnExit'
97+
'unmountOnExit',
98+
'tabIndex'
9199
]
92100

93101
export type { TabsPanelProps, TabsPanelStyle }

packages/ui-tabs/src/Tabs/README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const Example = () => {
2323
>
2424
<Tabs.Panel
2525
id="tabA"
26+
tabIndex={-1}
2627
renderTitle="Tab A"
2728
textAlign="center"
2829
padding="large"
@@ -244,6 +245,7 @@ const Example = () => {
244245
>
245246
<Tabs.Panel
246247
id="tabA"
248+
tabIndex={-1}
247249
renderTitle="Tab A"
248250
textAlign="center"
249251
padding="large"
@@ -425,6 +427,52 @@ const Example = () => {
425427
render(<Example />)
426428
```
427429

430+
### Managing focus with tabIndex
431+
432+
According to WCAG guidelines, the `tabindex="0"` attribute should only be set on a tabpanel when it doesn't contain any focusable elements. If your panel contains focusable elements (like buttons, inputs, or links), you should set `tabIndex={-1}` to remove the panel from the tab order, or `tabIndex={null}` to omit the attribute entirely.
433+
434+
```js
435+
---
436+
type: example
437+
---
438+
const Example = () => {
439+
const [selectedIndex, setSelectedIndex] = useState(0)
440+
441+
const handleTabChange = (event, { index }) => {
442+
setSelectedIndex(index)
443+
}
444+
445+
return (
446+
<Tabs
447+
margin="large auto"
448+
padding="medium"
449+
onRequestTabChange={handleTabChange}
450+
>
451+
<Tabs.Panel
452+
id="tabA"
453+
renderTitle="Panel with button"
454+
textAlign="center"
455+
padding="large"
456+
isSelected={selectedIndex === 0}
457+
tabIndex={-1}
458+
>
459+
<Button>Focus Me First</Button>
460+
</Tabs.Panel>
461+
<Tabs.Panel
462+
id="tabB"
463+
renderTitle="Panel with text only"
464+
isSelected={selectedIndex === 1}
465+
tabIndex={0}
466+
>
467+
This panel only contains text, so tabIndex is set to 0 to include it in the tab sequence.
468+
</Tabs.Panel>
469+
</Tabs>
470+
)
471+
}
472+
473+
render(<Example />)
474+
```
475+
428476
### Guidelines
429477

430478
```js

0 commit comments

Comments
 (0)