Skip to content

Commit 3c9e322

Browse files
TylerJDevcamertronjoshblack
authored
Accessibility fixes for ToggleSwitch (#4744)
Co-authored-by: Cameron Dutro <camertron@gmail.com> Co-authored-by: Josh Black <joshblack@github.com>
1 parent c2bda30 commit 3c9e322

File tree

8 files changed

+475
-26
lines changed

8 files changed

+475
-26
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
Address additional ToggleSwitch a11y feedback

packages/react/src/ToggleSwitch/ToggleSwitch.docs.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,18 @@
9595
"defaultValue": "'start'",
9696
"description": "Whether the \"on\" and \"off\" labels should appear before or after the switch.\n\n**This should only be changed when the switch's alignment needs to be adjusted.** For example: It needs to be left-aligned because the label appears above it and the caption appears below it."
9797
},
98+
{
99+
"name": "loadingLabelDelay",
100+
"type": "number",
101+
"defaultValue": "2000",
102+
"description": "When the switch is in the loading state, this value controls the amount of delay in milliseconds before the word \"Loading\" is announced to screen readers."
103+
},
104+
{
105+
"name": "loadingLabel",
106+
"type": "string",
107+
"defaultValue": "'Loading'",
108+
"description": "The text that is announced to AT such as screen readers when the switch is in a loading state."
109+
},
98110
{
99111
"name": "buttonType",
100112
"type": "'button' | 'submit' | 'reset'",

packages/react/src/ToggleSwitch/ToggleSwitch.features.stories.tsx

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import React from 'react'
1+
import React, {useState} from 'react'
2+
import {useSafeTimeout} from '..'
23
import ToggleSwitch from './ToggleSwitch'
34
import {action} from 'storybook/actions'
45
import ToggleSwitchStoryWrapper from './ToggleSwitchStoryWrapper'
@@ -68,6 +69,65 @@ export const Loading = () => (
6869
</ToggleSwitchStoryWrapper>
6970
)
7071

72+
type LoadingWithDelayProps = {
73+
loadingDelay: number
74+
loadingLabelDelay: number
75+
}
76+
77+
export const LoadingWithDelay = (args: LoadingWithDelayProps) => {
78+
const {loadingDelay, loadingLabelDelay} = args
79+
80+
const [isLoading, setIsLoading] = useState(false)
81+
const [timeoutId, setTimeoutId] = useState<number | null>(null)
82+
const [toggleState, setToggleState] = useState(false)
83+
84+
const {safeSetTimeout, safeClearTimeout} = useSafeTimeout()
85+
86+
const handleToggleClick = () => {
87+
setIsLoading(true)
88+
89+
if (timeoutId) {
90+
safeClearTimeout(timeoutId)
91+
setTimeoutId(null)
92+
}
93+
94+
setTimeoutId(safeSetTimeout(() => setIsLoading(false), loadingDelay) as unknown as number)
95+
}
96+
97+
return (
98+
<ToggleSwitchStoryWrapper>
99+
<span id="toggle" style={{fontWeight: 'bold', fontSize: 'var(--base-size-14)'}}>
100+
Enable feature
101+
</span>
102+
<ToggleSwitch
103+
loading={isLoading}
104+
loadingLabel={`${toggleState ? 'Enabling' : 'Disabling'} feature`}
105+
loadingLabelDelay={loadingLabelDelay}
106+
aria-labelledby="toggle"
107+
onClick={handleToggleClick}
108+
onChange={(on: boolean) => setToggleState(on)}
109+
/>
110+
</ToggleSwitchStoryWrapper>
111+
)
112+
}
113+
114+
LoadingWithDelay.args = {
115+
loadingDelay: 5000,
116+
loadingLabelDelay: 2000,
117+
}
118+
LoadingWithDelay.argTypes = {
119+
loadingDelay: {
120+
control: {
121+
type: 'number',
122+
},
123+
},
124+
loadingLabelDelay: {
125+
control: {
126+
type: 'number',
127+
},
128+
},
129+
}
130+
71131
export const LabelEnd = () => (
72132
<ToggleSwitchStoryWrapper>
73133
<span id="toggle" className={styles.ToggleLabel}>

packages/react/src/ToggleSwitch/ToggleSwitch.figma.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@ figma.connect(
2121
}),
2222
},
2323
example: ({size, checked, labelposition, loading}) => (
24-
<ToggleSwitch size={size} checked={checked} statusLabelPosition={labelposition} loading={loading} />
24+
<ToggleSwitch
25+
aria-labelledby=""
26+
size={size}
27+
checked={checked}
28+
statusLabelPosition={labelposition}
29+
loading={loading}
30+
/>
2531
),
2632
},
2733
)

