Skip to content

feat(toggle): improve accessibility #IX-3949#2405

Open
alexkaduk wants to merge 10 commits intomainfrom
feature/IX-3949
Open

feat(toggle): improve accessibility #IX-3949#2405
alexkaduk wants to merge 10 commits intomainfrom
feature/IX-3949

Conversation

@alexkaduk
Copy link
Collaborator

@alexkaduk alexkaduk commented Feb 17, 2026

Toggle Component Accessibility & Keyboard Navigation Fix

💡 What is the current behavior?

  • Keyboard focus issue: The native checkbox input used display: none, removing it from the tab order. Tab and Space keys had no effect on the toggle.
  • Focus outline issue: The focus ring was not visible (wrong selector) or appeared as a rectangle around the whole component; toggling via keyboard did work, but the visible focus indicator was missing or incorrect.
  • Screen reader issue: VoiceOver announced the inner checkbox ("unchecked, checkbox") instead of the switch role and state ("Off, switch").
  • Accessibility issue: VoiceOver announced the state text twice (e.g., "on, on, switch") due to both aria-label and visible text being read.
  • Axe tests: Toggle axe tests failed with aria-allowed-role violation when using role="presentation" on <label> element, and didn't account for the intentional nested-interactive pattern.

GitHub Issue Number: #3949

🆕 What is the new behavior?

Keyboard Focus & Navigation

  • Input is visually hidden: Uses position: absolute + clip (not display: none) so assistive technologies can still access it.
  • Host is the focusable element: Host has tabindex={0} when enabled, tabindex={-1} when disabled. The input has tabindex={-1} and forwards focus to the host via onFocus={() => this.hostElement.focus()}.
  • Keyboard activation: Host onKeyDown handles both Space and Enter keys with preventDefault() to toggle state without scrolling the page.

Focus Ring

  • Custom focus indicator: Host default outline is removed (:host(:focus), :host(:focus-visible) { outline: none }).
  • Focused state ring: A custom focus ring is drawn on the switch track (.switch > .slider) only when the host has :focus-visible (:host(:not(.disabled):focus-visible) .switch > .slider), matching Figma design specs.

Screen Reader Accessibility

  • Host exposes switch semantics: role="switch", aria-checked, and aria-disabled are set on the Host element.
  • Accessible naming:
    • When text is visible (hideText={false}): No aria-label is set (value is undefined). The visible text (ix-typography) provides the accessible name naturally.
    • When text is hidden (hideText={true}): aria-label uses the current state text (textOn/textOff/textIndeterminate).
    • When explicitly set: Custom aria-label attribute takes precedence.
  • ARIA hidden elements: The switch visual div and input have aria-hidden="true" so screen readers only announce from the Host.
  • Result: VoiceOver now correctly announces "On, switch" instead of "on, on, switch" or "unchecked, checkbox".

Click Handling

  • Wrapper div onClick: The wrapper <div class="wrapper"> has an onClick handler that calls onCheckedChange() to expand the clickable area for mouse users.
  • Input preventDefault: The input has onClick with e.preventDefault() and e.stopPropagation() to:
    • Prevent the browser's default checkbox toggle behavior (which would conflict with controlled state)
    • Stop event bubbling to prevent double-toggling
  • Linter suppression: ESLint warning (jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions) is suppressed with a comment explaining that keyboard interaction is properly handled by the Host element.

Testing

  • Axe tests fixed: Changed from <label role="presentation"> (axe violation) to <div class="wrapper"> (no semantic role needed).
  • Nested-interactive rule: Toggle axe specs disable nested-interactive for toggle preview test IDs via TOGGLE_AXE_TEST_IDS (now a Set for O(1) lookup) in testing/framework-tests/src/main.ts:
    • toggle
    • toggle-checked
    • toggle-custom-label
    • toggle-disabled
    • toggle-indeterminate
  • Why nested-interactive is disabled: The toggle intentionally has a focusable host (role="switch") with an internal checkbox (aria-hidden and tabindex={-1}). This is the correct accessible pattern for a custom switch component.

📋 Technical Details & Reasoning

Why Host is Focusable (Not the Input)

Screen readers need to announce the switch role and state (e.g., VoiceOver: "Off, switch") instead of the inner checkbox ("unchecked, checkbox"). The input is visually hidden (position: absolute + clip in SCSS) and has tabindex={-1}; its onFocus forwards to this.hostElement.focus() so only the Host receives focus.

Why onKeyDown is on the Host

The Host is the focusable element, so Space and Enter must be handled there. The handler:

onKeyDown={(e: KeyboardEvent) => {
  if (this.disabled) return;
  if (e.key === ' ' || e.key === 'Enter') {
    e.preventDefault();
    this.onCheckedChange(!this.checked);
  }
}}
  • Calls this.onCheckedChange(!this.checked) to toggle
  • Calls e.preventDefault() so the browser doesn't scroll on Space

Why Wrapper Div Has onClick

