From c274eab6a6e832c1df2ccf9d93274e8b1996acc4 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 2 Oct 2026 17:13:54 +0900 Subject: [PATCH] fix(components): make Toggle an accessible switch Refs #692 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_toggle_switch.json | 7 ++++ bindings/devup-ui-wasm/src/lib.rs | 8 ++--- libs/css/src/theme_tokens.rs | 2 +- libs/extractor/src/lib.rs | 8 ++--- libs/extractor/src/tailwind.rs | 4 +-- .../__snapshots__/index.browser.test.tsx.snap | 28 +++++++-------- .../Toggle/__tests__/index.browser.test.tsx | 34 ++++++++++++++++++- .../src/components/Toggle/index.tsx | 28 ++++++++++++++- 8 files changed, 92 insertions(+), 27 deletions(-) create mode 100644 .changepacks/changepack_log_toggle_switch.json 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/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 4560410f3..0a2347ac2 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -1551,7 +1551,7 @@ mod tests { ); // Test getters - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert_eq!(output.css_file(), Some("devup-ui-0.css".to_string())); assert_eq!(output.map(), Some("//# sourceMappingURL=test".to_string())); assert!(output.css().is_some()); @@ -1951,7 +1951,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_some()); } @@ -1975,7 +1975,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_none()); } @@ -2000,7 +2000,7 @@ mod tests { assert!(result.is_err()); if let Err(error) = result { - assert!(!error.is_empty()); + assert_ne!(error, ""); } } diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index d8d8fb021..3b2346aed 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -119,7 +119,7 @@ mod tests { set_typography_keys(vec!["body".to_string(), "title".to_string()]); assert_eq!(get_typography_keys(), vec!["body", "title"]); set_typography_keys(vec![]); - assert!(get_typography_keys().is_empty()); + assert_eq!(get_typography_keys(), Vec::::new()); } #[test] diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 68981f36c..96c6ca430 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -836,8 +836,8 @@ mod tests { alternate: None, }; - assert!(empty.extract().is_empty()); - assert!(empty.into_extract().is_empty()); + assert_eq!(empty.extract(), vec![]); + assert_eq!(empty.into_extract(), vec![]); } #[test] @@ -13598,7 +13598,7 @@ globalCss({ ); assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code.is_empty()); + assert_ne!(output.code, ""); } #[test] @@ -18778,7 +18778,7 @@ export const k = styled('div')({ color: SIZE });", &memory_resolver(CONSTANT_MODULES), ) .unwrap(); - assert!(without_imports.dependencies.is_empty()); + assert_eq!(without_imports.dependencies.len(), 0); let without_constants = extract_with_modules( "/src/Handler.tsx", "import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = ;", diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index 87e9f5178..70643aa7c 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -284,7 +284,7 @@ pub struct TailwindClass { /// non-overlapping) but mutates the existing buffer instead of allocating a new /// `String`. `needle` must be non-empty. fn remove_all_substr(haystack: &mut String, needle: &str) { - debug_assert!(!needle.is_empty()); + debug_assert_ne!(needle, ""); let mut search_from = 0; while let Some(rel) = haystack[search_from..].find(needle) { let at = search_from + rel; @@ -3934,7 +3934,7 @@ mod tests { #[test] fn test_empty_string() { let styles = parse_tailwind_to_styles(""); - assert!(styles.is_empty()); + assert_eq!(styles, vec![]); } #[test] 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" /> - + ) }