packages/react/src/ToggleSwitch/ToggleSwitch.test.tsx

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {describe, expect, it, vi} from 'vitest'
22
import React from 'react'
3-
import {render} from '@testing-library/react'
3+
import {render, waitFor} from '@testing-library/react'
44
import ToggleSwitch from './'
55
import userEvent from '@testing-library/user-event'
66

@@ -46,7 +46,7 @@ describe('ToggleSwitch', () => {
4646
expect(toggleSwitch).toHaveAttribute('aria-pressed', 'false')
4747
})
4848

49-
it("renders a switch who's state is loading", async () => {
49+
it('renders a switch whose state is loading', async () => {
5050
const user = userEvent.setup()
5151
const {getByLabelText, container} = render(
5252
<>
@@ -94,6 +94,22 @@ describe('ToggleSwitch', () => {
9494
expect(toggleSwitch).toHaveAttribute('aria-pressed', 'true')
9595
})
9696

97+
it('ensures the status label cannot toggle a disabled switch', async () => {
98+
const user = userEvent.setup()
99+
const {getByLabelText, getByText} = render(
100+
<>
101+
<div id="switchLabel">{SWITCH_LABEL_TEXT}</div>
102+
<ToggleSwitch aria-labelledby="switchLabel" disabled />
103+
</>,
104+
)
105+
const toggleSwitch = getByLabelText(SWITCH_LABEL_TEXT)
106+
const toggleSwitchStatusLabel = getByText('Off')
107+
108+
expect(toggleSwitch).toHaveAttribute('aria-pressed', 'false')
109+
await user.click(toggleSwitchStatusLabel)
110+
expect(toggleSwitch).toHaveAttribute('aria-pressed', 'false')
111+
})
112+
97113
it('switches from off to on with a controlled prop', async () => {
98114
const user = userEvent.setup()
99115
const ControlledSwitchComponent = () => {
@@ -157,6 +173,18 @@ describe('ToggleSwitch', () => {
157173
expect(toggleSwitch).toBeInTheDocument()
158174
})
159175

176+
it('renders a switch that has button type button', () => {
177+
const {getByLabelText} = render(
178+
<>
179+
<div id="switchLabel">{SWITCH_LABEL_TEXT}</div>
180+
<ToggleSwitch aria-labelledby="switchLabel" />
181+
</>,
182+
)
183+
184+
const toggleSwitch = getByLabelText(SWITCH_LABEL_TEXT)
185+
expect(toggleSwitch).toHaveAttribute('type', 'button')
186+
})
187+
160188
it('supports a `ref` on the inner <button> element', () => {
161189
const ref = vi.fn()
162190

@@ -171,15 +199,17 @@ describe('ToggleSwitch', () => {
171199
expect(ref).toHaveBeenCalledWith(expect.any(HTMLButtonElement))
172200
})
173201

174-
it('renders a switch that has button type button', () => {
175-
const {getByLabelText} = render(
202+
it('displays a loading label', async () => {
203+
const TEST_ID = 'a test id'
204+
205+
const {getByTestId} = render(
176206
<>
177-
<div id="switchLabel">{SWITCH_LABEL_TEXT}</div>
178-
<ToggleSwitch aria-labelledby="switchLabel" />
207+
<span id="label">label</span>
208+
<ToggleSwitch data-testid={TEST_ID} aria-labelledby="label" loadingLabelDelay={0} loading />
179209
</>,
180210
)
181211

182-
const toggleSwitch = getByLabelText(SWITCH_LABEL_TEXT)
183-
expect(toggleSwitch).toHaveAttribute('type', 'button')
212+
const toggleSwitch = getByTestId(TEST_ID)
213+
await waitFor(() => expect(toggleSwitch).toHaveTextContent('Loading'))
184214
})
185215
})

packages/react/src/ToggleSwitch/ToggleSwitch.tsx

Lines changed: 68 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,21 @@ import Box from '../Box'
66
import Spinner from '../Spinner'
77
import Text from '../Text'
88
import {get} from '../constants'
9-
import {useProvidedStateOrCreate} from '../hooks'
9+
import {useProvidedStateOrCreate, useId} from '../hooks'
1010
import type {BetterSystemStyleObject, SxProp} from '../sx'
1111
import sx from '../sx'
1212
import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles'
13+
import VisuallyHidden from '../_VisuallyHidden'
1314
import type {CellAlignment} from '../DataTable/column'
15+
import {AriaStatus} from '../live-region'
16+
import useSafeTimeout from '../hooks/useSafeTimeout'
1417

1518
const TRANSITION_DURATION = '80ms'
1619
const EASE_OUT_QUAD_CURVE = 'cubic-bezier(0.5, 1, 0.89, 1)'
1720

1821
export interface ToggleSwitchProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'>, SxProp {
22+
/** The id of the DOM node that labels the switch */
23+
['aria-labelledby']: string
1924
/** Uncontrolled - whether the switch is turned on */
2025
defaultChecked?: boolean
2126
/** Whether the switch is ready for user input */
@@ -34,6 +39,16 @@ export interface ToggleSwitchProps extends Omit<React.HTMLAttributes<HTMLDivElem
3439
* **This should only be changed when the switch's alignment needs to be adjusted.** For example: It needs to be left-aligned because the label appears above it and the caption appears below it.
3540
*/
3641
statusLabelPosition?: CellAlignment
42+
/**
43+
* If the switch is in the loading state, this value controls the amount of delay in milliseconds before
44+
* the `loadingLabel` is announced to screen readers.
45+
* @default 2000
46+
*/
47+
loadingLabelDelay?: number
48+
/** The text to describe what is loading. It should be descriptive and not verbose.
49+
* This is primarily used for AT (screen readers) to convey what is currently loading.
50+
*/
51+
loadingLabel?: string
3752
/** type of button to account for behavior when added to a form*/
3853
buttonType?: 'button' | 'submit' | 'reset'
3954
}
@@ -122,7 +137,7 @@ const SwitchButton = styled.button<SwitchButtonProps>`
122137
}
123138
}
124139
125-
&:hover:not(:disabled),
140+
&:hover:not(:disabled):not([aria-disabled='true']),
126141
&:focus:focus-visible {
127142
background-color: ${get('colors.switchTrack.hoverBg')};
128143
}
@@ -133,8 +148,12 @@ const SwitchButton = styled.button<SwitchButtonProps>`
133148
}
134149
135150
${props => {
136-
if (props.disabled) {
151+
if (props['aria-disabled']) {
137152
return css`
153+
@media (forced-colors: active) {
154+
border-color: GrayText;
155+
}
156+
138157
background-color: ${get('colors.switchTrack.disabledBg')};
139158
border-color: transparent;
140159
cursor: not-allowed;
@@ -147,7 +166,7 @@ const SwitchButton = styled.button<SwitchButtonProps>`
147166
background-color: ${get('colors.switchTrack.checked.bg')};
148167
border-color: var(--control-checked-borderColor-rest, transparent);
149168
150-
&:hover:not(:disabled),
169+
&:hover:not(:disabled):not([aria-disabled='true']),
151170
&:focus:focus-visible {
152171
background-color: ${get('colors.switchTrack.checked.hoverBg')};
153172
}
@@ -172,11 +191,12 @@ const SwitchButton = styled.button<SwitchButtonProps>`
172191
${sx}
173192
${sizeVariants}
174193
`
175-
const ToggleKnob = styled.div<{checked?: boolean; disabled?: boolean}>`
194+
const ToggleKnob = styled.div<{checked?: boolean; 'aria-disabled': React.AriaAttributes['aria-disabled']}>`
176195
background-color: ${get('colors.switchKnob.bg')};
177196
border-width: 1px;
178197
border-style: solid;
179-
border-color: ${props => (props.disabled ? get('colors.switchTrack.disabledBg') : get('colors.switchKnob.border'))};
198+
border-color: ${props =>
199+
props['aria-disabled'] ? get('colors.switchTrack.disabledBg') : get('colors.switchKnob.border')};
180200
border-radius: calc(${get('radii.2')} - 1px); /* -1px to account for 1px border around the control */
181201
width: 50%;
182202
position: absolute;
@@ -193,8 +213,12 @@ const ToggleKnob = styled.div<{checked?: boolean; disabled?: boolean}>`
193213
}
194214
195215
${props => {
196-
if (props.disabled) {
216+
if (props['aria-disabled']) {
197217
return css`
218+
@media (forced-colors: active) {
219+
color: GrayText;
220+
}
221+
198222
border-color: ${get('colors.switchTrack.disabledBg')};
199223
`
200224
}
@@ -226,27 +250,50 @@ const ToggleSwitch = React.forwardRef<HTMLButtonElement, React.PropsWithChildren
226250
buttonType = 'button',
227251
size = 'medium',
228252
statusLabelPosition = 'start',
253+
loadingLabelDelay = 2000,
254+
loadingLabel = 'Loading',
229255
sx: sxProp,
230256
...rest
231257
} = props
232258
const isControlled = typeof checked !== 'undefined'
233259
const [isOn, setIsOn] = useProvidedStateOrCreate<boolean>(checked, onChange, Boolean(defaultChecked))
234260
const acceptsInteraction = !disabled && !loading
261+
262+
const [isLoadingLabelVisible, setIsLoadingLabelVisible] = React.useState(false)
263+
const loadingLabelId = useId('loadingLabel')
264+
265+
const {safeSetTimeout} = useSafeTimeout()
266+
235267
const handleToggleClick: MouseEventHandler = useCallback(
236268
e => {
269+
if (disabled || loading) return
270+
237271
if (!isControlled) {
238272
setIsOn(!isOn)
239273
}
240274
onClick && onClick(e)
241275
},
242-
[onClick, isControlled, isOn, setIsOn],
276+
[disabled, isControlled, loading, onClick, setIsOn, isOn],
243277
)
244278

245279
useEffect(() => {
246-
if (onChange && isControlled) {
280+
if (onChange && isControlled && !disabled) {
247281
onChange(Boolean(checked))
248282
}
249-
}, [onChange, checked, isControlled, buttonType])
283+
}, [onChange, checked, isControlled, disabled])
284+
285+
useEffect(() => {
286+
if (!loading && isLoadingLabelVisible) {
287+
setIsLoadingLabelVisible(false)
288+
} else if (loading && !isLoadingLabelVisible) {
289+
safeSetTimeout(() => {
290+
setIsLoadingLabelVisible(true)
291+
}, loadingLabelDelay)
292+
}
293+
}, [loading, isLoadingLabelVisible, loadingLabelDelay, safeSetTimeout])
294+
295+
let switchButtonDescribedBy = loadingLabelId
296+
if (ariaDescribedby) switchButtonDescribedBy = `${switchButtonDescribedBy} ${ariaDescribedby}`
250297

251298
return (
252299
<Box
@@ -256,13 +303,19 @@ const ToggleSwitch = React.forwardRef<HTMLButtonElement, React.PropsWithChildren
256303
sx={sxProp}
257304
{...rest}
258305
>
259-
{loading ? <Spinner size="small" /> : null}
306+
<VisuallyHidden>
307+
<AriaStatus announceOnShow id={loadingLabelId}>
308+
{isLoadingLabelVisible && loadingLabel}
309+
</AriaStatus>
310+
</VisuallyHidden>
311+
312+
{loading ? <Spinner size="small" srText={null} /> : null}
260313
<Text
261314
color={acceptsInteraction ? 'fg.default' : 'fg.muted'}
262315
fontSize={size === 'small' ? 0 : 1}
263316
mx={2}
264317
aria-hidden="true"
265-
sx={{position: 'relative', cursor: 'pointer'}}
318+
sx={{position: 'relative', cursor: acceptsInteraction ? 'pointer' : 'not-allowed'}}
266319
onClick={handleToggleClick}
267320
>
268321
<Box textAlign="right" sx={isOn ? null : hiddenTextStyles}>
@@ -277,11 +330,11 @@ const ToggleSwitch = React.forwardRef<HTMLButtonElement, React.PropsWithChildren
277330
type={buttonType}
278331
onClick={handleToggleClick}
279332
aria-labelledby={ariaLabelledby}
280-
aria-describedby={ariaDescribedby}
333+
aria-describedby={isLoadingLabelVisible || ariaDescribedby ? switchButtonDescribedBy : undefined}
281334
aria-pressed={isOn}
282335
checked={isOn}
283336
size={size}
284-
disabled={!acceptsInteraction}
337+
aria-disabled={!acceptsInteraction}
285338
>
286339
<Box aria-hidden="true" display="flex" alignItems="center" width="100%" height="100%" overflow="hidden">
287340
<Box
@@ -313,7 +366,7 @@ const ToggleSwitch = React.forwardRef<HTMLButtonElement, React.PropsWithChildren
313366
<CircleIcon size={size} />
314367
</Box>
315368
</Box>
316-
<ToggleKnob aria-hidden="true" disabled={!acceptsInteraction} checked={isOn} />
369+
<ToggleKnob aria-hidden="true" aria-disabled={!acceptsInteraction} checked={isOn} />
317370
</SwitchButton>
318371
</Box>
319372
)

0 commit comments

Comments
 (0)