fix: expose input styling on RAC Checkbox and Radio - #10517
Conversation
snowystinger
left a comment
There was a problem hiding this comment.
Thanks for the PR. I don't think this is going to work. VisuallyHidden wraps the input in a position absolute. So anything inside it will be positioned relative to it. In addition, it has height and width of 1, so anything inside it using height/width 100% would still only be 1px.
I'd start by proving that it works in the browser using storybook, not unit tests, jsdom is a pretty bad place to test that this works.
|
@snowystinger you are right, and I verified it in a real browser. Thanks for the push to test this properly instead of relying on jsdom. I reproduced the exact
So exposing I'll rework this PR with a different approach. The real fix likely needs to change how the hidden input is laid out for these components (or how the screen reader focus ring is drawn), rather than exposing styles on the input. I'll dig into #9687 and come back with something that actually works in the browser. |
Screen readers (VoiceOver, NVDA) draw their focus indicator around the
native input element, not the visible component. In Checkbox and Radio
the input is rendered inside VisuallyHidden, which collapses it to 1x1px,
so the screen reader focus ring shows up as a tiny square disconnected
from the visual focus.
Expose inputClassName/inputStyle and visuallyHiddenClassName/visuallyHiddenStyle
on Checkbox, CheckboxField, Radio, RadioField, CheckboxButton, and RadioButton
so users can size and position both the hidden input and its VisuallyHidden
wrapper to encompass the visible component. The props follow the existing
inputRef pattern and are optional, so there is no behavior change by default.
To make the screen reader focus ring match the component, the label must be
a positioned containing block (position: relative), the VisuallyHidden wrapper
must be stretched to the label (e.g. {inset: 0, width: 'auto', height: 'auto'}),
and the input must fill the wrapper ({position: 'absolute', inset: 0, width: '100%', height: '100%'}).
This is documented on the new props.
Adds real-browser layout tests (not jsdom) that measure the hidden input's
bounding box against the component, verifying the input covers the component
rather than the viewport. This addresses the review feedback that jsdom
cannot validate layout.
Fixes adobe#9687
1de6149 to
393b02c
Compare
| * and set `position: relative` on the label (or a positioned ancestor) so the input resolves | ||
| * against it rather than the viewport. | ||
| */ | ||
| visuallyHiddenStyle?: CSSProperties; |
There was a problem hiding this comment.
VisuallyHidden has highly specific styles right now that ensure it's visible to screen readers. If we expose all of these styles, it'd be pretty easy for people to accidentally make it not accessible.
For example, making it full width and height would probably show the native browser chrome for the input. The natural thing to do is then to use visibility hidden. This is not accessible.
Please include a story that you, the person, confirm this works on. Including screen readers. Once we have that maybe we can find some smaller API to expose, as we do not want to expose all of these props if possible.
There was a problem hiding this comment.
You're right that exposing raw control over VisuallyHidden gives people a lot of rope, and the visibility: hidden trap is a real one - it is the natural thing to reach for and it does break screen reader exposure.
Two things I'd like to do:
-
Add a story and validate it with a real screen reader. I'll add a story for Checkbox and Radio that stretches the hidden input over the component using the documented technique (
visuallyHiddenStyle={{inset: 0, width: 'auto', height: 'auto'}}+inputStyle={{position: 'absolute', inset: 0}}), and confirm with VoiceOver that the focus ring tracks the component and the input stays exposed. -
Propose a smaller API. Instead of exposing all four props (
inputClassName/inputStyle+visuallyHiddenClassName/visuallyHiddenStyle), I'd rather explore a single prop that encodes the safe pattern and keepsVisuallyHiddenencapsulated. Something like afocusRingorinputPositionboolean that applies the correct positioning internally (label as containing block, wrapper stretched, input filling it) without handing out arbitrary style/className access. That achieves the fix for the reported issue with a much smaller surface, and it cannot be used to accidentally un-accessible the component.
My instinct is option 2 is the right shape for a library like this - the reported issue is specifically about the screen reader focus ring not tracking the component, and a boolean that encodes "make the hidden input cover the component" solves exactly that without exposing implementation details.
Want me to put together a proposal for the smaller prop (with the story + VoiceOver check) so we can compare it side by side against the current approach?
There was a problem hiding this comment.
yes, let's explore a smaller api
|
Validated the story I added ( Validating visually also surfaced a deeper preexisting bug in RAC Checkbox and RadioGroup that the current fix does not solve, and I want to flag it before we go further with the current approach. Observation: in Chrome desktop (with system theme), the native browser chrome for the checkbox/radio input is rendered on top of the custom SVG mark when the component is checked. This is visible on:
So the current fix exposes four style props that solve the bounding box issue (the input now covers the visible component) but does not solve the native chrome overlay. In fact, by stretching the input over the visible component, the native chrome overlay is now bigger and more obvious. Root cause: the hidden input has This means the smaller API you suggested is not just a nice-to-have - it is necessary to solve the actual bug. A single boolean prop that internally does the positioning correctly AND applies Two questions before I rework the PR:
Want me to put together the smaller prop proposal once we agree on the direction? |
|
I'm not sure what the prop should look like, a boolean probably isn't enough. If you refer back to the conversation on the Issue, one of the worries was that there were multiple ways that someone could want to style everything. However, I think it might be better to name those scenarios and add them as we come across them. For instance, you're currently demoing the "full height and width in a relative element" (open to naming ideas). Since you're using AI, you might ask it to think of other scenarios. The other thing we could do is, if position relative is the resolved style on the checkbox (check in a useLayoutEffect), then we could apply the full height/width automatically. I'm not sure if that'll break anything. It'd be susceptible to being changed outside of React's lifecycle as well. But maybe there are other ideas to explore around this. |
|
Good points. A boolean definitely isn't enough. I think the explicit style props we have now are the right escape hatch for arbitrary scenarios, but a named prop for the common case would be much cleaner. The scenario I'm demoing is essentially "stretch the hidden input to the visual label". We could expose that as Other scenarios I can think of:
On auto-detecting If you want, I can update the PR with |
Why
Screen readers (VoiceOver, NVDA) draw their focus indicator around the native
<input>element, not around the visible component. In Checkbox and Radio theinput is rendered inside
VisuallyHidden, which collapses it to 1x1px(
width: 1px; height: 1px; position: absolute; clip: rect(0 0 0 0)). The resultis that the screen reader focus ring shows up as a tiny square in the corner of
the component, disconnected from the visual focus. That is confusing for users
with low vision who rely on both the visual focus cue and the assistive
technology outline.
What changed
Expose
inputClassName/inputStyleandvisuallyHiddenClassName/visuallyHiddenStyleon Checkbox, CheckboxField, Radio, RadioField, CheckboxButton, and RadioButton so
users can size and position both the hidden input and its
VisuallyHiddenwrapperto encompass the visible component, making the screen reader focus ring match the
visual one.
The props follow the existing
inputRefpattern already on these components andare optional, so there is no behavior change by default.
Usage
To make the screen reader focus ring match the component, the label must be a
positioned containing block (
position: relative), theVisuallyHiddenwrappermust be stretched to the label, and the input must fill the wrapper:
The
position: relativeon the label is required because the input isabsolutely positioned; without a positioned ancestor it would resolve against
the viewport instead of the component. This is documented on the new props.
Testing
bounding box against the component, verifying the input covers the component
rather than the viewport. This addresses the review feedback that jsdom cannot
validate layout.
check-typesandlint are clean.
Fixes #9687
Findings
While implementing this fix I found a couple of related cases that are out of scope for this PR but worth flagging:
VisuallyHiddenand collapsed to 1x1px, so it likely has the same screen reader focus indicator issue. It does not haveinputClassName/inputStyleeither. Since [Bug]: Screen reader focus indicator is misaligned for Checkbox and Radio components [react-aria-components] #9687 only mentions Checkbox and Radio, I did not expand the scope here, but Switch is a good candidate for a follow-up.VisuallyHidden. These are different cases (range input, selection, and date input respectively), but if the same fix is wanted across the library, theinputClassName/inputStylepattern could be extended consistently.Happy to follow up on Switch if the maintainers want it. Otherwise these are documented for future work.