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

This PR fixes an issue where clicking on an interactive element _inside_
of a `<Label>` component should work as expected.

For example, if you have this situation:

```html
<label for="tac">
  <input id="tac" type="checkbox" name="terms-and-conditions" />
  I agree to the <a href="terms-and-conditions.html">Terms and Conditions</a>
</label>
```

Clicking on the `<a href="#">` inside the label should _not_ check the
checkbox, but should open the link instead.

Fixes: #3658
This commit is contained in:
Robin Malfait
2025-04-25 13:48:13 +02:00
committed by GitHub
parent ca05e7c0ee
commit 3e3f45df81
3 changed files with 198 additions and 145 deletions
@@ -17,6 +17,7 @@ import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useDisabled } from '../../internal/disabled' import { useDisabled } from '../../internal/disabled'
import { useProvidedId } from '../../internal/id' import { useProvidedId } from '../../internal/id'
import type { Props } from '../../types' import type { Props } from '../../types'
import * as DOM from '../../utils/dom'
import { forwardRefWithAs, useRender, type HasDisplayName, type RefProp } from '../../utils/render' import { forwardRefWithAs, useRender, type HasDisplayName, type RefProp } from '../../utils/render'
// --- // ---
@@ -131,6 +132,26 @@ function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
let handleClick = useEvent((e: ReactMouseEvent) => { let handleClick = useEvent((e: ReactMouseEvent) => {
let current = e.currentTarget 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 // 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 // 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. // instead of two. Otherwise this results in a visual no-op.
@@ -19,3 +19,24 @@ export function isHTMLElement(element: unknown): element is HTMLElement {
if (element === null) return false if (element === null) return false
return 'nodeName' in element 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]'
)
}
+156 -145
View File
@@ -167,169 +167,175 @@ export default function App() {
</Section> </Section>
<Section title="Listbox"> <Section title="Listbox">
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<Listbox name="person" defaultValue={people[1]}> <Field>
{({ value }) => ( <Label>Assigned to:</Label>
<> <Listbox name="person" defaultValue={people[1]}>
<div className="relative"> {({ value }) => (
<Listbox.Button as={Button} className="w-full"> <>
<span className="block truncate">{value?.name?.first}</span> <div className="relative">
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <Listbox.Button as={Button} className="w-full">
<svg <span className="block truncate">{value?.name?.first}</span>
className="h-5 w-5 text-gray-400" <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
viewBox="0 0 20 20" <svg
fill="none" className="h-5 w-5 text-gray-400"
stroke="currentColor" viewBox="0 0 20 20"
> fill="none"
<path stroke="currentColor"
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 }) => ( <path
<> d="M7 7l3-3 3 3m0 6l-3 3-3-3"
<span strokeWidth="1.5"
className={classNames( strokeLinecap="round"
'block truncate', strokeLinejoin="round"
selected ? 'font-semibold' : 'font-normal' />
)} </svg>
> </span>
{person.name.first} </Listbox.Button>
</span>
{selected && ( <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 <span
className={classNames( className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4', 'block truncate',
active ? 'text-white' : 'text-blue-600' selected ? 'font-semibold' : 'font-normal'
)} )}
> >
<svg {person.name.first}
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> </span>
)} {selected && (
</> <span
)} className={classNames(
</Listbox.Option> 'absolute inset-y-0 right-0 flex items-center pr-4',
))} active ? 'text-white' : 'text-blue-600'
</Listbox.Options> )}
>
<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>
</div> </>
</> )}
)} </Listbox>
</Listbox> </Field>
</div> </div>
</Section> </Section>
<Section title="Combobox"> <Section title="Combobox">
<div className="w-full space-y-1"> <div className="w-full space-y-1">
<Combobox <Field>
name="location" <Label>Location:</Label>
defaultValue={'New York'} <Combobox
onChange={(location) => { name="location"
setQuery('') defaultValue={'New York'}
}} onChange={(location) => {
> setQuery('')
{({ open, value }) => { }}
return ( >
<div className="relative"> {({ open, value }) => {
<div className="flex w-full flex-col"> return (
<Combobox.Input <div className="relative">
onChange={(e) => setQuery(e.target.value)} <div className="flex w-full flex-col">
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" <Combobox.Input
placeholder="Search users..." 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"
<div placeholder="Search users..."
className={classNames( />
'flex border-t', <div
value && !open ? 'border-transparent' : 'border-gray-200' 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 <div className="absolute z-10 mt-1 w-full rounded-md bg-white shadow-lg">
.filter((location) => <Combobox.Options className="shadow-2xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 sm:text-sm sm:leading-5">
location.toLowerCase().includes(query.toLowerCase()) {locations
) .filter((location) =>
.map((location) => ( location.toLowerCase().includes(query.toLowerCase())
<Combobox.Option )
key={location} .map((location) => (
value={location} <Combobox.Option
className={({ active }) => { key={location}
return classNames( value={location}
'relative flex cursor-default select-none space-x-4 py-2 pl-3 pr-9', className={({ active }) => {
active ? 'bg-blue-600 text-white' : 'text-gray-900' 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 {({ active, selected }) => (
className={classNames( <>
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
{location}
</span>
{active && (
<span <span
className={classNames( className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4', 'block truncate',
active ? 'text-white' : 'text-blue-600' selected ? 'font-semibold' : 'font-normal'
)} )}
> >
<svg {location}
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> </span>
)} {active && (
</> <span
)} className={classNames(
</Combobox.Option> 'absolute inset-y-0 right-0 flex items-center pr-4',
))} active ? 'text-white' : 'text-blue-600'
</Combobox.Options> )}
>
<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>
</div> </div>
</div> )
) }}
}} </Combobox>
</Combobox> </Field>
</div> </div>
</Section> </Section>
<Section title="Default form controls"> <Section title="Default form controls">
@@ -338,7 +344,12 @@ export default function App() {
<Input type="text" /> <Input type="text" />
</Field> </Field>
<Field className="flex flex-col p-1"> <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" /> <Input type="checkbox" />
</Field> </Field>
<Field className="flex flex-col p-1"> <Field className="flex flex-col p-1">