Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pink-tables-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"bits-ui": patch
---

fix(ContextMenu): rapid right click should not open multiple menus
16 changes: 8 additions & 8 deletions docs/src/routes/(docs)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();
</script>
Expand Down
27 changes: 25 additions & 2 deletions docs/src/routes/(docs)/sink/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
<script lang="ts">
import DateField from "./date-field.svelte";
import { ContextMenu } from "bits-ui";
</script>

<DateField />
<div class="flex flex-col">
{#each { length: 50 } as _, i (i)}
<ContextMenu.Root>
<ContextMenu.Trigger
class="rounded-card border-border-input text-muted-foreground flex select-none items-center justify-center border-2 border-dashed bg-transparent font-semibold"
>
<div class="flex flex-col items-center justify-center gap-4 text-center">
Right click me {i}
</div>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content
class="border-muted bg-background shadow-popover w-[229px] rounded-xl border px-1 py-1.5 outline-none focus-visible:outline-none"
>
<ContextMenu.Item
class="rounded-button data-highlighted:bg-muted flex h-10 select-none items-center py-3 pl-3 pr-1.5 text-sm font-medium focus-visible:outline-none"
>
{i}
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
{/each}
</div>
5 changes: 5 additions & 0 deletions packages/bits-ui/src/lib/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions packages/bits-ui/src/lib/bits/menu/menu.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
type ReadableBox,
type WritableBox,
afterSleep,
afterTick,
executeCallbacks,
onDestroyEffect,
Expand All @@ -22,6 +21,46 @@ globalThis.bitsDismissableLayers ??= new Map<
ReadableBox<InteractOutsideBehaviorType>
>();

// 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<Required<Omit<DismissibleLayerImplProps, "children" | "ref">>> {
ref: WritableBox<HTMLElement | null>;
Expand All @@ -32,6 +71,7 @@ export class DismissibleLayerState {
return new DismissibleLayerState(opts);
}
readonly opts: DismissibleLayerStateOpts;
#isDestroyed = false;
#interactOutsideProp: ReadableBox<EventCallback<PointerEvent>>;
#behaviorType: ReadableBox<InteractOutsideBehaviorType>;
#interceptedEvents: Record<string, boolean> = {
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -109,7 +156,11 @@ export class DismissibleLayerState {
on(
this.#documentObj,
"pointerdown",
executeCallbacks(this.#markInterceptedEvent, this.#markResponsibleLayer),
executeCallbacks(
beginInteractionWindow,
this.#markInterceptedEvent,
this.#markResponsibleLayer
),
{ capture: true }
),

Expand Down Expand Up @@ -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;
Expand Down
Loading