Skip to content

A11y: Improve boolean control contrast in forced colors mode#34204

Merged
Sidnioulz merged 8 commits into
storybookjs:nextfrom
anchmelev:fix/31695-boolean-control-forced-colors
Apr 13, 2026
Merged

A11y: Improve boolean control contrast in forced colors mode#34204
Sidnioulz merged 8 commits into
storybookjs:nextfrom
anchmelev:fix/31695-boolean-control-forced-colors

Conversation

@anchmelev
Copy link
Copy Markdown
Contributor

@anchmelev anchmelev commented Mar 19, 2026

Closes #31695

What I did

  • improved the Boolean control styling in forced colors mode so the selected state is clearly distinguishable
  • replaced the previous underline-only selected-state treatment with explicit system-color fill, text color, and outline
  • hid the native checkbox from visual rendering while preserving semantics and keyboard focus behavior
  • added a focused unit test covering the forced-colors style contract

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

  1. Run Storybook locally.
  2. Open Boolean Control docs or Example/Button -> Primary.
  3. Enable Windows High Contrast mode, or emulate forced-colors: active in browser devtools.
  4. Verify the selected boolean option is clearly distinguishable from the unselected option.
  5. Toggle between True and False and confirm the highlighted state moves correctly.
  6. Confirm no native checkbox artifact is rendered inside the selected segment.

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update MIGRATION.MD

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Improved focus and outline styling for boolean controls in forced-colors mode
    • Refined hover behavior for boolean control options to remove unnecessary visual effects
    • Enhanced label styling for better accessibility support
  • Tests

    • Added E2E tests for forced-colors mode behavior
    • Added E2E tests validating focus states and hover interactions

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 19, 2026

📝 Walkthrough

Walkthrough

Refactored the Boolean control component's styling by extracting styles into a helper function, shifting focus/forced-colors handling from input-level to label-level with focus-within selectors, and replacing explicit layout properties with srOnlyStyles spreading. Added two E2E tests validating forced-colors behavior and hover interactions.

Changes

Cohort / File(s) Summary
Boolean Control Styling Refactor
code/addons/docs/src/blocks/controls/Boolean.tsx
Extracted getBooleanControlStyles(theme) function, migrated focus/forced-colors from input to label-level using &:focus-within and @media (forced-colors: active), replaced explicit width/height/position with srOnlyStyles, and adjusted span hover and forced-colors visual properties including forced-color-adjust, background, color, box-shadow, and outline.
Forced-Colors E2E Tests
code/e2e-tests/addon-controls.spec.ts
Added two new Playwright tests: one validating forced-colors styling on boolean controls (outline styles, background colors, forced-color-adjust on options), and another confirming hover state does not apply box-shadow on inactive options.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
code/addons/docs/src/blocks/controls/Boolean.test.tsx (1)

12-65: Consider adding a dark theme test case.

The test thoroughly validates the forced-colors styling contract for the light theme. While the theme-dependent boxShadow logic (line 103-105 in Boolean.tsx) is overridden to 'none' in forced-colors mode anyway, adding a quick test with themes.dark would provide additional confidence that the forced-colors styles remain consistent regardless of theme base.

