Skip to content

Commit f17446e

Browse files
authored
Add focusZone to TabNav (#2139)
* Add focusZone to TabNav * Add aria-selected to tabs * Custom strategy to ensure selected tab is focused on re-entry * Add tests for new TabNav focus management
1 parent d09ea60 commit f17446e

File tree

4 files changed

+120
-5
lines changed

4 files changed

+120
-5
lines changed

.changeset/orange-spiders-study.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
Changes focus rules of TabNav to match WAI-ARIA rules for tablist

src/TabNav.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import classnames from 'classnames'
22
import {To} from 'history'
3-
import React from 'react'
3+
import React, {useRef} from 'react'
44
import styled from 'styled-components'
55
import {get} from './constants'
6+
import {FocusKeys, useFocusZone} from './hooks/useFocusZone'
67
import sx, {SxProp} from './sx'
78
import {ComponentProps} from './utils/types'
89
import getGlobalFocusStyles from './_getGlobalFocusStyles'
@@ -28,8 +29,21 @@ const TabNavNav = styled.nav`
2829
export type TabNavProps = ComponentProps<typeof TabNavBase>
2930

3031
function TabNav({children, 'aria-label': ariaLabel, ...rest}: TabNavProps) {
32+
const customContainerRef = useRef<HTMLElement>(null)
33+
const customStrategy = React.useCallback(() => {
34+
if (customContainerRef.current) {
35+
const tabs = Array.from(customContainerRef.current.querySelectorAll<HTMLElement>('a[aria-selected=true]'))
36+
return tabs[0]
37+
}
38+
}, [customContainerRef])
39+
const {containerRef: navRef} = useFocusZone({
40+
containerRef: customContainerRef,
41+
bindKeys: FocusKeys.ArrowHorizontal | FocusKeys.HomeAndEnd,
42+
focusOutBehavior: 'wrap',
43+
focusInStrategy: customStrategy
44+
})
3145
return (
32-
<TabNavBase {...rest}>
46+
<TabNavBase {...rest} ref={navRef as React.RefObject<HTMLDivElement>}>
3347
<TabNavNav aria-label={ariaLabel}>
3448
<TabNavTabList role="tablist">{children}</TabNavTabList>
3549
</TabNavNav>
@@ -45,7 +59,8 @@ type StyledTabNavLinkProps = {
4559
const TabNavLink = styled.a.attrs<StyledTabNavLinkProps>(props => ({
4660
activeClassName: typeof props.to === 'string' ? 'selected' : '',
4761
className: classnames(ITEM_CLASS, props.selected && SELECTED_CLASS, props.className),
48-
role: 'tab'
62+
role: 'tab',
63+
'aria-selected': !!props.selected
4964
}))<StyledTabNavLinkProps>`
5065
padding: 8px 12px;
5166
font-size: ${get('fontSizes.1')};

src/__tests__/TabNav.test.tsx

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
11
import React from 'react'
22
import {TabNav} from '..'
3-
import {behavesAsComponent, checkExports} from '../utils/testing'
4-
import {render as HTMLRender, cleanup} from '@testing-library/react'
3+
import {mount, behavesAsComponent, checkExports} from '../utils/testing'
4+
import {fireEvent, render as HTMLRender, cleanup} from '@testing-library/react'
5+
import userEvent from '@testing-library/user-event'
56
import {axe, toHaveNoViolations} from 'jest-axe'
67
import 'babel-polyfill'
8+
import {Button} from '../Button'
9+
import Box from '../Box'
710
expect.extend(toHaveNoViolations)
811

912
describe('TabNav', () => {
13+
const tabNavMarkup = (
14+
<Box>
15+
<TabNav>
16+
<TabNav.Link id="first" href="#">
17+
First
18+
</TabNav.Link>
19+
<TabNav.Link id="middle" href="#" selected>
20+
Middle
21+
</TabNav.Link>
22+
<TabNav.Link id="last" href="#">
23+
Last
24+
</TabNav.Link>
25+
</TabNav>
26+
<Button id="my-button">My Button</Button>
27+
</Box>
28+
)
29+
1030
behavesAsComponent({Component: TabNav})
1131

1232
checkExports('TabNav', {
@@ -29,4 +49,78 @@ describe('TabNav', () => {
2949
expect(getByLabelText('stuff')).toBeTruthy()
3050
expect(getByLabelText('stuff').tagName).toEqual('NAV')
3151
})
52+
53+
it('selects a tab when tab is loaded', () => {
54+
const component = mount(tabNavMarkup)
55+
const tab = component.find('#middle').first()
56+
expect(tab.getDOMNode().classList).toContain('selected')
57+
})
58+
59+
it('selects next tab when pressing right arrow', () => {
60+
const {getByText} = HTMLRender(tabNavMarkup)
61+
const middleTab = getByText('Middle')
62+
const lastTab = getByText('Last')
63+
64+
fireEvent.focus(middleTab)
65+
fireEvent.keyDown(middleTab, {key: 'ArrowRight'})
66+
67+
expect(lastTab).toHaveFocus()
68+
})
69+
70+
it('selects previous tab when pressing left arrow', () => {
71+
const {getByText} = HTMLRender(tabNavMarkup)
72+
const middleTab = getByText('Middle')
73+
const firstTab = getByText('First')
74+
75+
fireEvent.focus(middleTab)
76+
fireEvent.keyDown(middleTab, {key: 'ArrowLeft'})
77+
78+
expect(firstTab).toHaveFocus()
79+
})
80+
81+
it('selects last tab when pressing left arrow on first tab', () => {
82+
const {getByText} = HTMLRender(tabNavMarkup)
83+
const firstTab = getByText('First')
84+
const lastTab = getByText('Last')
85+
86+
fireEvent.focus(firstTab)
87+
fireEvent.keyDown(firstTab, {key: 'ArrowLeft'})
88+
89+
expect(lastTab).toHaveFocus()
90+
})
91+
92+
it('selects first tab when pressing right arrow on last tab', () => {
93+
const {getByText} = HTMLRender(tabNavMarkup)
94+
const lastTab = getByText('Last')
95+
const firstTab = getByText('First')
96+
97+
fireEvent.focus(lastTab)
98+
fireEvent.keyDown(lastTab, {key: 'ArrowRight'})
99+
100+
expect(firstTab).toHaveFocus()
101+
})
102+
103+
it('moves focus away from TabNav when pressing tab', () => {
104+
const {getByText, getByRole} = HTMLRender(tabNavMarkup)
105+
const middleTab = getByText('Middle')
106+
const button = getByRole('button')
107+
108+
userEvent.click(middleTab)
109+
expect(middleTab).toHaveFocus()
110+
userEvent.tab()
111+
112+
expect(button).toHaveFocus()
113+
})
114+
115+
it('moves focus to selected tab when TabNav regains focus', () => {
116+
const {getByText, getByRole} = HTMLRender(tabNavMarkup)
117+
const middleTab = getByText('Middle')
118+
const button = getByRole('button')
119+
120+
userEvent.click(button)
121+
expect(button).toHaveFocus()
122+
userEvent.tab({shift: true})
123+
124+
expect(middleTab).toHaveFocus()
125+
})
32126
})

src/__tests__/__snapshots__/TabNav.test.tsx.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ exports[`TabNav TabNav.Link renders consistently 1`] = `
4545
}
4646
4747
<a
48+
aria-selected={false}
4849
className="c0 TabNav-item"
4950
role="tab"
5051
/>

0 commit comments

Comments
 (0)