To expand the clickable area for mouse users. The wrapper div is not keyboard-focusable (no tabindex). All keyboard interaction goes through the Host element. This avoids:

  • Duplicate keyboard handlers
  • Nested interactive elements for keyboard users
  • Focus management conflicts

Why Input Has preventDefault + stopPropagation

onClick={(e) => {
  e.preventDefault();
  e.stopPropagation();
}}
  • preventDefault(): Prevents the browser from auto-toggling the checkbox (we control the state)
  • stopPropagation(): Stops the click from bubbling up to prevent double-toggling

Architecture

Host (role="switch", tabindex={0}, onKeyDown)
  └─ div.wrapper (onClick for mouse)
       ├─ div.switch (aria-hidden="true")
       │    └─ div.slider (receives focus ring)
       ├─ input (aria-hidden="true", tabindex={-1}, onFocus forwards to Host)
       └─ ix-typography (visible text, provides accessible name)

🏁 Checklist

  • 🦮 Accessibility (a11y) features were implemented
    • Keyboard navigation (Space, Enter, Tab)
    • Focus indicator visible
    • Screen reader support (role="switch", proper announcements)
    • VoiceOver tested: no duplicate announcements
  • 🗺️ Internationalization (i18n) - default text strings (textOn, textOff, textIndeterminate) are props that can be customized
  • 📲 Responsiveness - components handle viewport changes and content overflow gracefully
  • 📕 Add or update a Storybook story
  • 📄 Documentation was reviewed/updated siemens/ix-docs
  • 🧪 Unit tests were added/updated and pass (pnpm test)
    • Component tests pass (pnpm test.ct)
    • Accessibility tests pass (axe)
    • preventDefault test passes
  • 📸 Visual regression tests were added/updated and pass
  • 🧐 Static code analysis passes (pnpm lint)
    • ⚠️ Linter warnings for onClick on wrapper div are intentionally suppressed with explanatory comment
  • 🏗️ Successful compilation (pnpm build)

👨‍💻 Help & Support

N/A - Ready for review

@changeset-bot
Copy link

changeset-bot bot commented Feb 17, 2026

⚠️ No Changeset found

Latest commit: 35ec97a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @alexkaduk, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the accessibility of the ix-toggle component, addressing several issues related to keyboard navigation, screen reader announcements, and visual focus indicators. The changes ensure that the toggle is fully usable and understandable for users relying on assistive technologies, aligning with modern web accessibility standards and design guidelines.

Highlights

  • Improved Keyboard Accessibility: The ix-toggle component now correctly handles keyboard focus and activation. The native checkbox input is visually hidden but remains in the tab order, and the host element is the primary focusable element. Keyboard users can now toggle the switch using the Space key.
  • Enhanced Screen Reader Support: Screen readers now announce the ix-toggle as a 'switch' with its correct state (on/off) rather than an inner checkbox. This is achieved by setting role="switch" on the host and managing aria-checked, aria-disabled, and aria-label attributes, while hiding internal elements from assistive technology.
  • Refined Focus Ring Visuals: The focus ring has been updated to match design specifications, appearing only around the switch track when the host element has :focus-visible, and the default host outline is removed.
  • Updated Accessibility Testing: Axe accessibility tests have been updated to account for the intentional 'nested-interactive' pattern used in the toggle component, disabling the corresponding rule for toggle-related test IDs. ARIA snapshots were also updated to reflect the new accessibility tree structure.
