-
Notifications
You must be signed in to change notification settings - Fork 1.6k
fix: expose input styling on RAC Checkbox and Radio #10517
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
393b02c
3d19d3e
182d451
f23be64
c07895e
47ae4ee
9a427b0
5818570
79e3cc9
0cf378d
508a29a
6d922cb
be64a45
6e55c11
70e2b8c
684a880
b46d575
9bad6de
5482c58
f09ec98
887be2d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| /* | ||
| * Copyright 2026 Adobe. All rights reserved. | ||
| * This file is licensed to you under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software distributed under | ||
| * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS | ||
| * OF ANY KIND, either express or implied. See the License for the specific language | ||
| * governing permissions and limitations under the License. | ||
| */ | ||
|
|
||
| import {RefObject} from '@react-types/shared'; | ||
| import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; | ||
|
|
||
| let anchorSupport: boolean | null = null; | ||
| function getAnchorSupport(): boolean { | ||
| if (anchorSupport === null) { | ||
| anchorSupport = | ||
| typeof CSS !== 'undefined' && | ||
| typeof CSS.supports === 'function' && | ||
| CSS.supports('anchor-name: --test'); | ||
| } | ||
| return anchorSupport ?? false; | ||
| } | ||
|
|
||
| /** | ||
| * Positions the hidden native input of a component (e.g. Checkbox, Radio) over | ||
| * the component's outer element using CSS anchor positioning, so the screen | ||
| * reader focus indicator (VoiceOver and NVDA draw the ring around the native | ||
| * input) matches the visible component instead of collapsing to the 1x1px | ||
| * VisuallyHidden box. | ||
| * | ||
| * The input is taken out of flow with `position: fixed` and anchored to the | ||
| * outer element with `position-anchor` + `anchor()`/`anchor-size()`, which | ||
| * escapes the VisuallyHidden wrapper's 1x1px absolute box. If the outer | ||
| * element (or a consumer-provided class) declares an `anchor-name`, that name | ||
| * is used; otherwise a unique default name is applied inline. Browsers without | ||
| * CSS anchor positioning support keep today's behavior. | ||
| * | ||
| * CSS can change without notifying React, so a later `anchor-name` change is | ||
| * only picked up on re-render. | ||
| */ | ||
| export function useHiddenInputAnchor( | ||
| anchorRef: RefObject<HTMLElement | null>, | ||
| inputRef: RefObject<HTMLInputElement | null>, | ||
| defaultAnchorName: string | ||
| ): void { | ||
| useLayoutEffect(() => { | ||
| let outer = anchorRef.current; | ||
| let input = inputRef.current; | ||
| if (!outer || !input || !getAnchorSupport()) { | ||
| return; | ||
| } | ||
|
|
||
| let name = getComputedStyle(outer).getPropertyValue('anchor-name').trim(); | ||
| if (!name || name === 'none') { | ||
| name = defaultAnchorName; | ||
| outer.style.setProperty('anchor-name', name); | ||
| } | ||
|
|
||
| input.style.setProperty('position', 'fixed'); | ||
| input.style.setProperty('margin', '0'); | ||
| input.style.setProperty('position-anchor', name); | ||
| input.style.setProperty('top', 'anchor(top)'); | ||
| input.style.setProperty('left', 'anchor(left)'); | ||
| input.style.setProperty('width', 'anchor-size(width)'); | ||
| input.style.setProperty('height', 'anchor-size(height)'); | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,3 +27,19 @@ export const CheckboxExample: CheckboxStory = { | |
| </Checkbox> | ||
| ) | ||
| }; | ||
|
|
||
| // Demonstrates the screen reader focus indicator tracking the checkbox. The | ||
| // hidden input is anchored to the component's outer element via CSS anchor | ||
| // positioning, so VoiceOver/NVDA draw the ring around the visible component. | ||
| export const CheckboxScreenReaderFocusRing: CheckboxStory = { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same question |
||
| render: args => ( | ||
| <Checkbox {...args}> | ||
| <div className="checkbox"> | ||
| <svg viewBox="0 0 18 18" aria-hidden="true"> | ||
| <polyline points="1 9 7 14 15 4" /> | ||
| </svg> | ||
| </div> | ||
| Unsubscribe | ||
| </Checkbox> | ||
| ) | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -67,6 +67,25 @@ export const RadioGroupExample: RadioGroupStoryObj = { | |
| } | ||
| }; | ||
|
|
||
| // Demonstrates the screen reader focus indicator tracking each radio. The | ||
| // hidden input is anchored to the component's outer element via CSS anchor | ||
| // positioning, so VoiceOver/NVDA draw the ring around the visible component. | ||
| export const RadioGroupScreenReaderFocusRing: RadioGroupStoryObj = { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why do we need a separate story? won't this be on every radio and checkbox by default now? |
||
| render: props => { | ||
| return ( | ||
| <RadioGroup {...props} data-testid="radio-group-focus-ring"> | ||
| <Label>Favorite pet</Label> | ||
| <Radio onFocus={action('radio focus')} onBlur={action('radio blur')} value="dogs"> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why do we need focus and blur actions? |
||
| Dog | ||
| </Radio> | ||
| <Radio onFocus={action('radio focus')} onBlur={action('radio blur')} value="cats"> | ||
| Cat | ||
| </Radio> | ||
| </RadioGroup> | ||
| ); | ||
| } | ||
| }; | ||
|
|
||
| export const RadioGroupControlledExample: RadioGroupStory = props => { | ||
| let [selected, setSelected] = useState<string | null>(null); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| /* | ||
| * Copyright 2026 Adobe. All rights reserved. | ||
| * This file is licensed to you under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software distributed under | ||
| * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS | ||
| * OF ANY KIND, either express or implied. See the License for the specific language | ||
| * governing permissions and limitations under the License. | ||
| */ | ||
|
|
||
| // Verifies in a real browser (not jsdom) that the hidden native input is | ||
| // anchored to the component's outer element via CSS anchor positioning, so its | ||
| // bounding box matches the visible component and the screen reader focus | ||
| // indicator aligns with the visual one. | ||
| // | ||
| // This is a layout test: jsdom does no layout, so it cannot validate this. | ||
| // | ||
| // Browsers without CSS anchor positioning support keep the default 1x1px | ||
| // VisuallyHidden behavior, in which case these tests are skipped. | ||
|
|
||
| import {Checkbox} from '../src/Checkbox'; | ||
| import {expect, it} from 'vitest'; | ||
| import {Label} from '../src/Label'; | ||
| import {Radio, RadioGroup} from '../src/RadioGroup'; | ||
| import React from 'react'; | ||
| import {render} from 'vitest-browser-react'; | ||
|
|
||
| function rect(el: Element) { | ||
| let r = el.getBoundingClientRect(); | ||
| return {x: r.x, y: r.y, width: r.width, height: r.height}; | ||
| } | ||
|
|
||
| function supportsAnchorPositioning() { | ||
| return CSS.supports('anchor-name: --test'); | ||
| } | ||
|
|
||
| // The input should cover the component. It may be up to 2px larger than the | ||
| // label (subpixel rounding); that is fine for the screen reader focus ring. | ||
| function covers(a: {width: number; height: number}, b: {width: number; height: number}) { | ||
| return a.width >= b.width - 1 && a.height >= b.height - 1; | ||
| } | ||
|
|
||
| it('Checkbox: the hidden input covers the component via anchor positioning', async () => { | ||
| let screen = await render(<Checkbox>Test</Checkbox>); | ||
|
|
||
| let label = screen.container.querySelector('label')!; | ||
| let input = screen.container.querySelector('input')!; | ||
|
|
||
| let labelRect = rect(label); | ||
| let inputRect = rect(input); | ||
|
|
||
| // The visible component should be larger than 1x1. | ||
| expect(labelRect.width).toBeGreaterThan(1); | ||
| expect(labelRect.height).toBeGreaterThan(1); | ||
|
|
||
| if (supportsAnchorPositioning()) { | ||
| // The input should be anchored to the component's outer element, not the | ||
| // viewport and not the 1x1px VisuallyHidden wrapper. | ||
| expect(covers(inputRect, labelRect)).toBe(true); | ||
| expect(inputRect.width).toBeLessThanOrEqual(labelRect.width + 2); | ||
| expect(inputRect.height).toBeLessThanOrEqual(labelRect.height + 2); | ||
|
|
||
| // A consumer-provided anchor-name is used instead of the component default. | ||
| let anchorName = getComputedStyle(label).getPropertyValue('anchor-name').trim(); | ||
| expect(anchorName).not.toBe(''); | ||
| } | ||
| }); | ||
|
|
||
| it('Radio: the hidden input covers the component via anchor positioning', async () => { | ||
| let screen = await render( | ||
| <RadioGroup> | ||
| <Label>Test</Label> | ||
| <Radio value="a">A</Radio> | ||
| </RadioGroup> | ||
| ); | ||
|
|
||
| // The Radio's own label is the one containing the input, not the standalone | ||
| // <Label>Test</Label> which precedes it in the DOM. | ||
| let input = screen.container.querySelector('input')!; | ||
| let label = input.closest('label')!; | ||
|
|
||
| let labelRect = rect(label); | ||
| let inputRect = rect(input); | ||
|
|
||
| expect(labelRect.width).toBeGreaterThan(1); | ||
| expect(labelRect.height).toBeGreaterThan(1); | ||
|
|
||
| if (supportsAnchorPositioning()) { | ||
| expect(covers(inputRect, labelRect)).toBe(true); | ||
| expect(inputRect.width).toBeLessThanOrEqual(labelRect.width + 2); | ||
| expect(inputRect.height).toBeLessThanOrEqual(labelRect.height + 2); | ||
| } | ||
| }); | ||
|
|
||
| it('the hidden input respects a custom anchor-name provided via CSS', async () => { | ||
| let screen = await render( | ||
| <Checkbox style={{position: 'relative', ['anchorName' as any]: '--custom-anchor'}}> | ||
| Test | ||
| </Checkbox> | ||
| ); | ||
|
|
||
| let input = screen.container.querySelector('input')!; | ||
| let label = screen.container.querySelector('label')!; | ||
|
|
||
| if (supportsAnchorPositioning()) { | ||
| expect(getComputedStyle(label).getPropertyValue('anchor-name').trim()).toBe('--custom-anchor'); | ||
| expect(getComputedStyle(input).getPropertyValue('position-anchor').trim()).toBe( | ||
| '--custom-anchor' | ||
| ); | ||
|
|
||
| let labelRect = rect(label); | ||
| let inputRect = rect(input); | ||
| expect(covers(inputRect, labelRect)).toBe(true); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
most of this can be applied with css instead during render instead of in a layouteffect and most of it can be applied unconditionally since if it's not supported, it won't do anything