diff --git a/.changeset/orange-spiders-study.md b/.changeset/orange-spiders-study.md new file mode 100644 index 00000000000..8b39b560793 --- /dev/null +++ b/.changeset/orange-spiders-study.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Changes focus rules of TabNav to match WAI-ARIA rules for tablist diff --git a/src/TabNav.tsx b/src/TabNav.tsx index fc6cd810a83..6e1ec7f964e 100644 --- a/src/TabNav.tsx +++ b/src/TabNav.tsx @@ -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' @@ -28,8 +29,21 @@ const TabNavNav = styled.nav` export type TabNavProps = ComponentProps function TabNav({children, 'aria-label': ariaLabel, ...rest}: TabNavProps) { + const customContainerRef = useRef(null) + const customStrategy = React.useCallback(() => { + if (customContainerRef.current) { + const tabs = Array.from(customContainerRef.current.querySelectorAll('a[aria-selected=true]')) + return tabs[0] + } + }, [customContainerRef]) + const {containerRef: navRef} = useFocusZone({ + containerRef: customContainerRef, + bindKeys: FocusKeys.ArrowHorizontal | FocusKeys.HomeAndEnd, + focusOutBehavior: 'wrap', + focusInStrategy: customStrategy + }) return ( - + }> {children} @@ -45,7 +59,8 @@ type StyledTabNavLinkProps = { const TabNavLink = styled.a.attrs(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 }))` padding: 8px 12px; font-size: ${get('fontSizes.1')}; diff --git a/src/__tests__/TabNav.test.tsx b/src/__tests__/TabNav.test.tsx index 3362e2d917e..09cc2ea06d0 100644 --- a/src/__tests__/TabNav.test.tsx +++ b/src/__tests__/TabNav.test.tsx @@ -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 = ( + + + + First + + + Middle + + + Last + + + + + ) + behavesAsComponent({Component: TabNav}) checkExports('TabNav', { @@ -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() + }) }) diff --git a/src/__tests__/__snapshots__/TabNav.test.tsx.snap b/src/__tests__/__snapshots__/TabNav.test.tsx.snap index edcc428b949..52d5e00853b 100644 --- a/src/__tests__/__snapshots__/TabNav.test.tsx.snap +++ b/src/__tests__/__snapshots__/TabNav.test.tsx.snap @@ -45,6 +45,7 @@ exports[`TabNav TabNav.Link renders consistently 1`] = ` }