From 13510e3fcfbc4b4361fd21297bb7958a666467b8 Mon Sep 17 00:00:00 2001 From: cyphercodes Date: Fri, 22 May 2026 06:49:53 +0300 Subject: [PATCH] Fix checkbox field label htmlFor --- .../src/components/checkbox/checkbox.test.tsx | 27 +++++++++++++++++++ .../src/components/label/label.tsx | 26 +++++++++++++----- packages/@headlessui-react/src/utils/dom.ts | 7 +++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/packages/@headlessui-react/src/components/checkbox/checkbox.test.tsx b/packages/@headlessui-react/src/components/checkbox/checkbox.test.tsx index 4aff6769e4..f15fbc6604 100644 --- a/packages/@headlessui-react/src/components/checkbox/checkbox.test.tsx +++ b/packages/@headlessui-react/src/components/checkbox/checkbox.test.tsx @@ -12,6 +12,8 @@ import { commonRenderingScenarios, } from '../../test-utils/scenarios' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' +import { Field } from '../field/field' +import { Label } from '../label/label' import { Checkbox, type CheckboxProps } from './checkbox' commonRenderingScenarios(Checkbox, { getElement: getCheckbox }) @@ -121,6 +123,31 @@ describe.each([ }) describe('Form submissions', () => { + it('should omit invalid label `for` attributes in a Field while preserving label interaction', async () => { + let { container, getByText } = render( +
+ + + + +
+ ) + + let label = getByText('Accept terms') + let checkbox = getCheckbox() + + expect(label).not.toHaveAttribute('for') + expect(checkbox).toHaveAttribute('aria-labelledby', label.id) + + await click(label) + + assertCheckbox({ state: CheckboxState.Checked }) + expect(document.activeElement).toBe(checkbox) + expect(Object.fromEntries(new FormData(container.querySelector('form')!))).toEqual({ + terms: 'on', + }) + }) + it('should be possible to use in an uncontrolled way', async () => { let handleSubmission = jest.fn() diff --git a/packages/@headlessui-react/src/components/label/label.tsx b/packages/@headlessui-react/src/components/label/label.tsx index f13a6a7a73..206a93734e 100644 --- a/packages/@headlessui-react/src/components/label/label.tsx +++ b/packages/@headlessui-react/src/components/label/label.tsx @@ -122,14 +122,25 @@ function LabelFn( let providedDisabled = useDisabled() let { id = `headlessui-label-${internalId}`, - htmlFor = providedHtmlFor ?? context.props?.htmlFor, + htmlFor: theirHtmlFor, passive = false, ...theirProps } = props + let htmlFor = theirHtmlFor ?? providedHtmlFor ?? context.props?.htmlFor + let [targetIsLabelable, setTargetIsLabelable] = useState(false) let labelRef = useSyncRefs(ref) useIsoMorphicEffect(() => context.register(id), [id, context.register]) + useIsoMorphicEffect(() => { + if (theirHtmlFor != null) return + + let target = htmlFor ? document.getElementById(htmlFor) : null + let isLabelable = target ? DOM.isLabelableElement(target) : false + + setTargetIsLabelable((current) => (current === isLabelable ? current : isLabelable)) + }, [htmlFor, theirHtmlFor]) + let handleClick = useEvent((e: ReactMouseEvent) => { let current = e.currentTarget @@ -170,7 +181,7 @@ function LabelFn( } if (DOM.isHTMLLabelElement(current)) { - let target = document.getElementById(current.htmlFor) + let target = htmlFor ? document.getElementById(htmlFor) : null if (target) { // Bail if the target element is disabled let actuallyDisabled = target.getAttribute('disabled') @@ -183,15 +194,17 @@ function LabelFn( return } + let targetRole = target.getAttribute('role') + // Ensure we click the element this label is bound to. This is necessary for elements that // immediately require state changes, e.g.: Radio & Checkbox inputs need to be checked (or // unchecked). if ( (DOM.isHTMLInputElement(target) && (target.type === 'file' || target.type === 'radio' || target.type === 'checkbox')) || - target.role === 'radio' || - target.role === 'checkbox' || - target.role === 'switch' + targetRole === 'radio' || + targetRole === 'checkbox' || + targetRole === 'switch' ) { target.click() } @@ -204,12 +217,13 @@ function LabelFn( }) let slot = useSlot({ ...context.slot, disabled: providedDisabled || false }) + let shouldRenderHtmlFor = theirHtmlFor != null || targetIsLabelable let ourProps = { ref: labelRef, ...context.props, id, - htmlFor, + htmlFor: shouldRenderHtmlFor ? htmlFor : undefined, onClick: handleClick, } diff --git a/packages/@headlessui-react/src/utils/dom.ts b/packages/@headlessui-react/src/utils/dom.ts index aae4ef3857..5a1d4ecb7e 100644 --- a/packages/@headlessui-react/src/utils/dom.ts +++ b/packages/@headlessui-react/src/utils/dom.ts @@ -51,6 +51,13 @@ export function isHTMLLabelElement(element: unknown): element is HTMLLabelElemen return isHTMLElement(element) && element.nodeName === 'LABEL' } +// https://html.spec.whatwg.org/multipage/forms.html#category-label +export function isLabelableElement(element: unknown): element is Element { + if (!isElement(element)) return false + + return element.matches('button,input:not([type="hidden"]),meter,output,progress,select,textarea') +} + export function isHTMLFieldSetElement(element: unknown): element is HTMLFieldSetElement { return isHTMLElement(element) && element.nodeName === 'FIELDSET' }