diff --git a/.changepacks/changepack_log_toggle_switch.json b/.changepacks/changepack_log_toggle_switch.json new file mode 100644 index 000000000..744f10d0f --- /dev/null +++ b/.changepacks/changepack_log_toggle_switch.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/components/package.json": "Patch" + }, + "note": "Toggle is an accessible switch: role=switch with aria-checked, focusable with a visible focus ring, toggled by Space and Enter, labelled through aria-label or aria-labelledby, and submitted under the name it is given; when disabled it leaves the tab order, ignores keys and its hidden input is disabled", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/packages/components/src/components/Toggle/__tests__/__snapshots__/index.browser.test.tsx.snap b/packages/components/src/components/Toggle/__tests__/__snapshots__/index.browser.test.tsx.snap index 655fd871d..5fac1731c 100644 --- a/packages/components/src/components/Toggle/__tests__/__snapshots__/index.browser.test.tsx.snap +++ b/packages/components/src/components/Toggle/__tests__/__snapshots__/index.browser.test.tsx.snap @@ -2,7 +2,7 @@ exports[`Toggle should Toggle snapshot 1`] = ` "
-
+
@@ -12,17 +12,17 @@ exports[`Toggle should Toggle snapshot 1`] = ` exports[`Toggle should Toggle snapshot 2`] = ` "
-
+
- +
" `; exports[`Toggle should Toggle snapshot 3`] = ` "
-
+
@@ -32,7 +32,7 @@ exports[`Toggle should Toggle snapshot 3`] = ` exports[`Toggle should Toggle snapshot 4`] = ` "
-
+
@@ -42,7 +42,7 @@ exports[`Toggle should Toggle snapshot 4`] = ` exports[`Toggle should Toggle snapshot 5`] = ` "
-
+
@@ -52,17 +52,17 @@ exports[`Toggle should Toggle snapshot 5`] = ` exports[`Toggle should Toggle snapshot 6`] = ` "
-
+
- +
" `; exports[`Toggle should Toggle snapshot 7`] = ` "
-
+
@@ -72,7 +72,7 @@ exports[`Toggle should Toggle snapshot 7`] = ` exports[`Toggle should Toggle snapshot 8`] = ` "
-
+
@@ -82,7 +82,7 @@ exports[`Toggle should Toggle snapshot 8`] = ` exports[`Toggle should Toggle snapshot 9`] = ` "
-
+
@@ -92,7 +92,7 @@ exports[`Toggle should Toggle snapshot 9`] = ` exports[`Toggle should Toggle snapshot 10`] = ` "
-
+
@@ -102,7 +102,7 @@ exports[`Toggle should Toggle snapshot 10`] = ` exports[`Toggle should Toggle snapshot 11`] = ` "
-
+
@@ -112,7 +112,7 @@ exports[`Toggle should Toggle snapshot 11`] = ` exports[`Toggle should Toggle snapshot 12`] = ` "
-
+
diff --git a/packages/components/src/components/Toggle/__tests__/index.browser.test.tsx b/packages/components/src/components/Toggle/__tests__/index.browser.test.tsx index c0f58e34c..e872cea3e 100644 --- a/packages/components/src/components/Toggle/__tests__/index.browser.test.tsx +++ b/packages/components/src/components/Toggle/__tests__/index.browser.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, mock } from 'bun:test' -import { act, render, userEvent } from 'bun-test-env-dom' +import { act, fireEvent, render, userEvent } from 'bun-test-env-dom' import { Toggle } from '../index' @@ -91,4 +91,36 @@ describe('Toggle', () => { })) expect(input).toHaveAttribute('value', 'true') }) + + it('is a switch toggled by Space and Enter', () => { + const onChange = mock() + const { getByRole, container } = render( + , + ) + const toggle = getByRole('switch') + expect(toggle).toHaveAttribute('aria-checked', 'false') + expect(toggle).toHaveAttribute('aria-label', 'Dark mode') + expect(toggle).toHaveAttribute('tabindex', '0') + fireEvent.keyDown(toggle, { key: ' ' }) + expect(onChange).toHaveBeenLastCalledWith(true) + expect(toggle).toHaveAttribute('aria-checked', 'true') + fireEvent.keyDown(toggle, { key: 'Enter' }) + expect(onChange).toHaveBeenLastCalledWith(false) + fireEvent.keyDown(toggle, { key: 'a' }) + expect(onChange).toHaveBeenCalledTimes(2) + expect(container.querySelector('input')).toHaveAttribute('name', 'dark') + }) + + it('ignores keys and leaves the tab order when disabled', () => { + const onChange = mock() + const { getByRole, container } = render( + , + ) + const toggle = getByRole('switch') + expect(toggle).toHaveAttribute('tabindex', '-1') + expect(toggle).toHaveAttribute('aria-labelledby', 'l') + fireEvent.keyDown(toggle, { key: ' ' }) + expect(onChange).not.toHaveBeenCalled() + expect(container.querySelector('input')).toBeDisabled() + }) }) diff --git a/packages/components/src/components/Toggle/index.tsx b/packages/components/src/components/Toggle/index.tsx index 67adeb8ee..c766cf266 100644 --- a/packages/components/src/components/Toggle/index.tsx +++ b/packages/components/src/components/Toggle/index.tsx @@ -8,6 +8,10 @@ interface ToggleProps { onChange?: (value: boolean) => void disabled?: boolean variant?: 'default' | 'switch' + /** The name the value is submitted under in a form */ + name?: string + 'aria-label'?: string + 'aria-labelledby'?: string className?: string style?: React.CSSProperties classNames?: { @@ -38,6 +42,9 @@ export function Toggle({ colors, classNames, styles, + name, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, }: ToggleProps) { const [innerValue, setInnerValue] = useState( value ?? defaultValue ?? false, @@ -55,7 +62,14 @@ export function Toggle({ return ( <> !disabled && handleToggle(resultValue)} + onKeyDown={(event) => { + if (disabled || (event.key !== ' ' && event.key !== 'Enter')) return + event.preventDefault() + handleToggle(resultValue) + }} p={isDefault && 1} position="relative" + role="switch" selectors={{ '&[aria-disabled=true]': { cursor: 'not-allowed', @@ -90,6 +110,7 @@ export function Toggle({ hoverBg: colors?.hoverBg, disabledBg: colors?.disabledBg, }} + tabIndex={disabled ? -1 : 0} test-id="toggle-wrapper" transition=".25s" w={isDefault ? '50px' : '40px'} @@ -123,7 +144,12 @@ export function Toggle({ transition=".25s" /> - + ) }