|
49 | 49 | });
|
50 | 50 | }
|
51 | 51 |
|
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.) |
54 | 54 | 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 |
| -
|
64 | 55 | if (ev.target !== invoker && triggerEls.includes(ev.target as HTMLElement)) {
|
65 | 56 | invoker = ev.target as HTMLElement;
|
66 | 57 | // if (invoker) invoker.popoverTargetElement = popover;
|
67 |
| - isOpen = false; |
68 |
| - await new Promise((resolve) => setTimeout(resolve, triggerDelay)); |
69 | 58 | }
|
| 59 | + start_change(true, true); |
| 60 | + } |
70 | 61 |
|
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; |
75 | 67 | }
|
| 68 | + start_change(!isOpen, true); |
76 | 69 | }
|
77 | 70 |
|
| 71 | + // called in response to ui events (e.g. focusout, mouseout, click-outside) |
| 72 | + // that attempt to close the popover. |
78 | 73 | async function close_popover(ev: Event) {
|
79 | 74 | // For click triggers, don't close on focusout events from inside the popover
|
80 | 75 | if (trigger === "click" && ev.type === "focusout") {
|
|
91 | 86 | }
|
92 | 87 | }
|
93 | 88 |
|
94 |
| - isTriggered = false; |
95 |
| - await new Promise((resolve) => setTimeout(resolve, triggerDelay)); |
96 |
| - if (isTriggered) { |
97 |
| - return; |
98 |
| - } |
99 |
| -
|
100 | 89 | // if popover has focus don't close when leaving the invoker
|
101 | 90 | if (ev?.type === "mouseleave" && popover?.contains(popover.ownerDocument.activeElement)) {
|
102 | 91 | return;
|
|
105 | 94 | return;
|
106 | 95 | }
|
107 | 96 |
|
108 |
| - isOpen = false; |
| 97 | + start_change(false, true); |
109 | 98 | }
|
110 | 99 |
|
111 | 100 | let autoUpdateDestroy = () => {};
|
|
132 | 121 | function on_toggle(ev: ToggleEvent) {
|
133 | 122 | if (!invoker) return;
|
134 | 123 |
|
135 |
| - // Update isOpen value when popover state changes through other means |
136 |
| - isOpen = ev.newState === "open"; |
| 124 | + start_change(ev.newState === "open", false); |
137 | 125 |
|
138 | 126 | (ev as TriggeredToggleEvent).trigger = invoker;
|
139 | 127 | _ontoggle?.(ev as TriggeredToggleEvent);
|
|
143 | 131 | const events: [string, any, boolean][] = [
|
144 | 132 | ["focusin", open_popover, focusable],
|
145 | 133 | ["focusout", close_popover, focusable],
|
146 |
| - ["mousedown", open_popover, clickable], |
| 134 | + ["mousedown", toggle_popover, clickable], |
147 | 135 | ["mouseenter", open_popover, hoverable],
|
148 | 136 | ["mouseleave", close_popover, hoverable]
|
149 | 137 | ];
|
|
176 | 164 |
|
177 | 165 | function closeOnEscape(event: KeyboardEvent) {
|
178 | 166 | if (event.key === "Escape") {
|
179 |
| - isOpen = false; |
| 167 | + start_change(false, true); |
180 | 168 | }
|
181 | 169 | }
|
182 | 170 |
|
|
193 | 181 | // Only close if click is outside both popover and trigger elements
|
194 | 182 | if (!isClickInsidePopover && !isClickOnTrigger) {
|
195 | 183 | close_popover(event);
|
196 |
| - isOpen = false; |
197 | 184 | }
|
198 | 185 | }
|
| 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 | + } |
199 | 224 | </script>
|
200 | 225 |
|
201 | 226 | <div use:set_triggers hidden></div>
|
|
0 commit comments