Changelog
  • packages/angular-standalone-test-app/smoke-tests/preview-examples.spec.ts
    • Added import for working-with-axe.spec.ts to include axe accessibility tests.
  • packages/angular-test-app/smoke-tests/preview-examples.spec.ts
    • Added import for working-with-axe.spec.ts to include axe accessibility tests.
  • packages/core/src/components/toggle/toggle.scss
    • Changed display: none to a visually hidden technique (position: absolute, clip, etc.) for the native checkbox input to maintain its presence in the accessibility tree.
    • Removed the default outline for the host element when focused and introduced a custom focus ring style that applies only to the switch track on :focus-visible.
  • packages/core/src/components/toggle/toggle.tsx
    • Configured the host element with role="switch", dynamic tabindex, aria-label, aria-checked, and aria-disabled attributes.
    • Implemented an onKeyDown handler on the host to allow toggling the state with the Space key and prevent default browser scrolling.
    • Replaced the <button> element for the switch visual with a <div> and added aria-hidden="true" to it and the label.
    • Modified the native <input type="checkbox"> to have tabindex="-1" and aria-hidden="true", and added an onFocus handler to forward focus to the host element.
  • packages/react-test-app/src/preview-examples/toggle.tsx
    • Added an aria-label attribute to the IxToggle component in a preview example for better accessibility.
  • packages/storybook-docs/src/stories/toggle/accessibility/a11y-toggle.stories.ts
    • Added a new Storybook file dedicated to ix-toggle accessibility examples, including basic usage, aria-label, aria-describedby, disabled state, checked state, indeterminate state, custom labels, and scenarios without visible labels.
    • Configured axe accessibility testing parameters within Storybook to disable the nested-interactive rule for toggle stories.
  • testing/framework-tests/snapshots/toggle-aria-snapshot.yaml
    • Updated the ARIA snapshot to reflect the new accessibility tree structure, showing 'main' as the parent of 'switch'.
  • testing/framework-tests/snapshots/toggle-checked-aria-snapshot.yaml
    • Updated the ARIA snapshot to reflect the new accessibility tree structure, showing 'main' as the parent of 'switch [checked]'.
  • testing/framework-tests/snapshots/toggle-custom-label-aria-snapshot.yaml
    • Updated the ARIA snapshot to reflect the new accessibility tree structure, showing 'main' as the parent of 'switch'.
  • testing/framework-tests/snapshots/toggle-disabled-aria-snapshot.yaml
    • Updated the ARIA snapshot to reflect the new accessibility tree structure, showing 'main' as the parent of 'switch [disabled]'.
  • testing/framework-tests/snapshots/toggle-indeterminate-aria-snapshot.yaml
    • Updated the ARIA snapshot to reflect the new accessibility tree structure, showing 'main' as the parent of 'switch'.
  • testing/framework-tests/src/main.ts
    • Defined TOGGLE_AXE_TEST_IDS to identify specific toggle test IDs.
    • Modified the generateTestForTestId function to conditionally disable the nested-interactive axe rule for toggle-related tests.
  • testing/framework-tests/tests/generated/toggle-axe.spec.ts
    • Updated axe accessibility test to disable the nested-interactive rule for the 'toggle' component.
  • testing/framework-tests/tests/generated/toggle-checked-axe.spec.ts
    • Updated axe accessibility test to disable the nested-interactive rule for the 'toggle-checked' component.
  • testing/framework-tests/tests/generated/toggle-custom-label-axe.spec.ts
    • Updated axe accessibility test to disable the nested-interactive rule for the 'toggle-custom-label' component.
  • testing/framework-tests/tests/generated/toggle-disabled-axe.spec.ts
    • Updated axe accessibility test to disable the nested-interactive rule for the 'toggle-disabled' component.
  • testing/framework-tests/tests/generated/toggle-indeterminate-axe.spec.ts
    • Updated axe accessibility test to disable the nested-interactive rule for the 'toggle-indeterminate' component.
  • testing/framework-tests/tests/working-with-axe.spec.ts
    • Added imports for all generated toggle axe tests.
  • testing/framework-tests/tests/working.spec.ts
    • Added imports for all generated toggle tests.
Activity
  • Accessibility (a11y) features were implemented.
  • Internationalization (i18n) - no hard coded strings was confirmed.
  • Visual regression tests were added/updated and pass.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request significantly improves the accessibility of the ix-toggle component by making it keyboard-focusable, adding keyboard activation, improving screen reader announcements, and fixing the focus ring. The changes follow modern accessibility best practices, such as using a visually-hidden input and making the host element the focusable switch. The addition of Storybook stories for accessibility is also a great enhancement. I've added a couple of suggestions to further improve keyboard interaction and internationalization. Overall, this is a high-quality contribution.

}}
/>
{!this.hideText && (
<ix-typography class="label">{toggleText}</ix-typography>
Copy link
Collaborator

@lzeiml lzeiml Mar 3, 2026

Choose a reason for hiding this comment

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

This introduces redundant text to the a11y tree, making screenreader read "On switch on". We should hide it using aria-hidden.
see: https://www.w3.org/WAI/ARIA/apg/patterns/switch/examples/switch/

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

at the same time it is required to have aria-label. so did it like this aria-hidden={ariaLabelAttr ? 'true' : undefined}

Copy link
Collaborator

Choose a reason for hiding this comment

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

@alexkaduk Will discuss this with a11y team and then we can make a decision

role="switch"
tabindex={this.disabled ? -1 : 0}
aria-label={ariaLabel}
aria-checked={a11yBoolean(this.checked)}
Copy link
Collaborator

Choose a reason for hiding this comment

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

For indeterminate state the screenreader currently reads "off". aria-checked also supports the value "mixed" for this case.

Copy link
Collaborator Author

@alexkaduk alexkaduk Mar 3, 2026

Choose a reason for hiding this comment

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

@lzeiml based on this https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/switch_role#description
the switch role does NOT support "mixed" value

it can be done like
`const ariaChecked = this.indeterminate
? 'mixed'
: a11yBoolean(this.checked);

return (
  <Host
    role={this.indeterminate ? 'checkbox' : 'switch'}
    aria-checked={ariaChecked}

`
might it make sense? or no updates might be a way to go?

Copy link
Collaborator

Choose a reason for hiding this comment

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

@alexkaduk I think the role should be constant. Considering indeterminate state is possible, checkbox role would be warranted here as a general role.

@sonarqubecloud
Copy link

sonarqubecloud bot commented Mar 3, 2026

Quality Gate Failed Quality Gate failed

Failed conditions
B Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants