Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
0c22731
Address additional ToggleSwitch a11y feedback
camertron Nov 3, 2023
2ffa8cb
Add test ensuring clicking the status label cannot toggle a disabled …
camertron Nov 3, 2023
0d50aa4
Add changeset
camertron Nov 3, 2023
c157d7b
Update src/ToggleSwitch/ToggleSwitch.tsx
camertron Nov 8, 2023
af748d4
Update src/ToggleSwitch/ToggleSwitch.tsx
camertron Nov 8, 2023
9b72456
Move aria-describedby
camertron Nov 8, 2023
209c921
Update snapshot
camertron Nov 8, 2023
de0c7c5
Merge branch 'main' into toggle_switch_a11y_take4
camertron Nov 17, 2023
1d02071
Add loadingLabelDelay to prop docs; make a few changes suggested in r…
camertron Nov 30, 2023
51dea9e
Merge branch 'main' into toggle_switch_a11y_take4
camertron Nov 30, 2023
f6e6745
Merge branch 'toggle_switch_a11y_take4' of github.com:primer/react in…
camertron Nov 30, 2023
6cdc7b5
Merge branch 'main' into toggle_switch_a11y_take4
camertron Dec 13, 2023
1f423d6
Merge branch 'main' into toggle_switch_a11y_take4
TylerJDev Jun 21, 2024
00d5e43
Merge branch 'main' into toggle-switch-a11y-fixes
TylerJDev Jul 16, 2024
1839b94
Test/lint fixes
TylerJDev Jul 16, 2024
cf5d55d
Temp fix for figma file
TylerJDev Jul 16, 2024
a6b802b
Merge branch 'main' into toggle-switch-a11y-fixes
TylerJDev Jul 22, 2024
ea8f26b
Add new prop
TylerJDev Jul 22, 2024
94c7c3b
Adjust loading description
TylerJDev Jul 22, 2024
ce96e18
Update docs with new prop
TylerJDev Jul 22, 2024
e37e050
Update live region
TylerJDev Jul 22, 2024
709c490
Hide spinner loading text
TylerJDev Jul 23, 2024
25b440d
Make loading text conditional, add `childList` to mutation observer
TylerJDev Jul 30, 2024
9f661f2
Update packages/react/src/ToggleSwitch/ToggleSwitch.tsx
TylerJDev Sep 5, 2024
4343ba6
Update packages/react/src/ToggleSwitch/ToggleSwitch.tsx
TylerJDev Sep 5, 2024
df3833a
Add `useSafeTimeout`
TylerJDev Sep 9, 2024
deaea40
Utilize `useEffect`
TylerJDev Sep 10, 2024
6e9740b
Merge branch 'main' into toggle-switch-a11y-fixes
TylerJDev Sep 18, 2024
0d4a49f
Merge branch 'main' into toggle-switch-a11y-fixes
TylerJDev Oct 24, 2024
e4060ce
Merge branch 'main' into toggle-switch-a11y-fixes
TylerJDev Nov 15, 2024
be8dd43
Merge branch 'main' into toggle-switch-a11y-fixes
TylerJDev Jan 17, 2025
ef94612
Merge branch 'main' into toggle-switch-a11y-fixes
TylerJDev Mar 19, 2025
794c83e
Remove duplicate property
TylerJDev Mar 20, 2025
da98af9
Merge branch 'main' into toggle-switch-a11y-fixes
TylerJDev Apr 1, 2025
b269a81
Merge branch 'main' into toggle-switch-a11y-fixes
TylerJDev Jun 2, 2025
35c6e22
Fix merge
TylerJDev Jun 2, 2025
c1a1aff
Merge branch 'main' into toggle-switch-a11y-fixes
TylerJDev Aug 4, 2025
c0238ce
Remove duplicate test, adjust story
TylerJDev Aug 4, 2025
ab1c68a
Fix style
TylerJDev Aug 4, 2025
3d24041
Add back test
TylerJDev Aug 4, 2025
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/honest-gorillas-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

Address additional ToggleSwitch a11y feedback
12 changes: 12 additions & 0 deletions packages/react/src/ToggleSwitch/ToggleSwitch.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,18 @@
"defaultValue": "'start'",
"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."
},
{
"name": "loadingLabelDelay",
"type": "number",
"defaultValue": "2000",
"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."
},
{
"name": "loadingLabel",
"type": "string",
"defaultValue": "'Loading'",
"description": "The text that is announced to AT such as screen readers when the switch is in a loading state."
},
{
"name": "buttonType",
"type": "'button' | 'submit' | 'reset'",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react'
import React, {useState} from 'react'
import {useSafeTimeout} from '..'
import ToggleSwitch from './ToggleSwitch'
import {action} from 'storybook/actions'
import ToggleSwitchStoryWrapper from './ToggleSwitchStoryWrapper'
Expand Down Expand Up @@ -68,6 +69,65 @@ export const Loading = () => (
</ToggleSwitchStoryWrapper>
)

type LoadingWithDelayProps = {
loadingDelay: number
loadingLabelDelay: number
}

export const LoadingWithDelay = (args: LoadingWithDelayProps) => {
const {loadingDelay, loadingLabelDelay} = args

const [isLoading, setIsLoading] = useState(false)
const [timeoutId, setTimeoutId] = useState<number | null>(null)
const [toggleState, setToggleState] = useState(false)

const {safeSetTimeout, safeClearTimeout} = useSafeTimeout()

const handleToggleClick = () => {
setIsLoading(true)

if (timeoutId) {
safeClearTimeout(timeoutId)
setTimeoutId(null)
}

setTimeoutId(safeSetTimeout(() => setIsLoading(false), loadingDelay) as unknown as number)
}

return (
<ToggleSwitchStoryWrapper>
<span id="toggle" style={{fontWeight: 'bold', fontSize: 'var(--base-size-14)'}}>
Enable feature
</span>
<ToggleSwitch
loading={isLoading}
loadingLabel={`${toggleState ? 'Enabling' : 'Disabling'} feature`}
loadingLabelDelay={loadingLabelDelay}
aria-labelledby="toggle"
onClick={handleToggleClick}
onChange={(on: boolean) => setToggleState(on)}
/>
</ToggleSwitchStoryWrapper>
)
}

LoadingWithDelay.args = {
loadingDelay: 5000,
loadingLabelDelay: 2000,
}
LoadingWithDelay.argTypes = {
loadingDelay: {
control: {
type: 'number',
},
},
loadingLabelDelay: {
control: {
type: 'number',
},
},
}

export const LabelEnd = () => (
<ToggleSwitchStoryWrapper>
<span id="toggle" className={styles.ToggleLabel}>
Expand Down
8 changes: 7 additions & 1 deletion packages/react/src/ToggleSwitch/ToggleSwitch.figma.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ figma.connect(
}),
},
example: ({size, checked, labelposition, loading}) => (
<ToggleSwitch size={size} checked={checked} statusLabelPosition={labelposition} loading={loading} />
<ToggleSwitch
aria-labelledby=""
size={size}
checked={checked}
statusLabelPosition={labelposition}
loading={loading}
/>
),
},
)
46 changes: 38 additions & 8 deletions packages/react/src/ToggleSwitch/ToggleSwitch.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {describe, expect, it, vi} from 'vitest'
import React from 'react'
import {render} from '@testing-library/react'
import {render, waitFor} from '@testing-library/react'
import ToggleSwitch from './'
import userEvent from '@testing-library/user-event'

Expand Down Expand Up @@ -46,7 +46,7 @@ describe('ToggleSwitch', () => {
expect(toggleSwitch).toHaveAttribute('aria-pressed', 'false')
})

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

it('ensures the status label cannot toggle a disabled switch', async () => {
const user = userEvent.setup()
const {getByLabelText, getByText} = render(
<>
<div id="switchLabel">{SWITCH_LABEL_TEXT}</div>
<ToggleSwitch aria-labelledby="switchLabel" disabled />
</>,
)
const toggleSwitch = getByLabelText(SWITCH_LABEL_TEXT)
const toggleSwitchStatusLabel = getByText('Off')

expect(toggleSwitch).toHaveAttribute('aria-pressed', 'false')
await user.click(toggleSwitchStatusLabel)
expect(toggleSwitch).toHaveAttribute('aria-pressed', 'false')
})

