Skip to content

Commit 16fb9fa

Browse files
committed
fix: use document click listener for dropdown coordination
Replace event dispatch pattern with self-contained document-level click listeners. Each dropdown now closes when clicking outside, without requiring all components to participate in a coordination protocol. Follows Filament's established pattern.
1 parent 52d4bb2 commit 16fb9fa

File tree

3 files changed

+104
-69
lines changed

3 files changed

+104
-69
lines changed

resources/views/forms/multi-value-input.blade.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,38 @@ class="fi-fo-multi-value-input-wrp"
2626
"
2727
>
2828
<div
29-
wire:key="{{ $key }}"
29+
wire:key="{{ $key }}-{{ $isDisabled ? 'disabled' : 'enabled' }}"
3030
wire:ignore.self
31+
x-cloak
3132
x-data="{
3233
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')") }},
3334
newValue: '',
35+
componentKey: @js($key),
3436
allowMultiple: @js($allowMultiple),
3537
maxValues: @js($maxValues),
3638
isDisabled: @js($isDisabled),
3739
maxVisibleValues: 3,
3840
copiedIndex: null,
41+
documentClickListener: null,
3942
4043
init() {
4144
if (!Array.isArray(this.state)) {
4245
this.state = this.state ? [this.state] : [];
4346
}
4447
this.state = this.state.filter(v => v && v.trim() !== '');
48+
49+
this.documentClickListener = (event) => {
50+
if (this.isOpen() && !this.$el.contains(event.target)) {
51+
this.closePanel();
52+
}
53+
};
54+
document.addEventListener('click', this.documentClickListener);
55+
},
56+
57+
destroy() {
58+
if (this.documentClickListener) {
59+
document.removeEventListener('click', this.documentClickListener);
60+
}
4561
},
4662
4763
get canAddMore() {

resources/views/forms/phone-input.blade.php

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@ class="fi-fo-phone-input-wrp"
2828
"
2929
>
3030
<div
31-
wire:key="{{ $key }}"
31+
wire:key="{{ $key }}-{{ $isDisabled ? 'disabled' : 'enabled' }}"
3232
wire:ignore.self
33+
x-cloak
3334
x-data="{
3435
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')") }},
3536
isOpen: false,
37+
componentKey: @js($key),
3638
allowMultiple: @js($allowMultiple),
3739
maxValues: @js($maxValues),
3840
isDisabled: @js($isDisabled),
@@ -46,6 +48,7 @@ class="fi-fo-phone-input-wrp"
4648
highlightedIndex: -1,
4749
newEntry: { country: @js($defaultCountry), number: '' },
4850
copiedIndex: null,
51+
documentClickListener: null,
4952
5053
init() {
5154
if (!Array.isArray(this.state)) {
@@ -59,6 +62,19 @@ class="fi-fo-phone-input-wrp"
5962
country: entry?.country || this.defaultCountry,
6063
number: entry?.number || ''
6164
}));
65+
66+
this.documentClickListener = (event) => {
67+
if (this.isOpen && !this.$el.contains(event.target)) {
68+
this.close();
69+
}
70+
};
71+
document.addEventListener('click', this.documentClickListener);
72+
},
73+
74+
destroy() {
75+
if (this.documentClickListener) {
76+
document.removeEventListener('click', this.documentClickListener);
77+
}
6278
},
6379
6480
get canAddMore() {
@@ -414,7 +430,7 @@ class="sr-only"
414430
x-on:click.stop="activeCountryDropdown === 0 ? closeCountryDropdown() : openCountryDropdown(0)"
415431
x-on:keydown="handleButtonKeydown($event, 0)"
416432
:disabled="isDisabled"
417-
class="flex items-center gap-1 py-1.5 pl-3 pr-1.5 text-sm text-gray-950 dark:text-white hover:bg-gray-50 dark:hover:bg-white/5 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed rounded-l-lg w-20"
433+
class="flex items-center gap-1 py-1.5 pl-3 pr-1.5 text-sm text-gray-950 dark:text-white hover:bg-gray-50 dark:hover:bg-white/5 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed rounded-l-lg w-23"
418434
>
419435
<span x-text="getCountryLabel(state[0]?.country)" class="font-medium text-xs"></span>
420436
<x-heroicon-m-chevron-down class="size-3.5 text-gray-400" x-bind:class="{ 'rotate-180': activeCountryDropdown === 0 }" aria-hidden="true" />

resources/views/forms/record-select-input.blade.php

Lines changed: 69 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@
2323
class="fi-fo-record-select-input-wrp"
2424
>
2525
<div
26-
wire:key="{{ $key }}"
26+
wire:key="{{ $key }}-{{ $isDisabled ? 'disabled' : 'enabled' }}"
2727
wire:ignore.self
28+
x-cloak
2829
x-data="{
2930
state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$statePath}')") }},
31+
open: false,
3032
search: '',
3133
isSearching: false,
3234
searchResults: [],
@@ -39,6 +41,7 @@ class="fi-fo-record-select-input-wrp"
3941
maxVisibleValues: @js($maxVisiblePills),
4042
selectedSnapshot: [],
4143
activeIndex: -1,
44+
documentClickListener: null,
4245
4346
init() {
4447
if (!Array.isArray(this.state)) {
@@ -52,13 +55,42 @@ class="fi-fo-record-select-input-wrp"
5255
} else {
5356
this.searchResults = [];
5457
}
55-
// Reset activeIndex when search changes
5658
this.activeIndex = this.sortedOptions.length > 0 ? 0 : -1;
5759
});
60+
61+
this.$watch('open', (isOpen) => {
62+
if (isOpen) {
63+
this.selectedSnapshot = [...this.state];
64+
this.search = '';
65+
this.searchResults = [];
66+
this.activeIndex = this.getInitialActiveIndex();
67+
this.$nextTick(() => {
68+
this.$refs.searchInput?.focus();
69+
this.scrollActiveIntoView();
70+
});
71+
} else {
72+
this.search = '';
73+
this.searchResults = [];
74+
this.activeIndex = -1;
75+
}
76+
});
77+
78+
this.documentClickListener = (event) => {
79+
if (this.open && !this.$el.contains(event.target)) {
80+
this.close();
81+
}
82+
};
83+
document.addEventListener('click', this.documentClickListener);
84+
},
85+
86+
destroy() {
87+
if (this.documentClickListener) {
88+
document.removeEventListener('click', this.documentClickListener);
89+
}
5890
},
5991
6092
get activeDescendant() {
61-
if (!this.isOpen() || this.activeIndex < 0 || this.activeIndex >= this.sortedOptions.length) {
93+
if (!this.open || this.activeIndex < 0 || this.activeIndex >= this.sortedOptions.length) {
6294
return null;
6395
}
6496
return this.$id('option-' + this.activeIndex);
@@ -165,55 +197,26 @@ class="fi-fo-record-select-input-wrp"
165197
}
166198
},
167199
168-
isOpen() {
169-
return this.$refs.panel?._x_isShown === true;
170-
},
171-
172-
togglePanel() {
200+
toggle() {
173201
if (this.isDisabled) return;
174-
175-
const wasOpen = this.isOpen();
176-
this.$refs.panel?.toggle(this.$refs.trigger);
177-
178-
if (!wasOpen) {
179-
// Opening the panel
180-
this.selectedSnapshot = [...this.state];
181-
this.search = '';
182-
this.searchResults = [];
183-
this.activeIndex = this.getInitialActiveIndex();
184-
// Use setTimeout to ensure panel transition has started
185-
setTimeout(() => {
186-
this.$refs.searchInput?.focus();
187-
this.scrollActiveIntoView();
188-
}, 50);
189-
} else {
190-
this.activeIndex = -1;
191-
}
202+
this.open ? this.close() : this.openPanel();
192203
},
193204
194205
openPanel() {
195-
if (this.isDisabled) return;
196-
this.selectedSnapshot = [...this.state];
206+
if (this.isDisabled || this.open) return;
197207
this.$refs.panel?.open(this.$refs.trigger);
198-
this.search = '';
199-
this.searchResults = [];
200-
this.activeIndex = this.getInitialActiveIndex();
201-
// Use setTimeout to ensure panel transition has started
202-
setTimeout(() => {
203-
this.$refs.searchInput?.focus();
204-
this.scrollActiveIntoView();
205-
}, 50);
208+
this.open = true;
206209
},
207210
208-
closePanel() {
209-
const wasOpen = this.isOpen();
211+
close() {
212+
if (!this.open) return;
210213
this.$refs.panel?.close();
211-
this.search = '';
212-
this.searchResults = [];
213-
this.activeIndex = -1;
214-
if (wasOpen) {
215-
this.$refs.trigger?.focus();
216-
}
214+
this.open = false;
215+
this.$refs.trigger?.focus();
216+
},
217+
218+
closePanel() {
219+
this.close();
217220
},
218221
219222
onKeydown(event) {
@@ -223,7 +226,7 @@ class="fi-fo-record-select-input-wrp"
223226
case 'ArrowDown':
224227
event.preventDefault();
225228
event.stopPropagation();
226-
if (this.isOpen()) {
229+
if (this.open) {
227230
this.focusNext();
228231
} else {
229232
this.openPanel();
@@ -232,46 +235,45 @@ class="fi-fo-record-select-input-wrp"
232235
case 'ArrowUp':
233236
event.preventDefault();
234237
event.stopPropagation();
235-
if (this.isOpen()) {
238+
if (this.open) {
236239
this.focusPrevious();
237240
} else {
238241
this.openPanel();
239242
}
240243
break;
241244
case 'Home':
242-
if (this.isOpen()) {
245+
if (this.open) {
243246
event.preventDefault();
244247
this.focusFirst();
245248
}
246249
break;
247250
case 'End':
248-
if (this.isOpen()) {
251+
if (this.open) {
249252
event.preventDefault();
250253
this.focusLast();
251254
}
252255
break;
253256
case 'Enter':
254257
event.preventDefault();
255-
if (this.isOpen() && this.activeIndex >= 0 && this.activeIndex < this.sortedOptions.length) {
258+
if (this.open && this.activeIndex >= 0 && this.activeIndex < this.sortedOptions.length) {
256259
const record = this.sortedOptions[this.activeIndex];
257260
this.allowMultiple ? this.toggleRecord(record) : this.selectRecord(record);
258-
} else if (!this.isOpen()) {
261+
} else if (!this.open) {
259262
this.openPanel();
260263
}
261264
break;
262265
case ' ':
263-
// Don't intercept space when typing in search input
264266
if (document.activeElement === this.$refs.searchInput) {
265267
return;
266268
}
267-
if (!this.isOpen()) {
269+
if (!this.open) {
268270
event.preventDefault();
269271
this.openPanel();
270272
}
271273
break;
272274
case 'Tab':
273-
if (this.isOpen()) {
274-
this.closePanel();
275+
if (this.open) {
276+
this.close();
275277
}
276278
break;
277279
}
@@ -399,8 +401,8 @@ class="fi-fo-record-select-input-wrp"
399401
this.state = this.state.filter(id => id !== recordId);
400402
}
401403
}"
402-
x-on:click.outside="closePanel()"
403-
x-on:keydown.esc="isOpen() && (closePanel(), $event.stopPropagation())"
404+
x-on:click.outside="close()"
405+
x-on:keydown.esc="open && (close(), $event.stopPropagation())"
404406
x-on:keydown="onKeydown($event)"
405407
class="relative w-full"
406408
>
@@ -414,18 +416,19 @@ class="relative w-full"
414416
\Filament\Support\prepare_inherited_attributes($attributes)
415417
->class(['fi-fo-record-select-input'])
416418
"
419+
x-bind:class="{ 'ring-2 ring-primary-600 dark:ring-primary-500': open }"
417420
>
418421
{{-- Single Value Mode --}}
419422
<template x-if="!allowMultiple">
420423
<button
421424
type="button"
422425
x-ref="trigger"
423-
x-on:click="togglePanel()"
424-
x-on:keydown.enter.prevent="togglePanel()"
425-
x-on:keydown.space.prevent="togglePanel()"
426+
x-on:click="toggle()"
427+
x-on:keydown.enter.prevent="toggle()"
428+
x-on:keydown.space.prevent="toggle()"
426429
:disabled="isDisabled"
427430
role="combobox"
428-
:aria-expanded="isOpen() ? 'true' : 'false'"
431+
:aria-expanded="open ? 'true' : 'false'"
429432
aria-haspopup="listbox"
430433
:aria-controls="$id('panel')"
431434
:aria-activedescendant="activeDescendant"
@@ -466,7 +469,7 @@ class="shrink-0 rounded p-0.5 text-gray-400 hover:text-gray-500 dark:hover:text-
466469
{{-- Chevron --}}
467470
<x-heroicon-m-chevron-down
468471
class="size-4 text-gray-400 dark:text-gray-500 shrink-0 transition-transform duration-200"
469-
x-bind:class="{ 'rotate-180': isOpen() }"
472+
x-bind:class="{ 'rotate-180': open }"
470473
/>
471474
</button>
472475
</template>
@@ -477,12 +480,12 @@ class="size-4 text-gray-400 dark:text-gray-500 shrink-0 transition-transform dur
477480
<button
478481
type="button"
479482
x-ref="trigger"
480-
x-on:click="togglePanel()"
481-
x-on:keydown.enter.prevent="togglePanel()"
482-
x-on:keydown.space.prevent="togglePanel()"
483+
x-on:click="toggle()"
484+
x-on:keydown.enter.prevent="toggle()"
485+
x-on:keydown.space.prevent="toggle()"
483486
:disabled="isDisabled"
484487
role="combobox"
485-
:aria-expanded="isOpen() ? 'true' : 'false'"
488+
:aria-expanded="open ? 'true' : 'false'"
486489
aria-haspopup="listbox"
487490
:aria-controls="$id('panel')"
488491
:aria-activedescendant="activeDescendant"
@@ -532,7 +535,7 @@ class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
532535
{{-- Chevron --}}
533536
<x-heroicon-m-chevron-down
534537
class="size-4 text-gray-400 dark:text-gray-500 shrink-0 transition-transform duration-200"
535-
x-bind:class="{ 'rotate-180': isOpen() }"
538+
x-bind:class="{ 'rotate-180': open }"
536539
/>
537540
</button>
538541
</div>

0 commit comments

Comments
 (0)