💡 Optional: Add dark theme coverage
+  it('emits consistent forced-colors styles for dark theme', () => {
+    const styles = getBooleanControlStyles(convert(themes.dark));
+    const selectedStyles = asCssObject(
+      styles['input:checked ~ span:last-of-type, input:not(:checked) ~ span:first-of-type']
+    );
+    const selectedForcedColors = asCssObject(selectedStyles['@media (forced-colors: active)']);
+
+    expect(selectedForcedColors).toMatchObject({
+      forcedColorAdjust: 'none',
+      background: 'Highlight',
+      color: 'HighlightText',
+    });
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@code/addons/docs/src/blocks/controls/Boolean.test.tsx` around lines 12 - 65,
Add a parallel assertion block that runs the same forced-colors checks against
the dark theme: call getBooleanControlStyles(convert(themes.dark)) (same
helpers: asCssObject, accessors for '@media (forced-colors: active)',
'&:focus-within', styles.input, styles.span, and the selectedStyles selector)
and assert the same properties (background, outline, forcedColorAdjust, color,
boxShadow, absence of textDecoration, etc.); you can duplicate the existing
it(...) body into a second it(...) describing "emits explicit forced-colors
styles for the selected state (dark theme)" or parameterize the test to iterate
[themes.light, themes.dark] while keeping references to getBooleanControlStyles
and the selectedStyles selector.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@code/addons/docs/src/blocks/controls/Boolean.test.tsx`:
- Around line 12-65: Add a parallel assertion block that runs the same
forced-colors checks against the dark theme: call
getBooleanControlStyles(convert(themes.dark)) (same helpers: asCssObject,
accessors for '@media (forced-colors: active)', '&:focus-within', styles.input,
styles.span, and the selectedStyles selector) and assert the same properties
(background, outline, forcedColorAdjust, color, boxShadow, absence of
textDecoration, etc.); you can duplicate the existing it(...) body into a second
it(...) describing "emits explicit forced-colors styles for the selected state
(dark theme)" or parameterize the test to iterate [themes.light, themes.dark]
while keeping references to getBooleanControlStyles and the selectedStyles
selector.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bc008b5b-17dc-4330-afeb-acb5caf9ed61

📥 Commits

Reviewing files that changed from the base of the PR and between 8e95824 and b4ca4d3.

📒 Files selected for processing (2)
  • code/addons/docs/src/blocks/controls/Boolean.test.tsx
  • code/addons/docs/src/blocks/controls/Boolean.tsx

Comment thread code/addons/docs/src/blocks/controls/Boolean.tsx Outdated
Copy link
Copy Markdown
Contributor

@Sidnioulz Sidnioulz left a comment

Choose a reason for hiding this comment

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

Could you please mention the list of browsers used for testing, and if possible, provide screenshots? I've had a bunch of High Contrast Mode PRs in the past where I couldn't reproduce the fix as each browser may choose to use different system colours by default. It helps if I know which browsers were buggy initially! Thanks ❤️

@Sidnioulz Sidnioulz changed the title fix: improve boolean control contrast in forced colors mode A11y: Improve boolean control contrast in forced colors mode Mar 19, 2026
@Sidnioulz Sidnioulz added bug windows ci:normal a11y: contrast Accessibility issues related to contrast including Windows High Contrast Mode labels Mar 19, 2026
@Sidnioulz Sidnioulz self-assigned this Mar 19, 2026
@Sidnioulz Sidnioulz self-requested a review March 19, 2026 16:12
Copy link
Copy Markdown
Contributor

@Sidnioulz Sidnioulz left a comment

Choose a reason for hiding this comment

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

Thanks for the PR! This is a great improvement :)

Image

I'm seeing a side effect when emulation is off: hovering over the inactive option adds an odd border/shadow, which complicates the UI and makes toggle animations look off. We don't need a contrast ratio on hover (SC 1.4.11, G183).

Image

Could you please address that bug and switch the tests to using Playwright? 🙏

Comment thread code/addons/docs/src/blocks/controls/Boolean.test.tsx Outdated
Comment thread code/addons/docs/src/blocks/controls/Boolean.tsx Outdated
@valentinpalkovic valentinpalkovic moved this to Empathy Queue (prioritized) in Core Team Projects Mar 24, 2026
@valentinpalkovic valentinpalkovic moved this from Empathy Queue (prioritized) to In Progress in Core Team Projects Mar 24, 2026
@anchmelev
Copy link
Copy Markdown
Contributor Author

anchmelev commented Mar 27, 2026

Thanks for the PR! This is a great improvement :)

