Skip to content

Commit

Permalink
Add focusZone to TabNav (#2139)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
owenniblock authored Jun 28, 2022
1 parent d09ea60 commit f17446e
Show file tree
Hide file tree
Showing 4 changed files with 120 additions and 5 deletions.
5 changes: 5 additions & 0 deletions .changeset/orange-spiders-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Changes focus rules of TabNav to match WAI-ARIA rules for tablist
21 changes: 18 additions & 3 deletions src/TabNav.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import classnames from 'classnames'
import {To} from 'history'
import React from 'react'
import React, {useRef} from 'react'
import styled from 'styled-components'
import {get} from './constants'
import {FocusKeys, useFocusZone} from './hooks/useFocusZone'
import sx, {SxProp} from './sx'
import {ComponentProps} from './utils/types'
import getGlobalFocusStyles from './_getGlobalFocusStyles'
Expand All @@ -28,8 +29,21 @@ const TabNavNav = styled.nav`
export type TabNavProps = ComponentProps<typeof TabNavBase>

function TabNav({children, 'aria-label': ariaLabel, ...rest}: TabNavProps) {
const customContainerRef = useRef<HTMLElement>(null)
const customStrategy = React.useCallback(() => {
if (customContainerRef.current) {
const tabs = Array.from(customContainerRef.current.querySelectorAll<HTMLElement>('a[aria-selected=true]'))
return tabs[0]
}
}, [customContainerRef])
const {containerRef: navRef} = useFocusZone({
containerRef: customContainerRef,
bindKeys: FocusKeys.ArrowHorizontal | FocusKeys.HomeAndEnd,
focusOutBehavior: 'wrap',
focusInStrategy: customStrategy
})
return (
<TabNavBase {...rest}>
<TabNavBase {...rest} ref={navRef as React.RefObject<HTMLDivElement>}>
<TabNavNav aria-label={ariaLabel}>
<TabNavTabList role="tablist">{children}</TabNavTabList>
</TabNavNav>
Expand All @@ -45,7 +59,8 @@ type StyledTabNavLinkProps = {
const TabNavLink = styled.a.attrs<StyledTabNavLinkProps>(props => ({
activeClassName: typeof props.to === 'string' ? 'selected' : '',
className: classnames(ITEM_CLASS, props.selected && SELECTED_CLASS, props.className),
role: 'tab'
role: 'tab',
'aria-selected': !!props.selected
}))<StyledTabNavLinkProps>`
padding: 8px 12px;
font-size: ${get('fontSizes.1')};
Expand Down
98 changes: 96 additions & 2 deletions src/__tests__/TabNav.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
import React from 'react'
import {TabNav} from '..'
import {behavesAsComponent, checkExports} from '../utils/testing'
import {render as HTMLRender, cleanup} from '@testing-library/react'
import {mount, behavesAsComponent, checkExports} from '../utils/testing'
import {fireEvent, render as HTMLRender, cleanup} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {axe, toHaveNoViolations} from 'jest-axe'
import 'babel-polyfill'
import {Button} from '../Button'
import Box from '../Box'
expect.extend(toHaveNoViolations)

describe('TabNav', () => {
const tabNavMarkup = (
<Box>
<TabNav>
<TabNav.Link id="first" href="#">
First
</TabNav.Link>
<TabNav.Link id="middle" href="#" selected>
Middle
</TabNav.Link>
<TabNav.Link id="last" href="#">
Last
</TabNav.Link>
</TabNav>
<Button id="my-button">My Button</Button>
</Box>
)

behavesAsComponent({Component: TabNav})

checkExports('TabNav', {
Expand All @@ -29,4 +49,78 @@ describe('TabNav', () => {
expect(getByLabelText('stuff')).toBeTruthy()
expect(getByLabelText('stuff').tagName).toEqual('NAV')
})

it('selects a tab when tab is loaded', () => {
const component = mount(tabNavMarkup)
const tab = component.find('#middle').first()
expect(tab.getDOMNode().classList).toContain('selected')
})

it('selects next tab when pressing right arrow', () => {
const {getByText} = HTMLRender(tabNavMarkup)
const middleTab = getByText('Middle')
const lastTab = getByText('Last')

fireEvent.focus(middleTab)
fireEvent.keyDown(middleTab, {key: 'ArrowRight'})

expect(lastTab).toHaveFocus()
})

it('selects previous tab when pressing left arrow', () => {
const {getByText} = HTMLRender(tabNavMarkup)
const middleTab = getByText('Middle')
const firstTab = getByText('First')

fireEvent.focus(middleTab)
fireEvent.keyDown(middleTab, {key: 'ArrowLeft'})

expect(firstTab).toHaveFocus()
})

it('selects last tab when pressing left arrow on first tab', () => {
const {getByText} = HTMLRender(tabNavMarkup)
const firstTab = getByText('First')
const lastTab = getByText('Last')

fireEvent.focus(firstTab)
fireEvent.keyDown(firstTab, {key: 'ArrowLeft'})

expect(lastTab).toHaveFocus()
})

it('selects first tab when pressing right arrow on last tab', () => {
const {getByText} = HTMLRender(tabNavMarkup)
const lastTab = getByText('Last')
const firstTab = getByText('First')

fireEvent.focus(lastTab)
fireEvent.keyDown(lastTab, {key: 'ArrowRight'})

expect(firstTab).toHaveFocus()
})

it('moves focus away from TabNav when pressing tab', () => {
const {getByText, getByRole} = HTMLRender(tabNavMarkup)
const middleTab = getByText('Middle')
const button = getByRole('button')

userEvent.click(middleTab)
expect(middleTab).toHaveFocus()
userEvent.tab()

expect(button).toHaveFocus()
})

it('moves focus to selected tab when TabNav regains focus', () => {
const {getByText, getByRole} = HTMLRender(tabNavMarkup)
const middleTab = getByText('Middle')
const button = getByRole('button')

userEvent.click(button)
expect(button).toHaveFocus()
userEvent.tab({shift: true})

expect(middleTab).toHaveFocus()
})
})
1 change: 1 addition & 0 deletions src/__tests__/__snapshots__/TabNav.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ exports[`TabNav TabNav.Link renders consistently 1`] = `
}
<a
aria-selected={false}
className="c0 TabNav-item"
role="tab"
/>
Expand Down

0 comments on commit f17446e

Please sign in to comment.