Skip to content

Commit dbdb065

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 dbdb065

File tree

4 files changed

+96
-2
lines changed

4 files changed

+96
-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: 50 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"
@@ -387,6 +389,7 @@ const Example = () => {
387389
>
388390
<Tabs.Panel
389391
id="tabA"
392+
tabIndex={-1}
390393
renderTitle="I will persist"
391394
textAlign="center"
392395
padding="large"
@@ -397,6 +400,7 @@ const Example = () => {
397400
</Tabs.Panel>
398401
<Tabs.Panel
399402
id="tabB"
403+
tabIndex={-1}
400404
renderTitle="I will unmount"
401405
isSelected={selectedIndex === 1}
402406
textAlign="center"
@@ -425,6 +429,52 @@ const Example = () => {
425429
render(<Example />)
426430
```
427431

432+
### Managing focus with tabIndex
433+
434+
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.
435+
436+
```js
437+
---
438+
type: example
439+
---
440+
const Example = () => {
441+
const [selectedIndex, setSelectedIndex] = useState(0)
442+
443+
const handleTabChange = (event, { index }) => {
444+
setSelectedIndex(index)
445+
}
446+
447+
return (
448+
<Tabs
449+
margin="large auto"
450+
padding="medium"
451+
onRequestTabChange={handleTabChange}
452+
>
453+
<Tabs.Panel
454+
id="tabA"
455+
renderTitle="Panel with button"
456+
textAlign="center"
457+
padding="large"
458+
isSelected={selectedIndex === 0}
459+
tabIndex={-1}
460+
>
461+
<Button>Focus Me First</Button>
462+
</Tabs.Panel>
463+
<Tabs.Panel
464+
id="tabB"
465+
renderTitle="Panel with text only"
466+
isSelected={selectedIndex === 1}
467+
tabIndex={0}
468+
>
469+
This panel only contains text, so tabIndex is set to 0 to include it in the tab sequence.
470+
</Tabs.Panel>
471+
</Tabs>
472+
)
473+
}
474+
475+
render(<Example />)
476+
```
477+
428478
### Guidelines
429479

430480
```js

0 commit comments

Comments
 (0)