Skip to content

Commit a2950ac

Browse files
Improves keyboard navigation for the SegmentedControl (#2145)
* implements keyboard navigation strategy for the SegmentedControl suggested by the a11y team, and warns component users if the control does not have a label * adds changeset * crappy fix for bug where useFocusZone breaks after selecting a button * corrects typo in test name * consolidates button focus test, and adds comment to focusInStrategy test * remove getNextFocusable by tweaking focusInStrategy Co-authored-by: Siddharth Kshetrapal <[email protected]>
1 parent 300025d commit a2950ac

File tree

6 files changed

+146
-14
lines changed

6 files changed

+146
-14
lines changed

.changeset/tiny-radios-wait.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': patch
3+
---
4+
5+
Updates SegmentedControl component's keyboard navigation to align with the recommendations of GitHub's accessibility team.

src/SegmentedControl/SegmentedControl.test.tsx

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,34 @@
11
import React from 'react'
22
import '@testing-library/jest-dom/extend-expect'
3-
import {render} from '@testing-library/react'
3+
import {fireEvent, render} from '@testing-library/react'
44
import {EyeIcon, FileCodeIcon, PeopleIcon} from '@primer/octicons-react'
55
import userEvent from '@testing-library/user-event'
66
import {behavesAsComponent, checkExports, checkStoriesForAxeViolations} from '../utils/testing'
77
import {SegmentedControl} from '.' // TODO: update import when we move this to the global index
88

99
const segmentData = [
10-
{label: 'Preview', iconLabel: 'EyeIcon', icon: () => <EyeIcon aria-label="EyeIcon" />},
11-
{label: 'Raw', iconLabel: 'FileCodeIcon', icon: () => <FileCodeIcon aria-label="FileCodeIcon" />},
12-
{label: 'Blame', iconLabel: 'PeopleIcon', icon: () => <PeopleIcon aria-label="PeopleIcon" />}
10+
{label: 'Preview', id: 'preview', iconLabel: 'EyeIcon', icon: () => <EyeIcon aria-label="EyeIcon" />},
11+
{label: 'Raw', id: 'raw', iconLabel: 'FileCodeIcon', icon: () => <FileCodeIcon aria-label="FileCodeIcon" />},
12+
{label: 'Blame', id: 'blame', iconLabel: 'PeopleIcon', icon: () => <PeopleIcon aria-label="PeopleIcon" />}
1313
]
1414

1515
// TODO: improve test coverage
1616
describe('SegmentedControl', () => {
17+
const mockWarningFn = jest.fn()
18+
19+
beforeAll(() => {
20+
jest.spyOn(global.console, 'warn').mockImplementation(mockWarningFn)
21+
})
22+
1723
behavesAsComponent({
1824
Component: SegmentedControl,
1925
toRender: () => (
2026
<SegmentedControl aria-label="File view">
21-
<SegmentedControl.Button selected>Preview</SegmentedControl.Button>
22-
<SegmentedControl.Button>Raw</SegmentedControl.Button>
23-
<SegmentedControl.Button>Blame</SegmentedControl.Button>
27+
{segmentData.map(({label}, index) => (
28+
<SegmentedControl.Button selected={index === 0} key={label}>
29+
{label}
30+
</SegmentedControl.Button>
31+
))}
2432
</SegmentedControl>
2533
)
2634
})
@@ -133,6 +141,68 @@ describe('SegmentedControl', () => {
133141
}
134142
expect(handleClick).toHaveBeenCalled()
135143
})
144+
145+
it('focuses the selected button first', () => {
146+
const {getByRole} = render(
147+
<>
148+
<button>Before</button>
149+
<SegmentedControl aria-label="File view">
150+
{segmentData.map(({label, id}, index) => (
151+
<SegmentedControl.Button selected={index === 1} key={label} id={id}>
152+
{label}
153+
</SegmentedControl.Button>
154+
))}
155+
</SegmentedControl>
156+
</>
157+
)
158+
const initialFocusButtonNode = getByRole('button', {name: segmentData[1].label})
159+
160+
expect(document.activeElement?.id).not.toEqual(initialFocusButtonNode.id)
161+
162+
userEvent.tab() // focus the button before the segmented control
163+
userEvent.tab() // move focus into the segmented control
164+
165+
expect(document.activeElement?.id).toEqual(initialFocusButtonNode.id)
166+
})
167+
168+
it('focuses the previous button when keying ArrowLeft, and the next button when keying ArrowRight', () => {
169+
const {getByRole} = render(
170+
<SegmentedControl aria-label="File view">
171+
{segmentData.map(({label, id}, index) => (
172+
<SegmentedControl.Button selected={index === 1} key={label} id={id}>
173+
{label}
174+
</SegmentedControl.Button>
175+
))}
176+
</SegmentedControl>
177+
)
178+
const initialFocusButtonNode = getByRole('button', {name: segmentData[1].label})
179+
const nextFocusButtonNode = getByRole('button', {name: segmentData[0].label})
180+
181+
expect(document.activeElement?.id).not.toEqual(nextFocusButtonNode.id)
182+
183+
fireEvent.focus(initialFocusButtonNode)
184+
fireEvent.keyDown(initialFocusButtonNode, {key: 'ArrowLeft'})
185+
186+
expect(document.activeElement?.id).toEqual(nextFocusButtonNode.id)
187+
188+
fireEvent.keyDown(initialFocusButtonNode, {key: 'ArrowRight'})
189+
190+
expect(document.activeElement?.id).toEqual(initialFocusButtonNode.id)
191+
})
192+
193+
it('should warn the user if they neglect to specify a label for the segmented control', () => {
194+
render(
195+
<SegmentedControl>
196+
{segmentData.map(({label, id}) => (
197+
<SegmentedControl.Button id={id} key={label}>
198+
{label}
199+
</SegmentedControl.Button>
200+
))}
201+
</SegmentedControl>
202+
)
203+
204+
expect(mockWarningFn).toHaveBeenCalled()
205+
})
136206
})
137207