it('switches from off to on with a controlled prop', async () => {
const user = userEvent.setup()
const ControlledSwitchComponent = () => {
Expand Down Expand Up @@ -157,6 +173,18 @@ describe('ToggleSwitch', () => {
expect(toggleSwitch).toBeInTheDocument()
})

it('renders a switch that has button type button', () => {
const {getByLabelText} = render(
<>
<div id="switchLabel">{SWITCH_LABEL_TEXT}</div>
<ToggleSwitch aria-labelledby="switchLabel" />
</>,
)

const toggleSwitch = getByLabelText(SWITCH_LABEL_TEXT)
expect(toggleSwitch).toHaveAttribute('type', 'button')
})

it('supports a `ref` on the inner <button> element', () => {
const ref = vi.fn()

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

it('renders a switch that has button type button', () => {
const {getByLabelText} = render(
it('displays a loading label', async () => {
const TEST_ID = 'a test id'

const {getByTestId} = render(
<>
<div id="switchLabel">{SWITCH_LABEL_TEXT}</div>
<ToggleSwitch aria-labelledby="switchLabel" />
<span id="label">label</span>
<ToggleSwitch data-testid={TEST_ID} aria-labelledby="label" loadingLabelDelay={0} loading />
</>,
)

const toggleSwitch = getByLabelText(SWITCH_LABEL_TEXT)
expect(toggleSwitch).toHaveAttribute('type', 'button')
const toggleSwitch = getByTestId(TEST_ID)
await waitFor(() => expect(toggleSwitch).toHaveTextContent('Loading'))
})
})
83 changes: 68 additions & 15 deletions packages/react/src/ToggleSwitch/ToggleSwitch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,21 @@ import Box from '../Box'
import Spinner from '../Spinner'
import Text from '../Text'
import {get} from '../constants'
import {useProvidedStateOrCreate} from '../hooks'
import {useProvidedStateOrCreate, useId} from '../hooks'
import type {BetterSystemStyleObject, SxProp} from '../sx'
import sx from '../sx'
import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles'
import VisuallyHidden from '../_VisuallyHidden'
import type {CellAlignment} from '../DataTable/column'
import {AriaStatus} from '../live-region'
import useSafeTimeout from '../hooks/useSafeTimeout'

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

export interface ToggleSwitchProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'>, SxProp {
/** The id of the DOM node that labels the switch */
['aria-labelledby']: string
/** Uncontrolled - whether the switch is turned on */
defaultChecked?: boolean
/** Whether the switch is ready for user input */
Expand All @@ -34,6 +39,16 @@ export interface ToggleSwitchProps extends Omit<React.HTMLAttributes<HTMLDivElem
* **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.
*/
statusLabelPosition?: CellAlignment
/**
* If the switch is in the loading state, this value controls the amount of delay in milliseconds before
* the `loadingLabel` is announced to screen readers.
* @default 2000
*/
loadingLabelDelay?: number
/** The text to describe what is loading. It should be descriptive and not verbose.
* This is primarily used for AT (screen readers) to convey what is currently loading.
*/
loadingLabel?: string
/** type of button to account for behavior when added to a form*/
buttonType?: 'button' | 'submit' | 'reset'
}
Expand Down Expand Up @@ -122,7 +137,7 @@ const SwitchButton = styled.button<SwitchButtonProps>`
}
}

&:hover:not(:disabled),
&:hover:not(:disabled):not([aria-disabled='true']),
&:focus:focus-visible {
background-color: ${get('colors.switchTrack.hoverBg')};
}
Expand All @@ -133,8 +148,12 @@ const SwitchButton = styled.button<SwitchButtonProps>`
}

${props => {
if (props.disabled) {
if (props['aria-disabled']) {
return css`
@media (forced-colors: active) {
border-color: GrayText;
}

background-color: ${get('colors.switchTrack.disabledBg')};
border-color: transparent;
cursor: not-allowed;
Expand All @@ -147,7 +166,7 @@ const SwitchButton = styled.button<SwitchButtonProps>`
background-color: ${get('colors.switchTrack.checked.bg')};
border-color: var(--control-checked-borderColor-rest, transparent);

&:hover:not(:disabled),
&:hover:not(:disabled):not([aria-disabled='true']),
&:focus:focus-visible {
background-color: ${get('colors.switchTrack.checked.hoverBg')};
}
Expand All @@ -172,11 +191,12 @@ const SwitchButton = styled.button<SwitchButtonProps>`
${sx}
${sizeVariants}
`
const ToggleKnob = styled.div<{checked?: boolean; disabled?: boolean}>`
const ToggleKnob = styled.div<{checked?: boolean; 'aria-disabled': React.AriaAttributes['aria-disabled']}>`
background-color: ${get('colors.switchKnob.bg')};
border-width: 1px;
border-style: solid;
border-color: ${props => (props.disabled ? get('colors.switchTrack.disabledBg') : get('colors.switchKnob.border'))};
border-color: ${props =>
props['aria-disabled'] ? get('colors.switchTrack.disabledBg') : get('colors.switchKnob.border')};
border-radius: calc(${get('radii.2')} - 1px); /* -1px to account for 1px border around the control */
width: 50%;
position: absolute;
Expand All @@ -193,8 +213,12 @@ const ToggleKnob = styled.div<{checked?: boolean; disabled?: boolean}>`
}

${props => {
if (props.disabled) {
if (props['aria-disabled']) {
return css`
@media (forced-colors: active) {
color: GrayText;
}

border-color: ${get('colors.switchTrack.disabledBg')};
`
}
Expand Down Expand Up @@ -226,27 +250,50 @@ const ToggleSwitch = React.forwardRef<HTMLButtonElement, React.PropsWithChildren
buttonType = 'button',
size = 'medium',
statusLabelPosition = 'start',
loadingLabelDelay = 2000,
loadingLabel = 'Loading',
sx: sxProp,
...rest
} = props
const isControlled = typeof checked !== 'undefined'
const [isOn, setIsOn] = useProvidedStateOrCreate<boolean>(checked, onChange, Boolean(defaultChecked))
const acceptsInteraction = !disabled && !loading

const [isLoadingLabelVisible, setIsLoadingLabelVisible] = React.useState(false)
const loadingLabelId = useId('loadingLabel')

const {safeSetTimeout} = useSafeTimeout()

const handleToggleClick: MouseEventHandler = useCallback(
e => {
if (disabled || loading) return

if (!isControlled) {
setIsOn(!isOn)
}
onClick && onClick(e)
},
[onClick, isControlled, isOn, setIsOn],
[disabled, isControlled, loading, onClick, setIsOn, isOn],
)

useEffect(() => {
if (onChange && isControlled) {
if (onChange && isControlled && !disabled) {
onChange(Boolean(checked))
}
}, [onChange, checked, isControlled, buttonType])
}, [onChange, checked, isControlled, disabled])

useEffect(() => {
Copy link

Copilot AI Aug 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This effect will trigger the onChange callback even when the component is just mounting or when disabled changes, not just when the user actually toggles the switch. Consider moving this logic to the handleToggleClick function or adding additional conditions to prevent unintended calls.

Note: See the diff below for a potential fix:

@@ -270,19 +270,16 @@
 
         if (!isControlled) {
           setIsOn(!isOn)
+          onChange && onChange(!isOn)
+        } else {
+          onChange && onChange(!isOn)
         }
         onClick && onClick(e)
       },
-      [disabled, isControlled, loading, onClick, setIsOn, isOn],
+      [disabled, isControlled, loading, onClick, setIsOn, isOn, onChange],
     )
 
     useEffect(() => {
-      if (onChange && isControlled && !disabled) {
-        onChange(Boolean(checked))
-      }
-    }, [onChange, checked, isControlled, disabled])
-
-    useEffect(() => {
       if (!loading && isLoadingLabelVisible) {
         setIsLoadingLabelVisible(false)
       } else if (loading && !isLoadingLabelVisible) {

Copilot uses AI. Check for mistakes.
if (!loading && isLoadingLabelVisible) {
setIsLoadingLabelVisible(false)
} else if (loading && !isLoadingLabelVisible) {
safeSetTimeout(() => {
setIsLoadingLabelVisible(true)
}, loadingLabelDelay)
}
}, [loading, isLoadingLabelVisible, loadingLabelDelay, safeSetTimeout])

let switchButtonDescribedBy = loadingLabelId
if (ariaDescribedby) switchButtonDescribedBy = `${switchButtonDescribedBy} ${ariaDescribedby}`

return (
<Box
Expand All @@ -256,13 +303,19 @@ const ToggleSwitch = React.forwardRef<HTMLButtonElement, React.PropsWithChildren
sx={sxProp}
{...rest}
>
{loading ? <Spinner size="small" /> : null}
<VisuallyHidden>
<AriaStatus announceOnShow id={loadingLabelId}>
{isLoadingLabelVisible && loadingLabel}
</AriaStatus>
</VisuallyHidden>

{loading ? <Spinner size="small" srText={null} /> : null}
<Text
color={acceptsInteraction ? 'fg.default' : 'fg.muted'}
fontSize={size === 'small' ? 0 : 1}
mx={2}
aria-hidden="true"
sx={{position: 'relative', cursor: 'pointer'}}
sx={{position: 'relative', cursor: acceptsInteraction ? 'pointer' : 'not-allowed'}}
onClick={handleToggleClick}
>
<Box textAlign="right" sx={isOn ? null : hiddenTextStyles}>
Expand All @@ -277,11 +330,11 @@ const ToggleSwitch = React.forwardRef<HTMLButtonElement, React.PropsWithChildren
type={buttonType}
onClick={handleToggleClick}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
aria-describedby={isLoadingLabelVisible || ariaDescribedby ? switchButtonDescribedBy : undefined}
aria-pressed={isOn}
checked={isOn}
size={size}
disabled={!acceptsInteraction}
aria-disabled={!acceptsInteraction}
>
<Box aria-hidden="true" display="flex" alignItems="center" width="100%" height="100%" overflow="hidden">
<Box
Expand Down Expand Up @@ -313,7 +366,7 @@ const ToggleSwitch = React.forwardRef<HTMLButtonElement, React.PropsWithChildren
<CircleIcon size={size} />
</Box>
</Box>
<ToggleKnob aria-hidden="true" disabled={!acceptsInteraction} checked={isOn} />
<ToggleKnob aria-hidden="true" aria-disabled={!acceptsInteraction} checked={isOn} />
</SwitchButton>
</Box>
)
Expand Down
Loading
Loading