Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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