Skip to content

[EuiColorPicker] Ensure EuiFormRow label is applied to EuiColorPicker#9436

Merged
mgadewoll merged 11 commits intoelastic:mainfrom
mgadewoll:colorpicker/improve-a11y-labelling-in-form-row
Mar 23, 2026
Merged

[EuiColorPicker] Ensure EuiFormRow label is applied to EuiColorPicker#9436
mgadewoll merged 11 commits intoelastic:mainfrom
mgadewoll:colorpicker/improve-a11y-labelling-in-form-row

Conversation

@mgadewoll
Copy link
Copy Markdown
Contributor

@mgadewoll mgadewoll commented Mar 9, 2026

Summary

closes #9388

This PR updates EuiColorPicker to ensure that usages with a wrapping EuiFormRow can correctly pass down aria-label to the input.

To ensure this, the PR changes the usage of openLabel and closeLabel from being applied via aria-label to aria-describedby. This ensures that a dynamic aria-label attribute can be passed while we still keep the previous labels as hint texts for additional context.

Additional changes

  • Updated no-unnamed-interactive-element in @elastic/eslint-plugin-eui to include checking EuiColorPicker for missing accessible labels

Why are we making this change?

:accessibility: Improved Accessibility: This update ensures that other related labels can be associated with the color picker input.

Screenshots #

before after
Screenshot 2026-03-09 at 16 31 08 Screenshot 2026-03-09 at 16 30 13
Screen.Recording.2026-03-09.at.15.49.15.mov
Screen.Recording.2026-03-09.at.15.47.52.mov

Impact to users

🟢 No code updates are required on consumer side.

ℹ️ Due to the DOM changes, snapshot tests will fail.

QA

  • verify that EuiColorPicker has the previous open/close labels applied as aria-describedby
  • verify that aria-label can be passed to EuiColorPicker
  • verify that the EuiColorPicker input has the correct label inherited when nested within a EuiFormRow

General checklist

  • Browser QA
    • Checked in both light and dark modes
    • Checked in both MacOS and Windows high contrast modes
    • Checked in mobile
    • Checked in Chrome, Safari, Edge, and Firefox
    • Checked for accessibility including keyboard-only and screenreader modes
  • Docs site QA
  • Code quality checklist
  • Release checklist
    • A changelog entry exists and is marked appropriately
    • If applicable, added the breaking change issue label (and filled out the breaking change checklist)
    • If the changes unblock an issue in a different repo, smoke tested carefully (see Testing EUI features in Kibana ahead of time)
  • Designer checklist
    • If applicable, file an issue to update EUI's Figma library with any corresponding UI changes. (This is an internal repo, if you are external to Elastic, ask a maintainer to submit this request)

@mgadewoll mgadewoll self-assigned this Mar 9, 2026
@mgadewoll mgadewoll force-pushed the colorpicker/improve-a11y-labelling-in-form-row branch from dfaa024 to d85373b Compare March 9, 2026 15:59
@mgadewoll mgadewoll marked this pull request as ready for review March 13, 2026 10:37
@mgadewoll mgadewoll requested a review from a team as a code owner March 13, 2026 10:37
@weronikaolejniczak weronikaolejniczak self-requested a review March 17, 2026 09:25
@weronikaolejniczak
Copy link
Copy Markdown
Contributor

💭 I think the "before" and "after" screenshots are swapped 😄 not a big deal.

@mgadewoll
Copy link
Copy Markdown
Contributor Author

💭 I think the "before" and "after" screenshots are swapped 😄 not a big deal.

You're right! Thanks for the catch. It's corrected now.

Copy link
Copy Markdown
Contributor

@weronikaolejniczak weronikaolejniczak left a comment

Choose a reason for hiding this comment

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

Screen readers now announce a meaningful input label 🎉 Through testing I noticed we have an issue with the hint text now.

💭 Non-blocking idea: relying on snapshot tests for asserting these different cases is not ideal. I would improve the testing suite and directly assert the name and ariaDescribedById text, both standalone and within EuiFormRow, and passing custom hint text.

Testing notes

I tested the Playground and InFormRow stories from staging.

NVDA works, Jaws works but has some weird behaviors, VoiceOver has regressed.

I didn't test custom hint text passed through aria-describedby because classNames(ariaDescribedById, ariaDescribedby) is trivial.

❌ VoiceOver + Safari (MacOS)

❌ Standalone with aria-label

✅ reads aria-label
✅ reads aria-describedby hint text
✅ reads closeLabel when closed
❌ reads openLabel when open

When the popover is open, it does not read the openLabel even when refocused.

Kapture.2026-03-17.at.12.40.43.mp4

In production it's not working ideally but the openLabel/closeLabel dynamic change is announced by the VoiceOver when refocusing the input:

Kapture.2026-03-17.at.12.34.29.mp4

Should we use aria-expanded instead?

❌ Inside EuiFormRow

