diff --git a/packages/@headlessui-react/src/components/label/label.tsx b/packages/@headlessui-react/src/components/label/label.tsx index fa294bb..d6a970c 100644 --- a/packages/@headlessui-react/src/components/label/label.tsx +++ b/packages/@headlessui-react/src/components/label/label.tsx @@ -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( 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 + // + // ``` + // + // 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. diff --git a/packages/@headlessui-react/src/utils/dom.ts b/packages/@headlessui-react/src/utils/dom.ts index 9e44891..481a580 100644 --- a/packages/@headlessui-react/src/utils/dom.ts +++ b/packages/@headlessui-react/src/utils/dom.ts @@ -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]' + ) +} diff --git a/playgrounds/react/pages/combinations/form.tsx b/playgrounds/react/pages/combinations/form.tsx index 0ba071a..0244a67 100644 --- a/playgrounds/react/pages/combinations/form.tsx +++ b/playgrounds/react/pages/combinations/form.tsx @@ -167,169 +167,175 @@ export default function App() {
- - {({ value }) => ( - <> -
- - {value?.name?.first} - - - - - - - -
- - {people.map((person) => ( - { - return classNames( - 'relative cursor-default select-none py-2 pl-3 pr-9', - active ? 'bg-blue-600 text-white' : 'text-gray-900' - ) - }} + + + + {({ value }) => ( + <> +
+ + {value?.name?.first} + + - {({ active, selected }) => ( - <> - - {person.name.first} - - {selected && ( + + + + + +
+ + {people.map((person) => ( + { + return classNames( + 'relative cursor-default select-none py-2 pl-3 pr-9', + active ? 'bg-blue-600 text-white' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> - - - + {person.name.first} - )} - - )} - - ))} - + {selected && ( + + + + + + )} + + )} + + ))} + +
-
- - )} - + + )} + +
- { - setQuery('') - }} - > - {({ open, value }) => { - return ( -
-
- 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..." - /> -
-
- - {locations - .filter((location) => - location.toLowerCase().includes(query.toLowerCase()) - ) - .map((location) => ( - { - 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 }) => ( - <> - - {location} - - {active && ( + + + { + setQuery('') + }} + > + {({ open, value }) => { + return ( +
+
+ 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..." + /> +
+
+ + {locations + .filter((location) => + location.toLowerCase().includes(query.toLowerCase()) + ) + .map((location) => ( + { + 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 }) => ( + <> - - - + {location} - )} - - )} - - ))} - + {active && ( + + + + + + )} + + )} + + ))} + +
-
- ) - }} - + ) + }} + +
@@ -338,7 +344,12 @@ export default function App() { - +