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
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Don’t render `<Portal>` while hydrating ([#3825](https://github.com/tailwindlabs/headlessui/pull/3825))
- Ensure a `span` is used for non-labelable elements when using `Label` component ([#3831](https://github.com/tailwindlabs/headlessui/pull/3831))

## [2.2.9] - 2025-09-25

Expand Down
39 changes: 39 additions & 0 deletions packages/@headlessui-react/src/components/label/label.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { render } from '@testing-library/react'
import React, { type ReactNode } from 'react'
import {
CheckboxState,
assertActiveElement,
assertCheckbox,
assertLinkedWithLabel,
getCheckbox,
getLabel,
} from '../../test-utils/accessibility-assertions'
import { click } from '../../test-utils/interactions'
import { Checkbox } from '../checkbox/checkbox'
import { Field } from '../field/field'
import { Label, useLabels } from './label'

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

it('should be possible to use a Label with a non labelablea element', async () => {
function Example() {
return (
<Field>
<Label>Accept terms and conditions</Label>
<Checkbox />
</Field>
)
}

render(<Example />)

// Ensure the label is linked to the checkbox
assertLinkedWithLabel(getCheckbox(), getLabel()!)

// Ensure the checkbox is not checked
assertCheckbox({ state: CheckboxState.Unchecked })

// Ensure we can click on the label
await click(getLabel())

// Ensure the checkbox was toggled
assertCheckbox({ state: CheckboxState.Checked })

// Ensure focus is moved to the checkbox
assertActiveElement(getCheckbox())
})
54 changes: 40 additions & 14 deletions packages/@headlessui-react/src/components/label/label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import React, {
import { useEvent } from '../../hooks/use-event'
import { useId } from '../../hooks/use-id'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
import { useSlot } from '../../hooks/use-slot'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useDisabled } from '../../internal/disabled'
Expand Down Expand Up @@ -126,6 +127,7 @@ function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
passive = false,
...theirProps
} = props
let isLabelableElement = useIsLabelableElementById(htmlFor)
let labelRef = useSyncRefs(ref)

useIsoMorphicEffect(() => context.register(id), [id, context.register])
Expand Down Expand Up @@ -153,13 +155,6 @@ function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
return
}

// Labels connected to 'real' controls will already click the element. But we don't know that
// ahead of time. This will prevent the default click, such that only a single click happens
// instead of two. Otherwise this results in a visual no-op.
if (DOM.isHTMLLabelElement(current)) {
e.preventDefault()
}

// Ensure `onClick` from context is called
if (
context.props &&
Expand All @@ -169,8 +164,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)) {
let target = document.getElementById(htmlFor)
if (target) {
// Bail if the target element is disabled
let actuallyDisabled = target.getAttribute('disabled')
Expand All @@ -186,12 +181,13 @@ function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
// 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).
let role = target.role || target.getAttribute('role')
if (
(DOM.isHTMLInputElement(target) &&
(target.type === 'file' || target.type === 'radio' || target.type === 'checkbox')) ||
target.role === 'radio' ||
target.role === 'checkbox' ||
target.role === 'switch'
role === 'radio' ||
role === 'checkbox' ||
role === 'switch'
) {
target.click()
}
Expand All @@ -209,7 +205,7 @@ function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
ref: labelRef,
...context.props,
id,
htmlFor,
htmlFor: isLabelableElement ? htmlFor : undefined,
onClick: handleClick,
}

Expand All @@ -230,7 +226,7 @@ function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
ourProps,
theirProps,
slot,
defaultTag: htmlFor ? DEFAULT_LABEL_TAG : 'div',
defaultTag: htmlFor ? (isLabelableElement ? DEFAULT_LABEL_TAG : 'span') : 'div',
name: context.name || 'Label',
})
}
Expand All @@ -248,3 +244,33 @@ let LabelRoot = forwardRefWithAs(LabelFn) as _internal_ComponentLabel
export let Label = Object.assign(LabelRoot, {
//
})

function useIsLabelableElementById(id: string | undefined): boolean {
let ready = useServerHandoffComplete()
return ready && isLabelableElementById(id)
}

// See: https://html.spec.whatwg.org/multipage/forms.html#category-label
function isLabelableElementById(id: string | undefined): boolean {
if (id === undefined) return false
if (typeof window === 'undefined') return false

let element = document.getElementById(id)
if (!element) return false

if (element.tagName === 'BUTTON') return true
if (DOM.isHTMLInputElement(element) && element.type !== 'hidden') return true
if (element.tagName === 'METER') return true
if (element.tagName === 'OUTPUT') return true
if (element.tagName === 'PROGRESS') return true
if (element.tagName === 'SELECT') return true
if (element.tagName === 'TEXTAREA') return true

// @ts-expect-error If a custom element exist and is form associated, it will
// have a static property `formAssociated` on its class definition.
if (window.customElements.get(element.tagName.toLowerCase())?.formAssociated) {
return true
}

return false
}
17 changes: 16 additions & 1 deletion playgrounds/react/pages/combinations/form.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { Combobox, Field, Input, Label, Listbox, RadioGroup, Switch } from '@headlessui/react'
import {
Checkbox,
Combobox,
Field,
Input,
Label,
Listbox,
RadioGroup,
Switch,
} from '@headlessui/react'
import { useState } from 'react'
import { Button } from '../../components/button'
import { classNames } from '../../utils/class-names'
Expand Down Expand Up @@ -112,6 +121,12 @@ export default function App() {
</Switch.Group>
</Section>
</Section>
<Section title="Checkbox">
<Field className="flex items-center gap-2 p-1">
<Checkbox className="data-checked:bg-blue-600 size-4 border bg-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" />
<Label>Label for {'<Checkbox />'}</Label>
</Field>
</Section>
<Section title="Radio Group">
<RadioGroup defaultValue="sm" name="size">
<div className="flex -space-x-px rounded-md bg-white">
Expand Down
Loading