✅ reads aria-label
✅ reads aria-describedby hint text
❌ reads closeLabel when closed
❌ reads openLabel when open

This is even weirder, the popover is not open and it reads closeLabel:

Kapture.2026-03-17.at.12.43.45.mp4

⚠️ Jaws + Chrome (Windows in ParallelsVM)

🔊 Turn on the sound (sorry for the quiet sound but the Mac is under my desk)

⚠️ Standalone with aria-label

✅ reads aria-label
✅ reads aria-describedby hint text
✅ reads openLabel when open
⚠️ reads closeLabel when closed - with a caveat that when we close the popover, it doesn't read the closeLabel again

Kapture.2026-03-17.at.13.10.22.mp4

⚠️ It says "Press the ESC key to close the popover" twice.

To be fair, in production it says both "Press the ESC key to close the popover" and Jaws-specific "To close the dialog press...":

Kapture.2026-03-17.at.13.07.13.mp4

but it's less confusing then duplicating the same instruction.

⚠️ Inside EuiFormRow

✅ reads aria-label
✅ reads aria-describedby hint text
✅ reads openLabel when open
⚠️ reads closeLabel when closed

Kapture.2026-03-17.at.13.12.42.mp4

Same issues as above.

✅ NVDA + Chrome (Windows in ParallelsVM)

✅ Standalone with aria-label

✅ reads aria-label
✅ reads aria-describedby hint text
✅ reads openLabel when open
✅ reads closeLabel when closed

Kapture.2026-03-17.at.12.56.08.mp4

✅ Inside EuiFormRow

✅ reads aria-label
✅ reads aria-describedby hint text
✅ reads openLabel when open
✅ reads closeLabel when closed

Kapture.2026-03-17.at.12.58.13.mp4

@mgadewoll
Copy link
Copy Markdown
Contributor Author

❌ VoiceOver + Safari (MacOS)

❌ Standalone with aria-label

✅ reads aria-label
✅ reads aria-describedby hint text
✅ reads closeLabel when closed
❌ reads openLabel when open

When the popover is open, it does not read the openLabel even when refocused.

I'm not sure it's a regression per se.
The aria-describedby works, but VO is kinda weird. On initial focus it reads the label but on return focus it reads the semantic element information first instead. If you use the VO navigation then it reads the label and description again. Not sure if that's just "their way" of doing things if "nothing changed"? 🤔 In any case, weird, but I don't think that's anything we can optimize.

I can see the same behavior though on production (still using aria-label) as well.

Screen.Recording.2026-03-17.at.14.39.30.mov

I've done a bit of checking on the base behavior with native HTML elements in VoiceOver.

  • VO generally does not seem to announce changes in aria-describedby unless moving focus again to an element that has the updated aria-describedby (while e.g. NVDA does read the update)
  • when adding aria-describedby with an id to an element that has changing content like isOpen ? openLabel : closeLabel the output is very flaky and rather stale on navigation
  • adding a conditional aria-describedby like aria-describedby={isOpen ? openLabelId : closeLabelId} works better. It still reads the semantic element information instead of the label first when moving to another element of same type (my guess) but the aria-describedby content is not stale.
Screen.Recording.2026-03-17.at.14.21.11.mov

In our implementation the VO navigation somehow returns to the inner placeholder element instead of the input, which is weird.

Screen.Recording.2026-03-17.at.14.29.44.mov

When testing it with the base HTML elements it did the same when moving focus manually for every other time 🤷‍♀️
I'm leaning more towards this is "VO quirk" rather than implementation issue tbh.

Screen.Recording.2026-03-17.at.14.55.54.mov

❌ Inside EuiFormRow

✅ reads aria-label
✅ reads aria-describedby hint text
❌ reads closeLabel when closed
❌ reads openLabel when open

This is even weirder, the popover is not open and it reads closeLabel:

This could be due to what I noticed on stale content linking:

  • when adding aria-describedby with an id to an element that has changing content like isOpen ? openLabel : closeLabel the output is very flaky and rather stale on navigation

I quickly tested it, it still works as expected in NVDA and JAWS, we should be ok to update it.

@weronikaolejniczak
Copy link
Copy Markdown
Contributor

Thanks for digging deeper, Lene! 🙌🏻

It definitely looks like VO quirk, aria-describedby is handled starkly different from other screen readers and has issues historically, not only in this component but IMHO it is an issue and it might get raised in a11y audits. So maybe I'd run this by the a11y experts.

