diff --git a/.changeset/pink-tables-retire.md b/.changeset/pink-tables-retire.md new file mode 100644 index 000000000..bc9091bc6 --- /dev/null +++ b/.changeset/pink-tables-retire.md @@ -0,0 +1,5 @@ +--- +"bits-ui": patch +--- + +fix(ContextMenu): rapid right click should not open multiple menus diff --git a/docs/src/routes/(docs)/+layout.svelte b/docs/src/routes/(docs)/+layout.svelte index 39ca4c5b3..e7e2bc28f 100644 --- a/docs/src/routes/(docs)/+layout.svelte +++ b/docs/src/routes/(docs)/+layout.svelte @@ -6,15 +6,15 @@ import SidebarNav from "$lib/components/navigation/sidebar-nav.svelte"; import { navigation } from "$lib/config/index.js"; import "$lib/styles/app.css"; - import { onMount } from "svelte"; - import { page } from "$app/state"; + // import { onMount } from "svelte"; + // import { page } from "$app/state"; - onMount(async () => { - if (dev || page.url.searchParams.get("test")) { - const eruda = (await import("eruda")).default; - eruda.init(); - } - }); + // onMount(async () => { + // if (dev || page.url.searchParams.get("test")) { + // const eruda = (await import("eruda")).default; + // eruda.init(); + // } + // }); let { children } = $props(); diff --git a/docs/src/routes/(docs)/sink/+page.svelte b/docs/src/routes/(docs)/sink/+page.svelte index a0e669472..f37ab07f5 100644 --- a/docs/src/routes/(docs)/sink/+page.svelte +++ b/docs/src/routes/(docs)/sink/+page.svelte @@ -1,5 +1,28 @@ - +
+ {#each { length: 50 } as _, i (i)} + + +
+ Right click me {i} +
+
+ + + + {i} + + + +
+ {/each} +
diff --git a/packages/bits-ui/src/lib/app.d.ts b/packages/bits-ui/src/lib/app.d.ts index 48302a81b..7c77794d7 100644 --- a/packages/bits-ui/src/lib/app.d.ts +++ b/packages/bits-ui/src/lib/app.d.ts @@ -16,4 +16,9 @@ declare global { resetBodyStyle: () => void; }; var bitsAnimationsDisabled: boolean; + + // dismissible-layer interaction queue + var bitsDL_windowOpen: boolean; + var bitsDL_pendingLayerAdds: Array<() => void>; + var bitsDL_flushTimerId: number; } diff --git a/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts b/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts index ae86fb785..e13d17112 100644 --- a/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts @@ -1173,6 +1173,7 @@ export class ContextMenuTriggerState { #clearLongPressTimer() { if (this.#longPressTimer === null) return; getWindow(this.opts.ref.current).clearTimeout(this.#longPressTimer); + this.#longPressTimer = null; } #handleOpen(e: BitsMouseEvent | BitsPointerEvent) { diff --git a/packages/bits-ui/src/lib/bits/utilities/dismissible-layer/use-dismissable-layer.svelte.ts b/packages/bits-ui/src/lib/bits/utilities/dismissible-layer/use-dismissable-layer.svelte.ts index f697af71c..d8e735d36 100644 --- a/packages/bits-ui/src/lib/bits/utilities/dismissible-layer/use-dismissable-layer.svelte.ts +++ b/packages/bits-ui/src/lib/bits/utilities/dismissible-layer/use-dismissable-layer.svelte.ts @@ -1,7 +1,6 @@ import { type ReadableBox, type WritableBox, - afterSleep, afterTick, executeCallbacks, onDestroyEffect, @@ -22,6 +21,46 @@ globalThis.bitsDismissableLayers ??= new Map< ReadableBox >(); +// queue new layer registrations during an active outside-interaction window +// to avoid race conditions where a newly added layer becomes "responsible" +// before current layers finish handling the interaction +globalThis.bitsDL_windowOpen ??= false; +globalThis.bitsDL_pendingLayerAdds ??= []; +globalThis.bitsDL_flushTimerId ??= 0; + +function queueLayerRegistration(register: () => void) { + globalThis.bitsDL_pendingLayerAdds.push(register); +} + +function flushQueuedLayerAdds() { + const queue = globalThis.bitsDL_pendingLayerAdds; + while (queue.length) { + const fn = queue.shift()!; + try { + fn(); + } catch { + // ignore + } + } +} + +function beginInteractionWindow() { + globalThis.bitsDL_windowOpen = true; + if (globalThis.bitsDL_flushTimerId) window.clearTimeout(globalThis.bitsDL_flushTimerId); + // fallback to ensure the window eventually closes even if something prevents handlers + globalThis.bitsDL_flushTimerId = window.setTimeout(() => endInteractionWindow(), 120); +} + +function endInteractionWindow() { + if (!globalThis.bitsDL_windowOpen) return; + globalThis.bitsDL_windowOpen = false; + if (globalThis.bitsDL_flushTimerId) { + window.clearTimeout(globalThis.bitsDL_flushTimerId); + globalThis.bitsDL_flushTimerId = 0; + } + flushQueuedLayerAdds(); +} + interface DismissibleLayerStateOpts extends ReadableBoxedValues>> { ref: WritableBox; @@ -32,6 +71,7 @@ export class DismissibleLayerState { return new DismissibleLayerState(opts); } readonly opts: DismissibleLayerStateOpts; + #isDestroyed = false; #interactOutsideProp: ReadableBox>; #behaviorType: ReadableBox; #interceptedEvents: Record = { @@ -59,26 +99,33 @@ export class DismissibleLayerState { const cleanup = () => { this.#resetState(); globalThis.bitsDismissableLayers.delete(this); - this.#handleInteractOutside.destroy(); unsubEvents(); }; watch([() => this.opts.enabled.current, () => this.opts.ref.current], () => { if (!this.opts.enabled.current || !this.opts.ref.current) return; - afterSleep(1, () => { - if (!this.opts.ref.current) return; + const register = () => { + if (!this.opts.enabled.current || !this.opts.ref.current || this.#isDestroyed) + return; + // ensure document reference is up-to-date before attaching listeners + this.#documentObj = getOwnerDocument(this.opts.ref.current); globalThis.bitsDismissableLayers.set(this, this.#behaviorType); - unsubEvents(); unsubEvents = this.#addEventListeners(); - }); + }; + + if (globalThis.bitsDL_windowOpen) { + queueLayerRegistration(register); + } else { + register(); + } return cleanup; }); onDestroyEffect(() => { + this.#isDestroyed = true; this.#resetState.destroy(); globalThis.bitsDismissableLayers.delete(this); - this.#handleInteractOutside.destroy(); this.#unsubClickListener(); unsubEvents(); }); @@ -109,7 +156,11 @@ export class DismissibleLayerState { on( this.#documentObj, "pointerdown", - executeCallbacks(this.#markInterceptedEvent, this.#markResponsibleLayer), + executeCallbacks( + beginInteractionWindow, + this.#markInterceptedEvent, + this.#markResponsibleLayer + ), { capture: true } ), @@ -139,43 +190,46 @@ export class DismissibleLayerState { this.#interactOutsideProp.current(e as PointerEvent); }; - #handleInteractOutside = debounce((e: PointerEvent) => { - if (!this.opts.ref.current) { - this.#unsubClickListener(); - return; - } - const isEventValid = - this.opts.isValidEvent.current(e, this.opts.ref.current) || - isValidEvent(e, this.opts.ref.current); - - if (!this.#isResponsibleLayer || this.#isAnyEventIntercepted() || !isEventValid) { - this.#unsubClickListener(); - return; - } + #handleInteractOutside = (e: PointerEvent) => { + try { + if (!this.opts.ref.current) { + this.#unsubClickListener(); + return; + } + const isEventValid = + this.opts.isValidEvent.current(e, this.opts.ref.current) || + isValidEvent(e, this.opts.ref.current); - let event = e; - if (event.defaultPrevented) { - event = createWrappedEvent(event); - } + if (!this.#isResponsibleLayer || this.#isAnyEventIntercepted() || !isEventValid) { + this.#unsubClickListener(); + return; + } - if ( - this.#behaviorType.current !== "close" && - this.#behaviorType.current !== "defer-otherwise-close" - ) { - this.#unsubClickListener(); - return; - } + let event = e; + if (event.defaultPrevented) { + event = createWrappedEvent(event); + } - if (e.pointerType === "touch") { - this.#unsubClickListener(); + if ( + this.#behaviorType.current !== "close" && + this.#behaviorType.current !== "defer-otherwise-close" + ) { + this.#unsubClickListener(); + return; + } - this.#unsubClickListener = on(this.#documentObj, "click", this.#handleDismiss, { - once: true, - }); - } else { - this.#interactOutsideProp.current(event); + if (e.pointerType === "touch") { + this.#unsubClickListener(); + this.#unsubClickListener = on(this.#documentObj, "click", this.#handleDismiss, { + once: true, + }); + } else { + this.#interactOutsideProp.current(event); + } + } finally { + endInteractionWindow(); } - }, 10); + }; #markInterceptedEvent = (e: PointerEvent) => { this.#interceptedEvents[e.type] = true;