Skip to content

Commit 8c4e5cf

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 8c4e5cf

File tree

4 files changed

+82
-2
lines changed

4 files changed

+82
-2
lines changed

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

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

5050
expect(tabPanel).toHaveAttribute('role', 'tabpanel')
5151
})
52+
53+
it('should not 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).not.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+
})
5274
})

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

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

@@ -106,10 +107,10 @@ class Panel extends Component<TabsPanelProps> {
106107
{...passthroughProps(props)}
107108
css={styles?.panel}
108109
role="tabpanel"
109-
tabIndex={0}
110110
id={id}
111111
aria-labelledby={labelledBy}
112112
aria-hidden={this.isHidden ? 'true' : undefined}
113+
tabIndex={this.isHidden ? undefined : tabIndex}
113114
ref={this.handleRef}
114115
>
115116
<Transition

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ 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.
69+
*/
70+
tabIndex?: number
6571
}
6672

6773
type PropKeys = keyof TabsPanelOwnProps
@@ -87,7 +93,8 @@ const allowedProps: AllowedPropKeys = [
8793
'textAlign',
8894
'elementRef',
8995
'active',
90-
'unmountOnExit'
96+
'unmountOnExit',
97+
'tabIndex'
9198
]
9299

93100
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)