Skip to content

Commit 3e3f45d

Browse files
authored
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
1 parent ca05e7c commit 3e3f45d

File tree

3 files changed

+198
-145
lines changed

3 files changed

+198
-145
lines changed

packages/@headlessui-react/src/components/label/label.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useSyncRefs } from '../../hooks/use-sync-refs'
1717
import { useDisabled } from '../../internal/disabled'
1818
import { useProvidedId } from '../../internal/id'
1919
import type { Props } from '../../types'
20+
import * as DOM from '../../utils/dom'
2021
import { forwardRefWithAs, useRender, type HasDisplayName, type RefProp } from '../../utils/render'
2122

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

135+
// If a click happens on an interactive element inside of the label, then we
136+
// don't want to trigger the label behavior and let the browser handle the
137+
// click event.
138+
//
139+
// In a situation like:
140+
//
141+
// ```html
142+
// <label>
143+
// I accept the
144+
// <a href="#">terms and agreement</a>
145+
// <input type="checkbox" />
146+
// </label>
147+
// ```
148+
//
149+
// Clicking on the link, should not check the checkbox, but open the link
150+
// instead.
151+
if (e.target !== e.currentTarget && DOM.isInteractiveElement(e.target)) {
152+
return
153+
}
154+
134155
// Labels connected to 'real' controls will already click the element. But we don't know that
135156
// ahead of time. This will prevent the default click, such that only a single click happens
136157
// instead of two. Otherwise this results in a visual no-op.

packages/@headlessui-react/src/utils/dom.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,24 @@ export function isHTMLElement(element: unknown): element is HTMLElement {
1919
if (element === null) return false
2020
return 'nodeName' in element
2121
}
22+
23+
// https://html.spec.whatwg.org/#interactive-content-2
24+
// - a (if the href attribute is present)
25+
// - audio (if the controls attribute is present)
26+
// - button
27+
// - details
28+
// - embed
29+
// - iframe
30+
// - img (if the usemap attribute is present)
31+
// - input (if the type attribute is not in the Hidden state)
32+
// - label
33+
// - select
34+
// - textarea
35+
// - video (if the controls attribute is present)
36+
export function isInteractiveElement(element: unknown): element is Element {
37+
if (!isHTMLElement(element)) return false
38+
39+
return element.matches(
40+
'a[href],audio[controls],button,details,embed,iframe,img[usemap],input:not([type="hidden"]),label,select,textarea,video[controls]'
41+
)
42+
}

playgrounds/react/pages/combinations/form.tsx

