Skip to content

Ensure clicking on interactive elements inside <Label> works #3709

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 25, 2025
Merged
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
21 changes: 21 additions & 0 deletions packages/@headlessui-react/src/components/label/label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useDisabled } from '../../internal/disabled'
import { useProvidedId } from '../../internal/id'
import type { Props } from '../../types'
import * as DOM from '../../utils/dom'
import { forwardRefWithAs, useRender, type HasDisplayName, type RefProp } from '../../utils/render'

// ---
Expand Down Expand Up @@ -131,6 +132,26 @@ function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
let handleClick = useEvent((e: ReactMouseEvent) => {
let current = e.currentTarget

// If a click happens on an interactive element inside of the label, then we
// don't want to trigger the label behavior and let the browser handle the
// click event.
//
// In a situation like:
//
// ```html
// <label>
// I accept the
// <a href="#">terms and agreement</a>
// <input type="checkbox" />
// </label>
// ```
//
// Clicking on the link, should not check the checkbox, but open the link
// instead.
if (e.target !== e.currentTarget && DOM.isInteractiveElement(e.target)) {
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.
Expand Down
21 changes: 21 additions & 0 deletions packages/@headlessui-react/src/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,24 @@ export function isHTMLElement(element: unknown): element is HTMLElement {
if (element === null) return false
return 'nodeName' in element
}

// https://html.spec.whatwg.org/#interactive-content-2
// - a (if the href attribute is present)
// - audio (if the controls attribute is present)
// - button
// - details
// - embed
// - iframe
// - img (if the usemap attribute is present)
// - input (if the type attribute is not in the Hidden state)
// - label
// - select
// - textarea
// - video (if the controls attribute is present)
export function isInteractiveElement(element: unknown): element is Element {
if (!isHTMLElement(element)) return false

return element.matches(
'a[href],audio[controls],button,details,embed,iframe,img[usemap],input:not([type="hidden"]),label,select,textarea,video[controls]'
)
}
301 changes: 156 additions & 145 deletions playgrounds/react/pages/combinations/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,169 +167,175 @@ export default function App() {
</Section>
<Section title="Listbox">
<div className="w-full space-y-1">
<Listbox name="person" defaultValue={people[1]}>
{({ value }) => (
<>
<div className="relative">
<Listbox.Button as={Button} className="w-full">
<span className="block truncate">{value?.name?.first}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
className="h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
>
<path
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</Listbox.Button>

<div className="absolute z-10 mt-1 w-full rounded-md bg-white shadow-lg">
<Listbox.Options className="shadow-2xs focus:outline-hidden max-h-60 overflow-auto rounded-md py-1 text-base leading-6 sm:text-sm sm:leading-5">
{people.map((person) => (
<Listbox.Option
key={person.id}
value={person}
className={({ active }) => {
return classNames(
'relative cursor-default select-none py-2 pl-3 pr-9',
active ? 'bg-blue-600 text-white' : 'text-gray-900'
)
}}
<Field>
<Label>Assigned to:</Label>
<Listbox name="person" defaultValue={people[1]}>
{({ value }) => (
<>
<div className="relative">
<Listbox.Button as={Button} className="w-full">
<span className="block truncate">{value?.name?.first}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
className="h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
>
{({ active, selected }) => (
<>
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
{person.name.first}
</span>
{selected && (
<path
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</Listbox.Button>

<div className="absolute z-10 mt-1 w-full rounded-md bg-white shadow-lg">
<Listbox.Options className="shadow-2xs focus:outline-hidden max-h-60 overflow-auto rounded-md py-1 text-base leading-6 sm:text-sm sm:leading-5">
{people.map((person) => (
<Listbox.Option
key={person.id}
value={person}
className={({ active }) => {
return classNames(
'relative cursor-default select-none py-2 pl-3 pr-9',
active ? 'bg-blue-600 text-white' : 'text-gray-900'
)
}}
>
{({ active, selected }) => (
<>
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600'
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
<svg
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
{person.name.first}
</span>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
{selected && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600'
)}
>
<svg
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</div>
</div>
</div>
</>
)}
</Listbox>
</>
)}
</Listbox>
</Field>
</div>
</Section>
<Section title="Combobox">
<div className="w-full space-y-1">
<Combobox
name="location"
defaultValue={'New York'}
onChange={(location) => {
setQuery('')
}}
>
{({ open, value }) => {
return (
<div className="relative">
<div className="flex w-full flex-col">
<Combobox.Input
onChange={(e) => setQuery(e.target.value)}
className="shadow-xs focus:outline-hidden w-full rounded-md rounded-sm border-gray-300 bg-clip-padding px-3 py-1 focus:border-gray-300 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
placeholder="Search users..."
/>
<div
className={classNames(
'flex border-t',
value && !open ? 'border-transparent' : 'border-gray-200'
)}
>
<div className="absolute z-10 mt-1 w-full rounded-md bg-white shadow-lg">
<Combobox.Options className="shadow-2xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 sm:text-sm sm:leading-5">
{locations
.filter((location) =>
location.toLowerCase().includes(query.toLowerCase())
)
.map((location) => (
<Combobox.Option
key={location}
value={location}
className={({ active }) => {
return classNames(
'relative flex cursor-default select-none space-x-4 py-2 pl-3 pr-9',
active ? 'bg-blue-600 text-white' : 'text-gray-900'
)
}}
>
{({ active, selected }) => (
<>
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
{location}
</span>
{active && (
<Field>
<Label>Location:</Label>
<Combobox
name="location"
defaultValue={'New York'}
onChange={(location) => {
setQuery('')
}}
>
{({ open, value }) => {
return (
<div className="relative">
<div className="flex w-full flex-col">
<Combobox.Input
onChange={(e) => setQuery(e.target.value)}
className="shadow-xs focus:outline-hidden w-full rounded-md rounded-sm border-gray-300 bg-clip-padding px-3 py-1 focus:border-gray-300 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
placeholder="Search users..."
/>
<div
className={classNames(
'flex border-t',
value && !open ? 'border-transparent' : 'border-gray-200'
)}
>
<div className="absolute z-10 mt-1 w-full rounded-md bg-white shadow-lg">
<Combobox.Options className="shadow-2xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 sm:text-sm sm:leading-5">
{locations
.filter((location) =>
location.toLowerCase().includes(query.toLowerCase())
)
.map((location) => (
<Combobox.Option
key={location}
value={location}
className={({ active }) => {
return classNames(
'relative flex cursor-default select-none space-x-4 py-2 pl-3 pr-9',
active ? 'bg-blue-600 text-white' : 'text-gray-900'
)
}}
>
{({ active, selected }) => (
<>
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600'
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
<svg
className="h-5 w-5"
viewBox="0 0 25 24"
fill="none"
>
<path
d="M11.25 8.75L14.75 12L11.25 15.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{location}
</span>
)}
</>
)}
</Combobox.Option>
))}
</Combobox.Options>
{active && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600'
)}
>
<svg
className="h-5 w-5"
viewBox="0 0 25 24"
fill="none"
>
<path
d="M11.25 8.75L14.75 12L11.25 15.25"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
)}
</>
)}
</Combobox.Option>
))}
</Combobox.Options>
</div>
</div>
</div>
</div>
</div>
)
}}
</Combobox>
)
}}
</Combobox>
</Field>
</div>
</Section>
<Section title="Default form controls">
Expand All @@ -338,7 +344,12 @@ export default function App() {
<Input type="text" />
</Field>
<Field className="flex flex-col p-1">
<Label>Label for {'<Input type="checkbox">'}</Label>
<Label>
I agree to the{' '}
<a href="https://google.com" target="_blank" className="underline">
terms and conditions
</a>
</Label>
<Input type="checkbox" />
</Field>
<Field className="flex flex-col p-1">
Expand Down