Skip to content

Commit f4ef7b4

Browse files
authored
Update SegmentedControl to improve the experience for assistive technologies (#2220)
* updates role and focus behavior for SegmentedControl buttons, fixes style bug for full-width icon-only buttons, fixes bug for icon-only buttons where onClick and other shared props weren't being passed * updates tests * adds changeset * fixes visual regression
1 parent 7edf134 commit f4ef7b4

File tree

7 files changed

+239
-290
lines changed

7 files changed

+239
-290
lines changed

.changeset/old-experts-applaud.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@primer/react': patch
3+
---
4+
5+
- Fixes `role` and keyboard behavior for SegmentedControl.
6+
- Fixes a bug where icon-only SegmentedControl buttons did not fill the parent width when the `fullWidth` prop was set
7+
- Fixes a bug where click handlers were not passed correctly when the responsive variant was set to `'hideLabels'`

src/SegmentedControl/SegmentedControl.test.tsx

Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -197,55 +197,6 @@ describe('SegmentedControl', () => {
197197
expect(handleClick).toHaveBeenCalled()
198198
})
199199

200-
it('focuses the selected button first', async () => {
201-
const user = userEvent.setup()
202-
const {getByRole} = render(
203-
<>
204-
<button>Before</button>
205-
<SegmentedControl aria-label="File view">
206-
{segmentData.map(({label, id}, index) => (
207-
<SegmentedControl.Button selected={index === 1} key={label} id={id}>
208-
{label}
209-
</SegmentedControl.Button>
210-
))}
211-
</SegmentedControl>
212-
</>
213-
)
214-
const initialFocusButtonNode = getByRole('button', {name: segmentData[1].label})
215-
216-
expect(document.activeElement?.id).not.toEqual(initialFocusButtonNode.id)
217-
218-
await user.tab() // focus the button before the segmented control
219-
await user.tab() // move focus into the segmented control
220-
221-
expect(document.activeElement?.id).toEqual(initialFocusButtonNode.id)
222-
})
223-
224-
it('focuses the previous button when keying ArrowLeft, and the next button when keying ArrowRight', () => {
225-
const {getByRole} = render(
226-
<SegmentedControl aria-label="File view">
227-
{segmentData.map(({label, id}, index) => (
228-
<SegmentedControl.Button selected={index === 1} key={label} id={id}>
229-
{label}
230-
</SegmentedControl.Button>
231-
))}
232-
</SegmentedControl>
233-
)
234-
const initialFocusButtonNode = getByRole('button', {name: segmentData[1].label})
235-
const nextFocusButtonNode = getByRole('button', {name: segmentData[0].label})
236-
237-
expect(document.activeElement?.id).not.toEqual(nextFocusButtonNode.id)
238-
239-
fireEvent.focus(initialFocusButtonNode)
240-
fireEvent.keyDown(initialFocusButtonNode, {key: 'ArrowLeft'})
241-
242-
expect(document.activeElement?.id).toEqual(nextFocusButtonNode.id)
243-
244-
fireEvent.keyDown(initialFocusButtonNode, {key: 'ArrowRight'})
245-
246-
expect(document.activeElement?.id).toEqual(initialFocusButtonNode.id)
247-
})
248-
249200
it('calls onChange with index of clicked segment button when using the dropdown variant', async () => {
250201
act(() => {
251202
matchMedia.useMediaQuery(viewportRanges.narrow)

src/SegmentedControl/SegmentedControl.tsx

Lines changed: 24 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1-
import React, {RefObject, useRef} from 'react'
1+
import React, {useRef} from 'react'
22
import Button, {SegmentedControlButtonProps} from './SegmentedControlButton'
33
import SegmentedControlIconButton, {SegmentedControlIconButtonProps} from './SegmentedControlIconButton'
4-
import {ActionList, ActionMenu, Box, useTheme} from '..'
5-
import {merge, SxProp} from '../sx'
4+
import {ActionList, ActionMenu, useTheme} from '..'
5+
import sx, {merge, SxProp} from '../sx'
66
import {ResponsiveValue, useResponsiveValue} from '../hooks/useResponsiveValue'
77
import {ViewportRangeKeys} from '../utils/types/ViewportRangeKeys'
8-
import {FocusKeys, FocusZoneHookSettings, useFocusZone} from '../hooks/useFocusZone'
8+
import styled from 'styled-components'
99

1010
type WidthOnlyViewportRangeKeys = Exclude<ViewportRangeKeys, 'narrowLandscape' | 'portrait' | 'landscape'>
1111

12+
// Needed because passing a ref to `Box` causes a type error
13+
const SegmentedControlList = styled.ul`
14+
${sx};
15+
`
16+
1217
type SegmentedControlProps = {
1318
'aria-label'?: string
1419
'aria-labelledby'?: string
@@ -28,7 +33,10 @@ const getSegmentedControlStyles = (isFullWidth?: boolean) => ({
2833
borderStyle: 'solid',
2934
borderWidth: 1,
3035
display: isFullWidth ? 'flex' : 'inline-flex',
31-
height: '32px' // TODO: use primitive `control.medium.size` when it is available
36+
height: '32px', // TODO: use primitive `control.medium.size` when it is available
37+
margin: 0,
38+
padding: 0,
39+
width: isFullWidth ? '100%' : undefined
3240
})
3341

3442
const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
@@ -41,7 +49,7 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
4149
variant,
4250
...rest
4351
}) => {
44-
const segmentedControlContainerRef = useRef<HTMLSpanElement>(null)
52+
const segmentedControlContainerRef = useRef<HTMLUListElement>(null)
4553
const {theme} = useTheme()
4654
const responsiveVariant = useResponsiveValue(variant, 'default')
4755
const isFullWidth = useResponsiveValue(fullWidth, false)
@@ -74,26 +82,7 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
7482

7583
return React.isValidElement<SegmentedControlIconButtonProps>(childArg) ? childArg.props['aria-label'] : null
7684
}
77-
const sx = merge(getSegmentedControlStyles(isFullWidth), sxProp as SxProp)
78-
79-
const focusInStrategy: FocusZoneHookSettings['focusInStrategy'] = () => {
80-
if (segmentedControlContainerRef.current) {
81-
// we need to use type assertion because querySelector returns "Element", not "HTMLElement"
82-
type SelectedButton = HTMLButtonElement | undefined
83-
84-
const selectedButton = segmentedControlContainerRef.current.querySelector(
85-
'button[aria-current="true"]'
86-
) as SelectedButton
87-
88-
return selectedButton
89-
}
90-
}
91-
92-
useFocusZone({
93-
containerRef: segmentedControlContainerRef,
94-
bindKeys: FocusKeys.ArrowHorizontal | FocusKeys.HomeAndEnd,
95-
focusInStrategy
96-
})
85+
const listSx = merge(getSegmentedControlStyles(isFullWidth), sxProp as SxProp)
9786

9887
if (!ariaLabel && !ariaLabelledby) {
9988
// eslint-disable-next-line no-console
@@ -141,12 +130,11 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
141130
</ActionMenu>
142131
) : (
143132
// Render a segmented control
144-
<Box
145-
role="toolbar"
146-
sx={sx}
133+
<SegmentedControlList
134+
sx={listSx}
147135
aria-label={ariaLabel}
148136
aria-labelledby={ariaLabelledby}
149-
ref={segmentedControlContainerRef as RefObject<HTMLDivElement>}
137+
ref={segmentedControlContainerRef}
150138
{...rest}
151139
>
152140
{React.Children.map(children, (child, index) => {
@@ -180,6 +168,7 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
180168
children: childPropsChildren,
181169
...restChildProps
182170
} = child.props
171+
const {sx: sharedSxProp, ...restSharedChildProps} = sharedChildProps
183172
if (!leadingIcon) {
184173
// eslint-disable-next-line no-console
185174
console.warn('A `leadingIcon` prop is required when hiding visible labels')
@@ -190,12 +179,12 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
190179
icon={leadingIcon}
191180
sx={
192181
{
193-
'--separator-color':
194-
index === selectedIndex || index === selectedIndex - 1
195-
? 'transparent'
196-
: theme?.colors.border.default
182+
...sharedSxProp,
183+
// setting width here avoids having to pass `isFullWidth` directly to child components
184+
width: !isFullWidth ? '32px' : '100%' // TODO: use primitive `control.medium.size` when it is available instead of '32px'
197185
} as React.CSSProperties
198186
}
187+
{...restSharedChildProps}
199188
{...restChildProps}
200189
/>
201190
)
@@ -205,7 +194,7 @@ const Root: React.FC<React.PropsWithChildren<SegmentedControlProps>> = ({
205194
// Render the children as-is and add the shared child props
206195
return React.cloneElement(child, sharedChildProps)
207196
})}
208-
</Box>
197+
</SegmentedControlList>
209198
)
210199
}
211200

src/SegmentedControl/SegmentedControlButton.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {IconProps} from '@primer/octicons-react'
33
import styled from 'styled-components'
44
import {Box} from '..'
55
import sx, {merge, SxProp} from '../sx'
6-
import {getSegmentedControlButtonStyles} from './getSegmentedControlStyles'
6+
import {getSegmentedControlButtonStyles, getSegmentedControlListItemStyles} from './getSegmentedControlStyles'
77

88
export type SegmentedControlButtonProps = {
99
/** The visible label rendered in the button */
@@ -26,19 +26,25 @@ const SegmentedControlButton: React.FC<React.PropsWithChildren<SegmentedControlB
2626
sx: sxProp = {},
2727
...rest
2828
}) => {
29-
const mergedSx = merge(getSegmentedControlButtonStyles({selected, children}), sxProp as SxProp)
29+
const mergedSx = merge(getSegmentedControlListItemStyles(), sxProp as SxProp)
3030

3131
return (
32-
<SegmentedControlButtonStyled aria-current={selected} sx={mergedSx} {...rest}>
33-
<span className="segmentedControl-content">
34-
{LeadingIcon && (
35-
<Box mr={1}>
36-
<LeadingIcon />
37-
</Box>
38-
)}
39-
<Box className="segmentedControl-text">{children}</Box>
40-
</span>
41-
</SegmentedControlButtonStyled>
32+
<Box as="li" sx={mergedSx}>
33+
<SegmentedControlButtonStyled
34+
aria-current={selected}
35+
sx={getSegmentedControlButtonStyles({selected, children})}
36+
{...rest}
37+
>
38+
<span className="segmentedControl-content">
39+
{LeadingIcon && (
40+
<Box mr={1}>
41+
<LeadingIcon />
42+
</Box>
43+
)}
44+
<Box className="segmentedControl-text">{children}</Box>
45+
</span>
46+
</SegmentedControlButtonStyled>
47+
</Box>
4248
)
4349
}
4450

src/SegmentedControl/SegmentedControlIconButton.tsx

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,9 @@ import React, {HTMLAttributes} from 'react'
22
import {IconProps} from '@primer/octicons-react'
33
import styled from 'styled-components'
44
import sx, {merge, SxProp} from '../sx'
5-
import {
6-
borderedSegment,
7-
getSegmentedControlButtonStyles,
8-
directChildLayoutAdjustments
9-
} from './getSegmentedControlStyles'
5+
import {getSegmentedControlButtonStyles, getSegmentedControlListItemStyles} from './getSegmentedControlStyles'
106
import Tooltip from '../Tooltip'
7+
import Box from '../Box'
118

129
export type SegmentedControlIconButtonProps = {
1310
'aria-label': string
@@ -35,26 +32,28 @@ export const SegmentedControlIconButton: React.FC<React.PropsWithChildren<Segmen
3532
sx: sxProp = {},
3633
...rest
3734
}) => {
38-
const mergedSx = merge(getSegmentedControlButtonStyles({selected, isIconOnly: true}), sxProp as SxProp)
35+
const mergedSx = merge(
36+
{
37+
width: '32px', // TODO: use primitive `control.medium.size` when it is available
38+
...getSegmentedControlListItemStyles()
39+
},
40+
sxProp as SxProp
41+
)
3942

4043
return (
41-
<Tooltip
42-
text={ariaLabel}
43-
sx={{
44-
// Since the element rendered by Tooltip is now the direct child,
45-
// we need to put these styles on the Tooltip instead of the button.
46-
...directChildLayoutAdjustments,
47-
// The border drawn by the `:after` pseudo-element needs to scoped to the child button
48-
// because Tooltip uses `:before` and `:after` to render the tooltip.
49-
':not(:last-child) button': borderedSegment
50-
}}
51-
>
52-
<SegmentedControlIconButtonStyled aria-pressed={selected} sx={mergedSx} {...rest}>
53-
<span className="segmentedControl-content">
54-
<Icon />
55-
</span>
56-
</SegmentedControlIconButtonStyled>
57-
</Tooltip>
44+
<Box as="li" sx={mergedSx}>
45+
<Tooltip text={ariaLabel}>
46+
<SegmentedControlIconButtonStyled
47+
aria-pressed={selected}
48+
sx={getSegmentedControlButtonStyles({selected, isIconOnly: true})}
49+
{...rest}
50+
>
51+
<span className="segmentedControl-content">
52+
<Icon />
53+
</span>
54+
</SegmentedControlIconButtonStyled>
55+
</Tooltip>
56+
</Box>
5857
)
5958
}
6059

0 commit comments

Comments
 (0)