Image I'm seeing a side effect when emulation is off: hovering over the inactive option adds an odd border/shadow, which complicates the UI and makes toggle animations look off. We don't need a contrast ratio on hover ([SC 1.4.11](https://www.w3.org/WAI/WCAG21/Understanding/non-text-contrast.html), [G183](https://www.w3.org/WAI/WCAG21/Techniques/general/G183)). Image Could you please address that bug and switch the tests to using Playwright? 🙏

Yes, that was a real side effect. I removed the hover shadow from the inactive option in the default mode so the toggle no longer picks up that extra pseudo-border and the selected state remains visually cleaner.

I also tested this in Chrome on Windows 10, and I no longer see this side effect.
image
image

Please let me know if the issue still reproduces for you after the latest commit. Thanks!

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
code/e2e-tests/addon-controls.spec.ts (1)

129-157: Decouple selected/unselected assertions from default story args.

This test currently binds “selected” to trueOption. If the story default for primary changes, the test may fail for the wrong reason. Prefer deriving selected/unselected from input.isChecked() first.

♻️ Proposed refactor
-    const falseOption = label.locator('span').first();
-    const trueOption = label.locator('span').last();
+    const falseOption = label.locator('span').first();
+    const trueOption = label.locator('span').last();
+    const isChecked = await input.isChecked();
+    const selectedOption = isChecked ? trueOption : falseOption;
+    const unselectedOption = isChecked ? falseOption : trueOption;
@@
-    await expect(trueOption).toHaveCSS('forced-color-adjust', 'none');
+    await expect(selectedOption).toHaveCSS('forced-color-adjust', 'none');
@@
-    const selectedBackground = await trueOption.evaluate(
+    const selectedBackground = await selectedOption.evaluate(
       (el) => getComputedStyle(el).backgroundColor
     );
-    const unselectedBackground = await falseOption.evaluate(
+    const unselectedBackground = await unselectedOption.evaluate(
       (el) => getComputedStyle(el).backgroundColor
     );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@code/e2e-tests/addon-controls.spec.ts` around lines 129 - 157, The test
assumes trueOption is the selected option; instead query the radio state via
input.isChecked() and derive selectedOption/unselectedOption accordingly (use
the existing input locator and the label.locator('span').first()/last() to map):
call await input.isChecked() and set selected = isChecked ? trueOption :
falseOption and unselected = isChecked ? falseOption : trueOption, then replace
assertions that reference trueOption/falseOption for selected/unselected visuals
with selected/unselected variables (e.g.,
selectedBackground/unselectedBackground and the box-shadow/forced-color-adjust
expectations) so the test passes regardless of the story's default primary value
while keeping the focus/outline checks unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@code/e2e-tests/addon-controls.spec.ts`:
- Around line 129-157: The test assumes trueOption is the selected option;
instead query the radio state via input.isChecked() and derive
selectedOption/unselectedOption accordingly (use the existing input locator and
the label.locator('span').first()/last() to map): call await input.isChecked()
and set selected = isChecked ? trueOption : falseOption and unselected =
isChecked ? falseOption : trueOption, then replace assertions that reference
trueOption/falseOption for selected/unselected visuals with selected/unselected
variables (e.g., selectedBackground/unselectedBackground and the
box-shadow/forced-color-adjust expectations) so the test passes regardless of
the story's default primary value while keeping the focus/outline checks
unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d888e47a-00fa-4cfb-94cf-31dd115778d3

📥 Commits

Reviewing files that changed from the base of the PR and between c7238ac and eebbb79.

📒 Files selected for processing (2)
  • code/addons/docs/src/blocks/controls/Boolean.tsx
  • code/e2e-tests/addon-controls.spec.ts

@storybook-app-bot
Copy link
Copy Markdown

storybook-app-bot Bot commented Mar 27, 2026

Package Benchmarks

Commit: 032f8ca, ran on 2 April 2026 at 01:34:54 UTC

No significant changes detected, all good. 👏

@anchmelev anchmelev requested a review from Sidnioulz April 6, 2026 14:37
@anchmelev
Copy link
Copy Markdown
Contributor Author

Hi @Sidnioulz, following up on this PR.
I addressed the requested changes by removing the hover side effect in the default mode, moving the coverage to Playwright, and updating the e2e locator in the latest follow-up commit.
Please let me know if there’s anything else you’d like me to adjust to help move this forward.
Thanks!

@Sidnioulz Sidnioulz merged commit 0521e2d into storybookjs:next Apr 13, 2026
118 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a11y: contrast Accessibility issues related to contrast including Windows High Contrast Mode bug ci:normal windows

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[Bug]: Boolean selection states are not visually distinguishable when using windows high contrast

4 participants