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 { 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'
// ---
@@ -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.
@@ -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]'
)
}
+156 -145
View File
@@ -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">
@@ -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">