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]'
)
}
+12 -1
View File
@@ -167,6 +167,8 @@ export default function App() {
</Section>
<Section title="Listbox">
<div className="w-full space-y-1">
<Field>
<Label>Assigned to:</Label>
<Listbox name="person" defaultValue={people[1]}>
{({ value }) => (
<>
@@ -243,10 +245,13 @@ export default function App() {
</>
)}
</Listbox>
</Field>
</div>
</Section>
<Section title="Combobox">
<div className="w-full space-y-1">
<Field>
<Label>Location:</Label>
<Combobox
name="location"
defaultValue={'New York'}
@@ -330,6 +335,7 @@ export default function App() {
)
}}
</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">