Lines changed: 156 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -167,169 +167,175 @@ export default function App() {
167167
</Section>
168168
<Section title="Listbox">
169169
<div className="w-full space-y-1">
170-
<Listbox name="person" defaultValue={people[1]}>
171-
{({ value }) => (
172-
<>
173-
<div className="relative">
174-
<Listbox.Button as={Button} className="w-full">
175-
<span className="block truncate">{value?.name?.first}</span>
176-
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
177-
<svg
178-
className="h-5 w-5 text-gray-400"
179-
viewBox="0 0 20 20"
180-
fill="none"
181-
stroke="currentColor"
182-
>
183-
<path
184-
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
185-
strokeWidth="1.5"
186-
strokeLinecap="round"
187-
strokeLinejoin="round"
188-
/>
189-
</svg>
190-
</span>
191-
</Listbox.Button>
192-
193-
<div className="absolute z-10 mt-1 w-full rounded-md bg-white shadow-lg">
194-
<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">
195-
{people.map((person) => (
196-
<Listbox.Option
197-
key={person.id}
198-
value={person}
199-
className={({ active }) => {
200-
return classNames(
201-
'relative cursor-default select-none py-2 pl-3 pr-9',
202-
active ? 'bg-blue-600 text-white' : 'text-gray-900'
203-
)
204-
}}
170+
<Field>
171+
<Label>Assigned to:</Label>
172+
<Listbox name="person" defaultValue={people[1]}>
173+
{({ value }) => (
174+
<>
175+
<div className="relative">
176+
<Listbox.Button as={Button} className="w-full">
177+
<span className="block truncate">{value?.name?.first}</span>
178+
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
179+
<svg
180+
className="h-5 w-5 text-gray-400"
181+
viewBox="0 0 20 20"
182+
fill="none"
183+
stroke="currentColor"
205184
>
206-
{({ active, selected }) => (
207-
<>
208-
<span
209-
className={classNames(
210-
'block truncate',
211-
selected ? 'font-semibold' : 'font-normal'
212-
)}
213-
>
214-
{person.name.first}
215-
</span>
216-
{selected && (
185+
<path
186+
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
187+
strokeWidth="1.5"
188+
strokeLinecap="round"
189+
strokeLinejoin="round"
190+
/>
191+
</svg>
192+
</span>
193+
</Listbox.Button>
194+
195+
<div className="absolute z-10 mt-1 w-full rounded-md bg-white shadow-lg">
196+
<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">
197+
{people.map((person) => (
198+
<Listbox.Option
199+
key={person.id}
200+
value={person}
201+
className={({ active }) => {
202+
return classNames(
203+
'relative cursor-default select-none py-2 pl-3 pr-9',
204+
active ? 'bg-blue-600 text-white' : 'text-gray-900'
205+
)
206+
}}
207+
>
208+
{({ active, selected }) => (
209+
<>
217210
<span
218211
className={classNames(
219-
'absolute inset-y-0 right-0 flex items-center pr-4',
220-
active ? 'text-white' : 'text-blue-600'
212+
'block truncate',
213+
selected ? 'font-semibold' : 'font-normal'
221214
)}
222215
>
223-
<svg
224-
className="h-5 w-5"
225-
viewBox="0 0 20 20"
226-
fill="currentColor"
227-
>
228-
<path
229-
fillRule="evenodd"
230-
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"
231-
clipRule="evenodd"
232-
/>
233-
</svg>
216+
{person.name.first}
234217
</span>
235-
)}
236-
</>
237-
)}
238-
</Listbox.Option>
239-
))}
240-
</Listbox.Options>
218+
{selected && (
219+
<span
220+
className={classNames(
221+
'absolute inset-y-0 right-0 flex items-center pr-4',
222+
active ? 'text-white' : 'text-blue-600'
223+
)}
224+
>
225+
<svg
226+
className="h-5 w-5"
227+
viewBox="0 0 20 20"
228+
fill="currentColor"
229+
>
230+
<path
231+
fillRule="evenodd"
232+
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"
233+
clipRule="evenodd"
234+
/>
235+
</svg>
236+
</span>
237+
)}
238+
</>
239+
)}
240+
</Listbox.Option>
241+
))}
242+
</Listbox.Options>
243+
</div>
241244
</div>
242-
</div>
243-
</>
244-
)}
245-
</Listbox>
245+
</>
246+
)}
247+
</Listbox>
248+
</Field>
246249
</div>
247250
</Section>
248251
<Section title="Combobox">
249252
<div className="w-full space-y-1">
250-
<Combobox
251-
name="location"
252-
defaultValue={'New York'}
253-
onChange={(location) => {
254-
setQuery('')
255-
}}
256-
>
257-
{({ open, value }) => {
258-
return (
259-
<div className="relative">
260-
<div className="flex w-full flex-col">
261-
<Combobox.Input
262-
onChange={(e) => setQuery(e.target.value)}
263-
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"
264-
placeholder="Search users..."
265-
/>
266-
<div
267-
className={classNames(
268-
'flex border-t',
269-
value && !open ? 'border-transparent' : 'border-gray-200'
270-
)}
271-
>
272-
<div className="absolute z-10 mt-1 w-full rounded-md bg-white shadow-lg">
273-
<Combobox.Options className="shadow-2xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 sm:text-sm sm:leading-5">
274-
{locations
275-
.filter((location) =>
276-
location.toLowerCase().includes(query.toLowerCase())
277-
)
278-
.map((location) => (
279-
<Combobox.Option
280-
key={location}
281-
value={location}
282-
className={({ active }) => {
283-
return classNames(
284-
'relative flex cursor-default select-none space-x-4 py-2 pl-3 pr-9',
285-
active ? 'bg-blue-600 text-white' : 'text-gray-900'
286-
)
287-
}}
288-
>
289-
{({ active, selected }) => (
290-
<>
291-
<span
292-
className={classNames(
293-
'block truncate',
294-
selected ? 'font-semibold' : 'font-normal'
295-
)}
296-
>
297-
{location}
298-
</span>
299-
{active && (
253+
<Field>
254+
<Label>Location:</Label>
255+
<Combobox
256+
name="location"
257+
defaultValue={'New York'}
258+
onChange={(location) => {
259+
setQuery('')
260+
}}
261+
>
262+
{({ open, value }) => {
263+
return (
264+
<div className="relative">
265+
<div className="flex w-full flex-col">
266+
<Combobox.Input
267+
onChange={(e) => setQuery(e.target.value)}
268+
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"
269+
placeholder="Search users..."
270+
/>
271+
<div
272+
className={classNames(
273+
'flex border-t',
274+
value && !open ? 'border-transparent' : 'border-gray-200'
275+
)}
276+
>
277+
<div className="absolute z-10 mt-1 w-full rounded-md bg-white shadow-lg">
278+
<Combobox.Options className="shadow-2xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 sm:text-sm sm:leading-5">
279+
{locations
280+
.filter((location) =>
281+
location.toLowerCase().includes(query.toLowerCase())
282+
)
283+
.map((location) => (
284+
<Combobox.Option
285+
key={location}
286+
value={location}
287+
className={({ active }) => {
288+
return classNames(
289+
'relative flex cursor-default select-none space-x-4 py-2 pl-3 pr-9',
290+
active ? 'bg-blue-600 text-white' : 'text-gray-900'
291+
)
292+
}}
293+
>
294+
{({ active, selected }) => (
295+
<>
300296
<span
301297
className={classNames(
302-
'absolute inset-y-0 right-0 flex items-center pr-4',
303-
active ? 'text-white' : 'text-blue-600'
298+
'block truncate',
299+
selected ? 'font-semibold' : 'font-normal'
304300
)}
305301
>
306-
<svg
307-
className="h-5 w-5"
308-
viewBox="0 0 25 24"
309-
fill="none"
310-
>
311-
<path
312-
d="M11.25 8.75L14.75 12L11.25 15.25"
313-
stroke="currentColor"
314-
strokeWidth="1.5"
315-
strokeLinecap="round"
316-
strokeLinejoin="round"
317-
/>
318-
</svg>
302+
{location}
319303
</span>
320-
)}
321-
</>
322-
)}
323-
</Combobox.Option>
324-
))}
325-
</Combobox.Options>
304+
{active && (
305+
<span
306+
className={classNames(
307+
'absolute inset-y-0 right-0 flex items-center pr-4',
308+
active ? 'text-white' : 'text-blue-600'
309+
)}
310+
>
311+
<svg
312+
className="h-5 w-5"
313+
viewBox="0 0 25 24"
314+
fill="none"
315+
>
316+
<path
317+
d="M11.25 8.75L14.75 12L11.25 15.25"
318+
stroke="currentColor"
319+
strokeWidth="1.5"
320+
strokeLinecap="round"
321+
strokeLinejoin="round"
322+
/>
323+
</svg>
324+
</span>
325+
)}
326+
</>
327+
)}
328+
</Combobox.Option>
329+
))}
330+
</Combobox.Options>
331+
</div>
326332
</div>
327333
</div>
328334
</div>
329-
</div>
330-
)
331-
}}
332-
</Combobox>
335+
)
336+
}}
337+
</Combobox>
338+
</Field>
333339
</div>
334340
</Section>
335341
<Section title="Default form controls">
@@ -338,7 +344,12 @@ export default function App() {
338344
<Input type="text" />
339345
</Field>
340346
<Field className="flex flex-col p-1">
341-
<Label>Label for {'<Input type="checkbox">'}</Label>
347+
<Label>
348+
I agree to the{' '}
349+
<a href="https://google.com" target="_blank" className="underline">
350+
terms and conditions
351+
</a>
352+
</Label>
342353
<Input type="checkbox" />
343354
</Field>
344355
<Field className="flex flex-col p-1">

0 commit comments

Comments
 (0)