Skip to content

Commit 7b30e06

Browse files
Fix outside click detection when component is mounted in the Shadow DOM (#2866)
* Fix outside click detection when component is mounted in the Shadow DOM * Fix code style * Fix error
1 parent 24486b3 commit 7b30e06

File tree

3 files changed

+146
-123
lines changed

3 files changed

+146
-123
lines changed

packages/@headlessui-react/src/hooks/use-root-containers.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export function useRootContainers({
4343
if (!(container instanceof HTMLElement)) continue // Skip non-HTMLElements
4444
if (container.id === 'headlessui-portal-root') continue // Skip the Headless UI portal root
4545
if (container.contains(mainTreeNodeRef.current)) continue // Skip if it is the main app
46+
if (container.contains((mainTreeNodeRef.current?.getRootNode() as ShadowRoot)?.host)) continue // Skip if it is the main app (and the component is inside a shadow root)
4647
if (containers.some((defaultContainer) => container.contains(defaultContainer))) continue // Skip if the current container is part of a container we've already seen (e.g.: default container / portal)
4748

4849
containers.push(container)

packages/playground-react/pages/combobox/combobox-virtual-with-empty-states.tsx

Lines changed: 86 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { useRef, useState } from 'react'
33
import { classNames } from '../../utils/class-names'
44

55
import { Button } from '../../components/button'
6-
import { flushSync } from 'react-dom'
76

87
type Option = {
98
name: string
@@ -13,32 +12,32 @@ type Option = {
1312

1413
export default function Home() {
1514
let [list, setList] = useState<Option[]>(() => [
16-
{name: 'Alice', disabled: false},
17-
{name: 'Bob', disabled: false},
18-
{name: 'Charlie', disabled: false},
19-
{name: 'David', disabled: false},
20-
{name: 'Eve', disabled: false},
21-
{name: 'Fred', disabled: false},
22-
{name: 'George', disabled: false},
23-
{name: 'Helen', disabled: false},
24-
{name: 'Iris', disabled: false},
25-
{name: 'John', disabled: false},
26-
{name: 'Kate', disabled: false},
27-
{name: 'Linda', disabled: false},
28-
{name: 'Michael', disabled: false},
29-
{name: 'Nancy', disabled: false},
30-
{name: 'Oscar', disabled: true},
31-
{name: 'Peter', disabled: false},
32-
{name: 'Quentin', disabled: false},
33-
{name: 'Robert', disabled: false},
34-
{name: 'Sarah', disabled: false},
35-
{name: 'Thomas', disabled: false},
36-
{name: 'Ursula', disabled: false},
37-
{name: 'Victor', disabled: false},
38-
{name: 'Wendy', disabled: false},
39-
{name: 'Xavier', disabled: false},
40-
{name: 'Yvonne', disabled: false},
41-
{name: 'Zachary', disabled: false},
15+
{ name: 'Alice', disabled: false },
16+
{ name: 'Bob', disabled: false },
17+
{ name: 'Charlie', disabled: false },
18+
{ name: 'David', disabled: false },
19+
{ name: 'Eve', disabled: false },
20+
{ name: 'Fred', disabled: false },
21+
{ name: 'George', disabled: false },
22+
{ name: 'Helen', disabled: false },
23+
{ name: 'Iris', disabled: false },
24+
{ name: 'John', disabled: false },
25+
{ name: 'Kate', disabled: false },
26+
{ name: 'Linda', disabled: false },
27+
{ name: 'Michael', disabled: false },
28+
{ name: 'Nancy', disabled: false },
29+
{ name: 'Oscar', disabled: true },
30+
{ name: 'Peter', disabled: false },
31+
{ name: 'Quentin', disabled: false },
32+
{ name: 'Robert', disabled: false },
33+
{ name: 'Sarah', disabled: false },
34+
{ name: 'Thomas', disabled: false },
35+
{ name: 'Ursula', disabled: false },
36+
{ name: 'Victor', disabled: false },
37+
{ name: 'Wendy', disabled: false },
38+
{ name: 'Xavier', disabled: false },
39+
{ name: 'Yvonne', disabled: false },
40+
{ name: 'Zachary', disabled: false },
4241
])
4342

4443
let emptyOption = useRef({ name: 'No results', disabled: true, empty: true })
@@ -52,10 +51,9 @@ export default function Home() {
5251
? list
5352
: list.filter((item) => item.name.toLowerCase().includes(query.toLowerCase()))
5453

55-
5654
return (
5755
<div className="mx-auto max-w-fit">
58-
<div className="py-8 font-mono text-xs">Selected person: {selectedPerson?.name ?? "N/A"}</div>
56+
<div className="py-8 font-mono text-xs">Selected person: {selectedPerson?.name ?? 'N/A'}</div>
5957
<Combobox
6058
virtual={{
6159
options: filtered.length > 0 ? filtered : [emptyOption.current],
@@ -68,12 +66,11 @@ export default function Home() {
6866
setQuery('')
6967
}}
7068
as="div"
71-
7269
// Don't do this lol — it's not supported
7370
// It's just so we can tab to the "Add" button for the demo
7471
// The combobox doesn't actually support this behavior
7572
onKeyDownCapture={(event: KeyboardEvent) => {
76-
let addButton = document.querySelector('#add_person')
73+
let addButton = document.querySelector('#add_person') as HTMLElement | null
7774
if (event.key === 'Tab' && addButton && filtered.length === 0) {
7875
event.preventDefault()
7976
setTimeout(() => addButton.focus(), 0)
@@ -116,69 +113,76 @@ export default function Home() {
116113
// It comes with some caveats:
117114
// like the option callback being called with a null option (which is probably a bug)
118115
static={filtered.length === 0}
119-
120116
ref={optionsRef}
121117
className={classNames(
122-
"shadow-xs max-h-60 rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5",
118+
'shadow-xs max-h-60 rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5',
123119
filtered.length === 0 ? 'overflow-hidden' : 'overflow-auto'
124120
)}
125121
>
126-
{
127-
({ option }: { option: Option }) => {
128-
if (!option || option.empty) {
129-
return (
130-
<Combobox.Option
131-
// TODO: `disabled` being required is a bug
132-
disabled
133-
// Note: Do NOT use `null` for the `value`
134-
value={option ?? emptyOption.current}
135-
className="relative w-full cursor-default select-none py-2 px-3 focus:outline-none text-center"
136-
>
137-
<div className="grid grid-cols-1 grid-rows-1 h-full relative">
138-
<div className="absolute inset-0">
139-
<svg fill="none" viewBox="0 0 24 24" strokeWidth={0.5} stroke="currentColor" className="text-gray-500/5 -translate-y-1/4">
140-
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
141-
</svg>
142-
</div>
143-
<div className="z-20 col-start-1 row-start-1 col-span-full row-span-full p-8 flex flex-col justify-center items-center">
144-
<h3 className="mx-2 text-xl mb-4 text-gray-400 font-semibold">No people found</h3>
145-
<button
146-
id="add_person"
147-
type="button"
148-
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded font-semibold focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 focus:outline-none"
149-
onClick={() => {
150-
let person = { name: query, disabled: false }
151-
setList(list => [...list, person])
152-
setSelectedPerson(person)
153-
}}
154-
>
155-
Add "{query}"
156-
</button>
157-
</div>
158-
</div>
159-
</Combobox.Option>
160-
)
161-
}
162-
122+
{({ option }: { option: Option }) => {
123+
if (!option || option.empty) {
163124
return (
164125
<Combobox.Option
165126
// TODO: `disabled` being required is a bug
166-
disabled={option.disabled}
167-
value={option}
168-
className={({ active }) => {
169-
return classNames(
170-
'relative w-full cursor-default select-none py-2 pl-3 pr-9 focus:outline-none',
171-
active ? 'bg-indigo-600 text-white' : 'text-gray-900'
172-
)
173-
}}
127+
disabled
128+
// Note: Do NOT use `null` for the `value`
129+
value={option ?? emptyOption.current}
130+
className="relative w-full cursor-default select-none px-3 py-2 text-center focus:outline-none"
174131
>
175-
<span className='block truncate'>
176-
{option.name}
177-
</span>
132+
<div className="relative grid h-full grid-cols-1 grid-rows-1">
133+
<div className="absolute inset-0">
134+
<svg
135+
fill="none"
136+
viewBox="0 0 24 24"
137+
strokeWidth={0.5}
138+
stroke="currentColor"
139+
className="-translate-y-1/4 text-gray-500/5"
140+
>
141+
<path
142+
strokeLinecap="round"
143+
strokeLinejoin="round"
144+
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
145+
/>
146+
</svg>
147+
</div>
148+
<div className="z-20 col-span-full col-start-1 row-span-full row-start-1 flex flex-col items-center justify-center p-8">
149+
<h3 className="mx-2 mb-4 text-xl font-semibold text-gray-400">
150+
No people found
151+
</h3>
152+
<button
153+
id="add_person"
154+
type="button"
155+
className="rounded bg-blue-500 px-4 py-2 font-semibold text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
156+
onClick={() => {
157+
let person = { name: query, disabled: false }
158+
setList((list) => [...list, person])
159+
setSelectedPerson(person)
160+
}}
161+
>
162+
Add "{query}"
163+
</button>
164+
</div>
165+
</div>
178166
</Combobox.Option>
179167
)
180168
}
181-
}
169+
170+
return (
171+
<Combobox.Option
172+
// TODO: `disabled` being required is a bug
173+
disabled={option.disabled}
174+
value={option}
175+
className={({ active }) => {
176+
return classNames(
177+
'relative w-full cursor-default select-none py-2 pl-3 pr-9 focus:outline-none',
178+
active ? 'bg-indigo-600 text-white' : 'text-gray-900'
179+
)
180+
}}
181+
>
182+
<span className="block truncate">{option.name}</span>
183+
</Combobox.Option>
184+
)
185+
}}
182186
</Combobox.Options>
183187
</div>
184188
</div>

packages/playground-vue/src/components/combobox/combobox-virtual-with-empty-states.vue

Lines changed: 59 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
<script setup lang="ts">
22
import { computed, ref } from 'vue'
3-
import { Combobox, ComboboxLabel, ComboboxInput, ComboboxOption, ComboboxOptions, ComboboxButton } from '@headlessui/vue'
3+
import {
4+
Combobox,
5+
ComboboxLabel,
6+
ComboboxInput,
7+
ComboboxOption,
8+
ComboboxOptions,
9+
ComboboxButton,
10+
} from '@headlessui/vue'
411
512
type Option = {
613
name: string
@@ -9,32 +16,32 @@ type Option = {
916
}
1017
1118
let list = ref([
12-
{name: 'Alice', disabled: false},
13-
{name: 'Bob', disabled: false},
14-
{name: 'Charlie', disabled: false},
15-
{name: 'David', disabled: false},
16-
{name: 'Eve', disabled: false},
17-
{name: 'Fred', disabled: false},
18-
{name: 'George', disabled: false},
19-
{name: 'Helen', disabled: false},
20-
{name: 'Iris', disabled: false},
21-
{name: 'John', disabled: false},
22-
{name: 'Kate', disabled: false},
23-
{name: 'Linda', disabled: false},
24-
{name: 'Michael', disabled: false},
25-
{name: 'Nancy', disabled: false},
26-
{name: 'Oscar', disabled: true},
27-
{name: 'Peter', disabled: false},
28-
{name: 'Quentin', disabled: false},
29-
{name: 'Robert', disabled: false},
30-
{name: 'Sarah', disabled: false},
31-
{name: 'Thomas', disabled: false},
32-
{name: 'Ursula', disabled: false},
33-
{name: 'Victor', disabled: false},
34-
{name: 'Wendy', disabled: false},
35-
{name: 'Xavier', disabled: false},
36-
{name: 'Yvonne', disabled: false},
37-
{name: 'Zachary', disabled: false},
19+
{ name: 'Alice', disabled: false },
20+
{ name: 'Bob', disabled: false },
21+
{ name: 'Charlie', disabled: false },
22+
{ name: 'David', disabled: false },
23+
{ name: 'Eve', disabled: false },
24+
{ name: 'Fred', disabled: false },
25+
{ name: 'George', disabled: false },
26+
{ name: 'Helen', disabled: false },
27+
{ name: 'Iris', disabled: false },
28+
{ name: 'John', disabled: false },
29+
{ name: 'Kate', disabled: false },
30+
{ name: 'Linda', disabled: false },
31+
{ name: 'Michael', disabled: false },
32+
{ name: 'Nancy', disabled: false },
33+
{ name: 'Oscar', disabled: true },
34+
{ name: 'Peter', disabled: false },
35+
{ name: 'Quentin', disabled: false },
36+
{ name: 'Robert', disabled: false },
37+
{ name: 'Sarah', disabled: false },
38+
{ name: 'Thomas', disabled: false },
39+
{ name: 'Ursula', disabled: false },
40+
{ name: 'Victor', disabled: false },
41+
{ name: 'Wendy', disabled: false },
42+
{ name: 'Xavier', disabled: false },
43+
{ name: 'Yvonne', disabled: false },
44+
{ name: 'Zachary', disabled: false },
3845
])
3946
4047
let emptyOption = { name: 'No results', disabled: true, empty: true }
@@ -48,18 +55,17 @@ let filtered = computed(() => {
4855
? list.value
4956
: list.value.filter((item) => item.name.toLowerCase().includes(query.value.toLowerCase()))
5057
})
51-
5258
</script>
5359
<template>
5460
<div class="mx-auto max-w-fit">
55-
<div class="py-8 font-mono text-xs">Selected person: {{ selectedPerson?.name ?? "N/A" }}</div>
61+
<div class="py-8 font-mono text-xs">Selected person: {{ selectedPerson?.name ?? 'N/A' }}</div>
5662
<Combobox
5763
:virtual="{
5864
options: filtered.length > 0 ? filtered : [emptyOption],
5965
disabled: (option) => option.disabled || option.empty,
6066
}"
6167
v-model="selectedPerson"
62-
@update:modelValue="() => query = ''"
68+
@update:modelValue="() => (query = '')"
6369
nullable
6470
as="div"
6571
>
@@ -70,7 +76,7 @@ let filtered = computed(() => {
7076
<div class="relative">
7177
<span class="relative inline-flex flex-row overflow-hidden rounded-md border shadow-sm">
7278
<ComboboxInput
73-
@change="(e) => query = e.target.value"
79+
@change="(e) => (query = e.target.value)"
7480
:displayValue="(option: Option | null) => option?.name ?? ''"
7581
class="border-none px-3 py-1 outline-none"
7682
/>
@@ -98,24 +104,36 @@ let filtered = computed(() => {
98104
:ref="optionsRef"
99105
:class="[
100106
'shadow-xs max-h-60 rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5',
101-
filtered.length === 0 ? 'overflow-hidden' : 'overflow-auto'
107+
filtered.length === 0 ? 'overflow-hidden' : 'overflow-auto',
102108
]"
103109
v-slot="{ option }"
104110
>
105111
<template v-if="option.empty">
106112
<ComboboxOption
107113
:value="option"
108-
class="relative w-full cursor-default select-none py-2 px-3 focus:outline-none text-center"
114+
class="relative w-full cursor-default select-none px-3 py-2 text-center focus:outline-none"
109115
disabled
110116
>
111-
<div class="grid grid-cols-1 grid-rows-1 h-full relative">
117+
<div class="relative grid h-full grid-cols-1 grid-rows-1">
112118
<div class="absolute inset-0">
113-
<svg fill="none" viewBox="0 0 24 24" stroke-width="0.5" stroke="currentColor" class="text-gray-500/5 -translate-y-1/4">
114-
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
119+
<svg
120+
fill="none"
121+
viewBox="0 0 24 24"
122+
stroke-width="0.5"
123+
stroke="currentColor"
124+
class="-translate-y-1/4 text-gray-500/5"
125+
>
126+
<path
127+
stroke-linecap="round"
128+
stroke-linejoin="round"
129+
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
130+
/>
115131
</svg>
116132
</div>
117-
<div class="z-20 col-start-1 row-start-1 col-span-full row-span-full p-8 flex flex-col justify-center items-center">
118-
<h3 class="mx-2 text-xl mb-4 text-gray-400 font-semibold">No people found</h3>
133+
<div
134+
class="z-20 col-span-full col-start-1 row-span-full row-start-1 flex flex-col items-center justify-center p-8"
135+
>
136+
<h3 class="mx-2 mb-4 text-xl font-semibold text-gray-400">No people found</h3>
119137
</div>
120138
</div>
121139
</ComboboxOption>
@@ -129,11 +147,11 @@ let filtered = computed(() => {
129147
>
130148
<div
131149
:class="[
132-
'relative w-full cursor-default select-none py-2 pl-3 pr-9 focus:outline-none',
133-
active ? 'bg-indigo-600 text-white' : 'text-gray-900'
150+
'relative w-full cursor-default select-none py-2 pl-3 pr-9 focus:outline-none',
151+
active ? 'bg-indigo-600 text-white' : 'text-gray-900',
134152
]"
135153
>
136-
<span class='block truncate'>
154+
<span class="block truncate">
137155
{{ option.name }}
138156
</span>
139157
</div>

0 commit comments

Comments
 (0)