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:
@@ -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]'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user