diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 75f326771a..5531bc54a6 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Don’t render `` 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 diff --git a/packages/@headlessui-react/src/components/label/label.test.tsx b/packages/@headlessui-react/src/components/label/label.test.tsx index 8f6718c31b..d5acd28488 100644 --- a/packages/@headlessui-react/src/components/label/label.test.tsx +++ b/packages/@headlessui-react/src/components/label/label.test.tsx @@ -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') @@ -71,3 +82,31 @@ it('should be possible to use a LabelProvider and multiple Label components, and let { container } = render() expect(container.firstChild).toMatchSnapshot() }) + +it('should be possible to use a Label with a non labelablea element', async () => { + function Example() { + return ( + + + + + ) + } + + render() + + // 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()) +}) diff --git a/packages/@headlessui-react/src/components/label/label.tsx b/packages/@headlessui-react/src/components/label/label.tsx index f13a6a7a73..74d5bbb093 100644 --- a/packages/@headlessui-react/src/components/label/label.tsx +++ b/packages/@headlessui-react/src/components/label/label.tsx @@ -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' @@ -126,6 +127,7 @@ function LabelFn( passive = false, ...theirProps } = props + let isLabelableElement = useIsLabelableElementById(htmlFor) let labelRef = useSyncRefs(ref) useIsoMorphicEffect(() => context.register(id), [id, context.register]) @@ -153,13 +155,6 @@ function LabelFn( 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 && @@ -169,8 +164,8 @@ function LabelFn( 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') @@ -186,12 +181,13 @@ function LabelFn( // 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() } @@ -209,7 +205,7 @@ function LabelFn( ref: labelRef, ...context.props, id, - htmlFor, + htmlFor: isLabelableElement ? htmlFor : undefined, onClick: handleClick, } @@ -230,7 +226,7 @@ function LabelFn( ourProps, theirProps, slot, - defaultTag: htmlFor ? DEFAULT_LABEL_TAG : 'div', + defaultTag: htmlFor ? (isLabelableElement ? DEFAULT_LABEL_TAG : 'span') : 'div', name: context.name || 'Label', }) } @@ -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 +} diff --git a/playgrounds/react/pages/combinations/form.tsx b/playgrounds/react/pages/combinations/form.tsx index 0244a67c51..61a5b8e822 100644 --- a/playgrounds/react/pages/combinations/form.tsx +++ b/playgrounds/react/pages/combinations/form.tsx @@ -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' @@ -112,6 +121,12 @@ export default function App() { +
+ + + + +