diff --git a/packages/@headlessui-react/src/components/checkbox/checkbox.test.tsx b/packages/@headlessui-react/src/components/checkbox/checkbox.test.tsx
index 4aff6769e..f15fbc660 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 f13a6a7a7..206a93734 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 aae4ef385..5a1d4ecb7 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'
}