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
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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(
<form>
<Field>
<Label>Accept terms</Label>
<Checkbox name="terms" />
</Field>
</form>
)

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()

Expand Down
26 changes: 20 additions & 6 deletions packages/@headlessui-react/src/components/label/label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,25 @@ function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
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

Expand Down Expand Up @@ -170,7 +181,7 @@ function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
}

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')
Expand All @@ -183,15 +194,17 @@ function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
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()
}
Expand All @@ -204,12 +217,13 @@ function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
})

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,
}

Expand Down
7 changes: 7 additions & 0 deletions packages/@headlessui-react/src/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down