- this works more reliable in VO to prevent stale hint announcements on navigation
Copilot AI review requested due to automatic review settings March 17, 2026 15:15
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={classNames(ariaDescribedById, ariaDescribedby)}
aria-describedby={classNames(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This change is to prevent stale aria-describedby announcements on navigation in VO (as mentioned here)

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR improves EuiColorPicker accessibility by allowing EuiFormRow-provided labeling (and consumer-provided aria-label) to correctly apply to the underlying input, while moving the existing open/close guidance text into screen reader descriptions.

Changes:

  • Updated EuiColorPicker to stop overriding the input’s accessible name and instead expose open/close guidance via aria-describedby + SR-only content.
  • Added Storybook coverage for EuiColorPicker inside EuiFormRow and fixed an incorrect import path.
  • Extended @elastic/eslint-plugin-eui’s no-unnamed-interactive-element rule to include EuiColorPicker, with accompanying tests and changelog entries.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/eui/src/components/color_picker/color_picker.tsx Routes aria-label/aria-labelledby to the input and moves open/close instructions to aria-describedby via SR-only elements.
packages/eui/src/components/color_picker/color_picker.stories.tsx Adds an EuiFormRow story and adjusts story args/imports to support QA of labeling behavior.
packages/eui/src/components/color_picker/snapshots/color_picker.test.tsx.snap Updates snapshots for the new aria-describedby + SR-only markup.
packages/eui/changelogs/upcoming/9436.md Adds an EUI changelog entry for the accessibility fix.
packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.ts Includes EuiColorPicker in the unnamed interactive element lint rule.
packages/eslint-plugin/src/rules/a11y/no_unnamed_interactive_element.test.ts Adds test cases ensuring EuiColorPicker is flagged when missing accessible labeling.
packages/eslint-plugin/changelogs/upcoming/9436.md Adds an eslint-plugin changelog entry for the rule update.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

aria-label={ariaLabel}
// if an id is provided it might be used in combination with `htmlFor` on a label,
// so we don't want to override it with a fallback `aria-label`
aria-label={id ? undefined : _ariaLabel ?? ariaLabel}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This results in: EuiFormRow label > custom aria-label > internal fallback label.

Interaction taken:

  • focus input
  • press arrowDown
  • tab into the popover
  • press Escape
scenario NVDA JAWS VO
with EuiFormRow Screenshot 2026-03-17 at 16 51 19 Screenshot 2026-03-17 at 16 50 07
Screen.Recording.2026-03-17.at.17.00.05.mov
with fallback aria-label Screenshot 2026-03-17 at 16 51 02 Screenshot 2026-03-17 at 16 49 35
Screen.Recording.2026-03-17.at.16.59.21.mov

Fyi, the VO behavior is slightly better when the next element is not the same type (leaving the frame and going back to the same input element doesn't count) but it still reads the generic semantic information anyway. 🤷‍♀️

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I realized just now that the order was still not quite correct.
If a custom aria-label is passed, that would be on purpose and hence should override the form label, imho.

Updated in 2f6923b.
The new order is:
Custom aria-label > EuiFormRow label > internal fallback label.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Definitely agreed about the order!

@elasticmachine
Copy link
Copy Markdown
Collaborator

💚 Build Succeeded

History

cc @mgadewoll

@elasticmachine
Copy link
Copy Markdown
Collaborator

💚 Build Succeeded

History

cc @mgadewoll

@weronikaolejniczak weronikaolejniczak self-requested a review March 19, 2026 14:52
Copy link
Copy Markdown
Contributor

@weronikaolejniczak weronikaolejniczak left a comment

Choose a reason for hiding this comment

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

🟢 Code LGTM, Safari + VO works as expected now, and there's no regression for JAWS and NVDA. Thank you for digging deeper and figuring out a smart solution to this, Lene 🙏🏻

Testing notes

🟢 Safari + VoiceOver

🟢 [Standalone] Fallback value 🟢 [Standalone] Custom aria-label 🟢 [Form row] Label 🟢 [Form row] Custom aria-label
Kapture.2026-03-19.at.15.55.49.mp4
Kapture.2026-03-19.at.15.57.38.mp4
Kapture.2026-03-19.at.15.58.28.mp4
Kapture.2026-03-19.at.15.59.29.mp4

🟢 JAWS + Chrome

🟢 [Standalone] Fallback value 🟢 [Standalone] Custom aria-label 🟢 [Form row] Label 🟢 [Form row] Custom aria-label
Kapture.2026-03-19.at.16.20.59.mp4
Kapture.2026-03-19.at.16.22.14.mp4
Kapture.2026-03-19.at.16.25.43.mp4
Kapture.2026-03-19.at.16.24.39.mp4

⚠️ Still the duplicated "Press the ESC key to close the popover" but I think it's fine.

🟢 NVDA + Chrome

NVDA actually prioritizes that aria-label instead of the id + htmlFor.

🟢 [Standalone] Fallback value 🟢 [Standalone] Custom aria-label 🟢 [Form row] Label 🟢 [Form row] Custom aria-label
Kapture.2026-03-19.at.16.15.28.mp4
Kapture.2026-03-19.at.16.16.35.mp4
Kapture.2026-03-19.at.16.17.47.mp4
Kapture.2026-03-19.at.16.18.46.mp4

@mgadewoll mgadewoll merged commit 75211b7 into elastic:main Mar 23, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[A11y] Improve EuiColorPicker screen reader announcement

4 participants