Skip to content

Commit bbd7e1e

Browse files
committed
fix: attempt to debounce popover toggling logic to simplify and fix on safari
1 parent dbe5157 commit bbd7e1e

File tree

1 file changed

+54
-29
lines changed

1 file changed

+54
-29
lines changed

src/lib/utils/Popper.svelte

Lines changed: 54 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -49,32 +49,27 @@
4949
});
5050
}
5151
52-
let isTriggered: boolean = false;
53-
52+
// called in response to ui events that attempt to open the popover (e.g.
53+
// clicks, hover, focusin, etc.)
5454
async function open_popover(ev: Event) {
55-
// throttle
56-
isTriggered = true;
57-
await new Promise((resolve) => setTimeout(resolve, triggerDelay));
58-
if (!isTriggered) {
59-
return;
60-
}
61-
62-
ev.preventDefault();
63-
6455
if (ev.target !== invoker && triggerEls.includes(ev.target as HTMLElement)) {
6556
invoker = ev.target as HTMLElement;
6657
// if (invoker) invoker.popoverTargetElement = popover;
67-
isOpen = false;
68-
await new Promise((resolve) => setTimeout(resolve, triggerDelay));
6958
}
59+
start_change(true, true);
60+
}
7061
71-
if (ev.type === "mousedown") {
72-
isOpen = !isOpen;
73-
} else {
74-
isOpen = true;
62+
// called in response to ui events (e.g. clicks) that attempt to toggle the
63+
// popover, such as clicking the invoker repeatedly.
64+
async function toggle_popover(ev: Event) {
65+
if (ev.target !== invoker && triggerEls.includes(ev.target as HTMLElement)) {
66+
invoker = ev.target as HTMLElement;
7567
}
68+
start_change(!isOpen, true);
7669
}
7770
71+
// called in response to ui events (e.g. focusout, mouseout, click-outside)
72+
// that attempt to close the popover.
7873
async function close_popover(ev: Event) {
7974
// For click triggers, don't close on focusout events from inside the popover
8075
if (trigger === "click" && ev.type === "focusout") {
@@ -91,12 +86,6 @@
9186
}
9287
}
9388
94-
isTriggered = false;
95-
await new Promise((resolve) => setTimeout(resolve, triggerDelay));
96-
if (isTriggered) {
97-
return;
98-
}
99-
10089
// if popover has focus don't close when leaving the invoker
10190
if (ev?.type === "mouseleave" && popover?.contains(popover.ownerDocument.activeElement)) {
10291
return;
@@ -105,7 +94,7 @@
10594
return;
10695
}
10796
108-
isOpen = false;
97+
start_change(false, true);
10998
}
11099
111100
let autoUpdateDestroy = () => {};
@@ -132,8 +121,7 @@
132121
function on_toggle(ev: ToggleEvent) {
133122
if (!invoker) return;
134123
135-
// Update isOpen value when popover state changes through other means
136-
isOpen = ev.newState === "open";
124+
start_change(ev.newState === "open", false);
137125
138126
(ev as TriggeredToggleEvent).trigger = invoker;
139127
_ontoggle?.(ev as TriggeredToggleEvent);
@@ -143,7 +131,7 @@
143131
const events: [string, any, boolean][] = [
144132
["focusin", open_popover, focusable],
145133
["focusout", close_popover, focusable],
146-
["mousedown", open_popover, clickable],
134+
["mousedown", toggle_popover, clickable],
147135
["mouseenter", open_popover, hoverable],
148136
["mouseleave", close_popover, hoverable]
149137
];
@@ -176,7 +164,7 @@
176164
177165
function closeOnEscape(event: KeyboardEvent) {
178166
if (event.key === "Escape") {
179-
isOpen = false;
167+
start_change(false, true);
180168
}
181169
}
182170
@@ -193,9 +181,46 @@
193181
// Only close if click is outside both popover and trigger elements
194182
if (!isClickInsidePopover && !isClickOnTrigger) {
195183
close_popover(event);
196-
isOpen = false;
197184
}
198185
}
186+
187+
interface ChangeContext {
188+
nextOpen: boolean;
189+
interactive: boolean;
190+
}
191+
let context: ChangeContext | undefined = $state(undefined);
192+
let timeout: NodeJS.Timeout | undefined = $state(undefined);
193+
194+
// start_change debounces calls that attempt to open or close the popover.
195+
// callers must specify whether this request is on behalf of ui interactivity
196+
// (e.g. clicks, hovers). non-interactive invocations are assumed to be due
197+
// to changing value of isOpen.
198+
// NOTE: start_change prioritizes non-interactive changes over interactive
199+
// ones, so that binding to isOpen (i.e. for things like programmatically
200+
// controlled dropdowns) always results in the popover being toggled.
201+
function start_change(nextOpen: boolean, interactive: boolean) {
202+
// ignore redundant requests
203+
if (!context && nextOpen == isOpen) return;
204+
if (context && context.nextOpen == nextOpen) return;
205+
206+
// ignore interactive requests while we're in the middle of a programmatic
207+
// one. e.g. if a button is clicked which updates a bound isOpen prop, we
208+
// should obey the value of isOpen and ignore any interactive events
209+
// (click, hover, etc.) that arrive until we finish handling (triggerDelay
210+
// ms).
211+
if (context && !context.interactive && interactive) return;
212+
213+
context = { interactive, nextOpen };
214+
if (timeout) clearTimeout(timeout);
215+
timeout = setTimeout(finish_change, triggerDelay);
216+
}
217+
218+
// finish_change is called after debouncing calls to start_change.
219+
function finish_change() {
220+
if (!context) return;
221+
isOpen = context.nextOpen;
222+
context = undefined;
223+
}
199224
</script>
200225

201226
<div use:set_triggers hidden></div>

0 commit comments

Comments
 (0)