Skip to content

Commit 89d0a7b

Browse files
authored
fix(ContextMenu): interact outside behaviors (#1788)
1 parent 1d996c0 commit 89d0a7b

File tree

6 files changed

+150
-3
lines changed

6 files changed

+150
-3
lines changed

.changeset/red-pugs-admire.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"bits-ui": patch
3+
---
4+
5+
fix(ContextMenu): ensure context menus respect interact outside of other dismissable layers

packages/bits-ui/src/lib/bits/menu/menu.svelte.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { DOMTypeahead } from "$lib/internal/dom-typeahead.svelte.js";
4949
import { RovingFocusGroup } from "$lib/internal/roving-focus-group.js";
5050
import { GraceArea } from "$lib/internal/grace-area.svelte.js";
5151
import { OpenChangeComplete } from "$lib/internal/open-change-complete.js";
52+
import { getTopMostDismissableLayer } from "../utilities/dismissible-layer/use-dismissable-layer.svelte.js";
5253

5354
export const CONTEXT_MENU_TRIGGER_ATTR = "data-context-menu-trigger";
5455

@@ -1181,6 +1182,21 @@ export class ContextMenuTriggerState {
11811182

11821183
oncontextmenu(e: BitsMouseEvent) {
11831184
if (e.defaultPrevented || this.opts.disabled.current) return;
1185+
1186+
const topMostLayer = getTopMostDismissableLayer();
1187+
1188+
if (topMostLayer) {
1189+
const topLayerRef = topMostLayer[0].opts.ref.current;
1190+
const topLayerRefContainsTrigger = topLayerRef?.contains(this.opts.ref.current);
1191+
1192+
if (
1193+
!topLayerRefContainsTrigger &&
1194+
!topLayerRef?.hasAttribute?.("data-context-menu-content")
1195+
) {
1196+
return;
1197+
}
1198+
}
1199+
11841200
this.#clearLongPressTimer();
11851201
this.#handleOpen(e);
11861202
e.preventDefault();

packages/bits-ui/src/lib/bits/utilities/dismissible-layer/use-dismissable-layer.svelte.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,10 @@ export class DismissibleLayerState {
221221
};
222222
}
223223

224-
function getTopMostLayer(
225-
layersArr: [DismissibleLayerState, ReadableBox<InteractOutsideBehaviorType>][]
224+
export function getTopMostDismissableLayer(
225+
layersArr: [DismissibleLayerState, ReadableBox<InteractOutsideBehaviorType>][] = [
226+
...globalThis.bitsDismissableLayers,
227+
]
226228
) {
227229
return layersArr.findLast(
228230
([_, { current: behaviorType }]) => behaviorType === "close" || behaviorType === "ignore"
@@ -237,7 +239,7 @@ function isResponsibleLayer(node: HTMLElement): boolean {
237239
* responsible for the outside interaction. Otherwise, we know that all layers defer so
238240
* the first layer is the responsible one.
239241
*/
240-
const topMostLayer = getTopMostLayer(layersArr);
242+
const topMostLayer = getTopMostDismissableLayer(layersArr);
241243
if (topMostLayer) return topMostLayer[0].opts.ref.current === node;
242244
const [firstLayerNode] = layersArr[0]!;
243245
return firstLayerNode.opts.ref.current === node;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<script lang="ts" module>
2+
import { ContextMenu, Dialog, DropdownMenu } from "bits-ui";
3+
export type ContextMenuTestProps = ContextMenu.RootProps & {
4+
checked?: boolean;
5+
subChecked?: boolean;
6+
radio?: string;
7+
subRadio?: string;
8+
open?: boolean;
9+
group?: string[];
10+
contentProps?: Omit<ContextMenu.ContentProps, "children" | "child">;
11+
portalProps?: Omit<ContextMenu.PortalProps, "children" | "child">;
12+
subTriggerProps?: Omit<ContextMenu.SubTriggerProps, "children" | "child">;
13+
checkboxGroupProps?: Omit<ContextMenu.CheckboxGroupProps, "children" | "child" | "value">;
14+
openFocusOverride?: boolean;
15+
};
16+
</script>
17+
18+
{#snippet contextMenu({ id }: { id: string })}
19+
<ContextMenu.Root>
20+
<ContextMenu.Trigger
21+
data-testid="context-trigger-{id}"
22+
class="z-[100] h-[500px] w-[500px]"
23+
aria-expanded={undefined}
24+
aria-controls={undefined}
25+
>
26+
open
27+
</ContextMenu.Trigger>
28+
<ContextMenu.Portal>
29+
<ContextMenu.Content data-testid="context-content-{id}">
30+
<ContextMenu.Item>
31+
<span>item</span>
32+
</ContextMenu.Item>
33+
</ContextMenu.Content>
34+
</ContextMenu.Portal>
35+
</ContextMenu.Root>
36+
{/snippet}
37+
38+
<main class="flex flex-col gap-16">
39+
{@render contextMenu({ id: "1" })}
40+
<DropdownMenu.Root>
41+
<DropdownMenu.Trigger data-testid="dropdown-trigger">open</DropdownMenu.Trigger>
42+
<DropdownMenu.Portal>
43+
<DropdownMenu.Content data-testid="dropdown-content">
44+
<DropdownMenu.Item>Hello</DropdownMenu.Item>
45+
</DropdownMenu.Content>
46+
</DropdownMenu.Portal>
47+
</DropdownMenu.Root>
48+
{@render contextMenu({ id: "2" })}
49+
50+
<Dialog.Root>
51+
<Dialog.Trigger data-testid="dialog-trigger">open</Dialog.Trigger>
52+
<Dialog.Portal>
53+
<Dialog.Content data-testid="dialog-content" class="z-[10]">
54+
{@render contextMenu({ id: "3" })}
55+
<Dialog.Close data-testid="dialog-close">close</Dialog.Close>
56+
</Dialog.Content>
57+
</Dialog.Portal>
58+
</Dialog.Root>
59+
</main>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<script lang="ts">
2+
import { ContextMenu } from "bits-ui";
3+
</script>
4+
5+
<ContextMenu.Root>
6+
<ContextMenu.Trigger data-testid="trigger">open</ContextMenu.Trigger>
7+
<ContextMenu.Portal>
8+
<ContextMenu.Content data-testid="content">
9+
<ContextMenu.Root>
10+
<ContextMenu.Trigger data-testid="nested-trigger">
11+
{#snippet child({ props })}
12+
<ContextMenu.Item {...props}>item</ContextMenu.Item>
13+
<ContextMenu.Portal>
14+
<ContextMenu.Content data-testid="nested-content">
15+
<ContextMenu.Item>some nested item</ContextMenu.Item>
16+
</ContextMenu.Content>
17+
</ContextMenu.Portal>
18+
{/snippet}
19+
</ContextMenu.Trigger>
20+
</ContextMenu.Root>
21+
</ContextMenu.Content>
22+
</ContextMenu.Portal>
23+
</ContextMenu.Root>

tests/src/tests/context-menu/context-menu.browser.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type { ContextMenuTestProps } from "./context-menu-test.svelte";
77
import type { ContextMenuForceMountTestProps } from "./context-menu-force-mount-test.svelte";
88
import ContextMenuForceMountTest from "./context-menu-force-mount-test.svelte";
99
import { expectExists, expectNotExists, setupBrowserUserEvents } from "../browser-utils";
10+
import ContextMenuIntegrationTest from "./context-menu-integration-test.svelte";
11+
import ContextMenuNestedTest from "./context-menu-nested-test.svelte";
1012

1113
const kbd = getTestKbd();
1214

@@ -427,3 +429,43 @@ it("calls `onValueChange` when the value of the checkbox group changes", async (
427429
await page.getByTestId("checkbox-group-item-1").click();
428430
expect(onValueChange).toHaveBeenCalledWith(["2"]);
429431
});
432+
433+
it("should not open when right-clicked while another floating layer is open that is not a context menu", async () => {
434+
render(ContextMenuIntegrationTest);
435+
await page.getByTestId("dropdown-trigger").click();
436+
const dropdownContent = page.getByTestId("dropdown-content");
437+
await expectExists(dropdownContent);
438+
await page.getByTestId("context-trigger-1").click({ button: "right" });
439+
await expectNotExists(page.getByTestId("context-content-1"));
440+
await expectExists(dropdownContent);
441+
});
442+
443+
it("should allow switching between context menus via right-click", async () => {
444+
render(ContextMenuIntegrationTest);
445+
await page.getByTestId("context-trigger-1").click({ button: "right" });
446+
await expectExists(page.getByTestId("context-content-1"));
447+
await page.getByTestId("context-trigger-2").click({ button: "right" });
448+
await expectNotExists(page.getByTestId("context-content-1"));
449+
await expectExists(page.getByTestId("context-content-2"));
450+
});
451+
452+
it("should open inside of a dialog", async () => {
453+
render(ContextMenuIntegrationTest);
454+
await page.getByTestId("dialog-trigger").click();
455+
await expectExists(page.getByTestId("dialog-content"));
456+
await page.getByTestId("context-trigger-3").click({ button: "right" });
457+
await expectExists(page.getByTestId("context-content-3"));
458+
await expectExists(page.getByTestId("dialog-content"));
459+
await page.getByTestId("dialog-content").click({ force: true });
460+
await expectExists(page.getByTestId("dialog-content"));
461+
await expectNotExists(page.getByTestId("context-content-3"));
462+
});
463+
464+
it("should open nested context menus", async () => {
465+
render(ContextMenuNestedTest);
466+
await page.getByTestId("trigger").click({ button: "right" });
467+
await expectExists(page.getByTestId("content"));
468+
await page.getByTestId("nested-trigger").click({ button: "right" });
469+
await expectExists(page.getByTestId("nested-content"));
470+
await expectExists(page.getByTestId("content"));
471+
});

0 commit comments

Comments
 (0)