Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Nothing yet!
### Fixed

- Don't render the `for` attribute on a `Label` when the linked control is not a labelable element (e.g.: `Radio`, `Checkbox`) ([#3883](https://github.com/tailwindlabs/headlessui/issues/3883))

## [2.2.10] - 2026-04-07

Expand Down
68 changes: 67 additions & 1 deletion packages/@headlessui-react/src/components/label/label.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { render } from '@testing-library/react'
import React, { type ReactNode } from 'react'
import React, { useState, type ReactNode } from 'react'
import { assertActiveElement } from '../../test-utils/accessibility-assertions'
import { click } from '../../test-utils/interactions'
import { Field } from '../field/field'
import { Input } from '../input/input'
import { Radio, RadioGroup } from '../radio-group/radio-group'
import { Label, useLabels } from './label'

jest.mock('../../hooks/use-id')
Expand Down Expand Up @@ -71,3 +76,64 @@ it('should be possible to use a LabelProvider and multiple Label components, and
let { container } = render(<Example />)
expect(container.firstChild).toMatchSnapshot()
})

it('should render a `for` attribute on the `Label` when it is linked to a labelable element', async () => {
let { container } = render(
<Field>
<Label>Name</Label>
<Input />
</Field>
)

let label = container.querySelector('label')
let input = container.querySelector('input')

expect(label).toHaveAttribute('for', input?.id)
})

it('should not render a `for` attribute on the `Label` when it is linked to a non-labelable element (e.g.: a `Radio`)', async () => {
let { container } = render(
<RadioGroup value="a" onChange={() => {}}>
<Field>
<Radio value="a" />
<Label>A</Label>
</Field>
</RadioGroup>
)

let label = container.querySelector('label')
let radio = container.querySelector('[role="radio"]')

expect(label).not.toHaveAttribute('for')

// The `Label` should still be linked to the `Radio` via `aria-labelledby`
expect(radio).toHaveAttribute('aria-labelledby', label?.id)
})

it('should still proxy clicks on the `Label` to the linked `Radio` without a `for` attribute', async () => {
function Example() {
let [value, setValue] = useState<string | null>(null)

return (
<RadioGroup value={value} onChange={setValue}>
<Field>
<Radio value="a" />
<Label>A</Label>
</Field>
</RadioGroup>
)
}

let { container } = render(<Example />)

let label = container.querySelector('label')!
let radio = container.querySelector('[role="radio"]') as HTMLElement

expect(label).not.toHaveAttribute('for')

await click(label)

// The `Radio` received focus, which means the `Label` was still able to
// resolve the linked control even without a rendered `for` attribute.
assertActiveElement(radio)
})
22 changes: 19 additions & 3 deletions packages/@headlessui-react/src/components/label/label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,22 @@ function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(

useIsoMorphicEffect(() => context.register(id), [id, context.register])

// The `for` attribute is only valid on labelable elements
// (https://html.spec.whatwg.org/#category-label). Some components (e.g.:
// `Radio` and `Checkbox`) render a non-labelable element (e.g.: a `span`)
// under the hood, in which case we shouldn't render the `for` attribute at
// all. Once we know which element the `for` attribute points to, we can drop
// the attribute if that element turns out to not be labelable.
let [renderHtmlFor, setRenderHtmlFor] = useState(true)
useIsoMorphicEffect(() => {
if (!htmlFor) return

let target = document.getElementById(htmlFor)
if (!target) return

setRenderHtmlFor(DOM.isLabelableElement(target))
}, [htmlFor])

let handleClick = useEvent((e: ReactMouseEvent) => {
let current = e.currentTarget

Expand Down Expand Up @@ -169,8 +185,8 @@ function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
context.props.onClick(e)
}

if (DOM.isHTMLLabelElement(current)) {
let target = document.getElementById(current.htmlFor)
if (DOM.isHTMLLabelElement(current) && htmlFor) {
let target = document.getElementById(htmlFor)
if (target) {
// Bail if the target element is disabled
let actuallyDisabled = target.getAttribute('disabled')
Expand Down Expand Up @@ -209,7 +225,7 @@ function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
ref: labelRef,
...context.props,
id,
htmlFor,
htmlFor: renderHtmlFor ? htmlFor : undefined,
onClick: handleClick,
}

Expand Down
14 changes: 14 additions & 0 deletions packages/@headlessui-react/src/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,17 @@ export function isInteractiveElement(element: unknown): element is Element {
'a[href],audio[controls],button,details,embed,iframe,img[usemap],input:not([type="hidden"]),label,select,textarea,video[controls]'
)
}

// https://html.spec.whatwg.org/#category-label
// - button
// - input (if the type attribute is not in the Hidden state)
// - meter
// - output
// - progress
// - select
// - textarea
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')
}