138208
checkStoriesForAxeViolations('examples', '../SegmentedControl/')

src/SegmentedControl/SegmentedControl.tsx

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import React from 'react'
1+
import React, {RefObject, useRef} from 'react'
22
import Button, {SegmentedControlButtonProps} from './SegmentedControlButton'
33
import SegmentedControlIconButton, {SegmentedControlIconButtonProps} from './SegmentedControlIconButton'
44
import {Box, useTheme} from '..'
55
import {merge, SxProp} from '../sx'
6+
import {FocusKeys, FocusZoneHookSettings, useFocusZone} from '../hooks/useFocusZone'
67

78
type SegmentedControlProps = {
89
'aria-label'?: string
@@ -28,10 +29,16 @@ const getSegmentedControlStyles = (props?: SegmentedControlProps) => ({
2829
})
2930

3031
// TODO: implement `variant` prop for responsive behavior
31-
// TODO: implement `loading` prop
32-
// TODO: log a warning if no `ariaLabel` or `ariaLabelledBy` prop is passed
33-
// TODO: implement keyboard behavior to move focus using the arrow keys
34-
const Root: React.FC<SegmentedControlProps> = ({children, fullWidth, onChange, sx: sxProp = {}, ...rest}) => {
32+
const Root: React.FC<SegmentedControlProps> = ({
33+
'aria-label': ariaLabel,
34+
'aria-labelledby': ariaLabelledby,
35+
children,
36+
fullWidth,
37+
onChange,
38+
sx: sxProp = {},
39+
...rest
40+
}) => {
41+
const segmentedControlContainerRef = useRef<HTMLSpanElement>(null)
3542
const {theme} = useTheme()
3643
const selectedChildren = React.Children.toArray(children).map(
3744
child =>
@@ -46,8 +53,41 @@ const Root: React.FC<SegmentedControlProps> = ({children, fullWidth, onChange, s
4653
sxProp as SxProp
4754
)
4855

56+
const focusInStrategy: FocusZoneHookSettings['focusInStrategy'] = () => {
57+
if (segmentedControlContainerRef.current) {
58+
// we need to use type assertion because querySelector returns "Element", not "HTMLElement"
59+
type SelectedButton = HTMLButtonElement | undefined
60+
61+
const selectedButton = segmentedControlContainerRef.current.querySelector(
62+
'button[aria-current="true"]'
63+
) as SelectedButton
64+
65+
return selectedButton
66+
}
67+
}
68+
69+
useFocusZone({
70+
containerRef: segmentedControlContainerRef,
71+
bindKeys: FocusKeys.ArrowHorizontal | FocusKeys.HomeAndEnd,
72+
focusInStrategy
73+
})
74+
75+
if (!ariaLabel && !ariaLabelledby) {
76+
// eslint-disable-next-line no-console
77+
console.warn(
78+
'Use the `aria-label` or `aria-labelledby` prop to provide an accessible label for assistive technology'
79+
)
80+
}
81+
4982
return (
50-
<Box role="toolbar" sx={sx} {...rest}>
83+
<Box
84+
role="toolbar"
85+
sx={sx}
86+
aria-label={ariaLabel}
87+
aria-labelledby={ariaLabelledby}
88+
ref={segmentedControlContainerRef as RefObject<HTMLDivElement>}
89+
{...rest}
90+
>
5191
{React.Children.map(children, (child, i) => {
5292
if (React.isValidElement<SegmentedControlButtonProps | SegmentedControlIconButtonProps>(child)) {
5393
return React.cloneElement(child, {

src/SegmentedControl/__snapshots__/SegmentedControl.test.tsx.snap

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ exports[`SegmentedControl renders consistently 1`] = `
8686
width: 1px;
8787
}
8888
89+
.c1:focus:focus-visible:not(:last-child):after {
90+
width: 0;
91+
}
92+
8993
.c1 .segmentedControl-text:after {
9094
content: "Preview";
9195
display: block;
@@ -180,6 +184,10 @@ exports[`SegmentedControl renders consistently 1`] = `
180184
width: 1px;
181185
}
182186
187+
.c2:focus:focus-visible:not(:last-child):after {
188+
width: 0;
189+
}
190+
183191
.c2 .segmentedControl-text:after {
184192
content: "Raw";
185193
display: block;
@@ -274,6 +282,10 @@ exports[`SegmentedControl renders consistently 1`] = `
274282
width: 1px;
275283
}
276284
285+
.c3:focus:focus-visible:not(:last-child):after {
286+
width: 0;
287+
}
288+
277289
.c3 .segmentedControl-text:after {
278290
content: "Blame";
279291
display: block;

src/SegmentedControl/examples.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export const Default = (args: Args) => (
5050
)
5151

5252
export const Controlled = (args: Args) => {
53-
const [selectedIndex, setSelectedIndex] = useState(1)
53+
const [selectedIndex, setSelectedIndex] = useState(0)
5454
const handleChange = (i: number) => {
5555
setSelectedIndex(i)
5656
}

src/SegmentedControl/getSegmentedControlStyles.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ const getSegmentedControlButtonStyles = (props?: SegmentedControlButtonProps & {
7575
}
7676
},
7777

78+
// fixes an issue where the focus outline shows over the pseudo-element
79+
':focus:focus-visible:not(:last-child):after': {
80+
width: 0
81+
},
82+
7883
'.segmentedControl-text': {
7984
':after': {
8085
content: `"${props?.children}"`,

0 commit comments

Comments
 (0)