diff --git a/packages/bits-ui/src/lib/bits/context-menu/components/context-menu-content.svelte b/packages/bits-ui/src/lib/bits/context-menu/components/context-menu-content.svelte index 6681ae03c..a9814241d 100644 --- a/packages/bits-ui/src/lib/bits/context-menu/components/context-menu-content.svelte +++ b/packages/bits-ui/src/lib/bits/context-menu/components/context-menu-content.svelte @@ -17,6 +17,9 @@ el = $bindable(), loop = true, onInteractOutside = noop, + // we need to explicitly pass this prop to the PopperLayer to override + // the default menu behavior of handling outside interactions on the trigger + onInteractOutsideStart = noop, onEscapeKeydown = noop, forceMount = false, ...restProps @@ -62,6 +65,7 @@ sideOffset={2} align="start" present={state.parentMenu.open.value || forceMount} + {onInteractOutsideStart} onInteractOutside={(e) => { onInteractOutside(e); if (e.defaultPrevented) return; diff --git a/packages/bits-ui/src/lib/bits/context-menu/index.ts b/packages/bits-ui/src/lib/bits/context-menu/index.ts index 42f4f413b..4e5ec7739 100644 --- a/packages/bits-ui/src/lib/bits/context-menu/index.ts +++ b/packages/bits-ui/src/lib/bits/context-menu/index.ts @@ -12,6 +12,7 @@ export { default as RadioGroup } from "$lib/bits/menu/components/menu-radio-grou export { default as SubContent } from "$lib/bits/menu/components/menu-sub-content.svelte"; export { default as SubTrigger } from "$lib/bits/menu/components/menu-sub-trigger.svelte"; export { default as CheckboxItem } from "$lib/bits/menu/components/menu-checkbox-item.svelte"; +export { default as Portal } from "$lib/bits/utilities/portal/portal.svelte"; export type { ContextMenuArrowProps as ArrowProps, @@ -28,4 +29,5 @@ export type { ContextMenuSubTriggerProps as SubTriggerProps, ContextMenuContentProps as ContentProps, ContextMenuTriggerProps as TriggerProps, + ContextMenuPortalProps as PortalProps, } from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/context-menu/types.ts b/packages/bits-ui/src/lib/bits/context-menu/types.ts index 0784e0a34..fc36b8aca 100644 --- a/packages/bits-ui/src/lib/bits/context-menu/types.ts +++ b/packages/bits-ui/src/lib/bits/context-menu/types.ts @@ -27,6 +27,7 @@ export type { SubContentProps as ContextMenuSubContentProps, SubProps as ContextMenuSubProps, SubTriggerProps as ContextMenuSubTriggerProps, + PortalProps as ContextMenuPortalProps, } from "$lib/bits/menu/index.js"; export type { @@ -42,4 +43,5 @@ export type { MenuSubPropsWithoutHTML as ContextMenuSubPropsWithoutHTML, MenuSubTriggerPropsWithoutHTML as ContextMenuSubTriggerPropsWithoutHTML, MenuSubContentPropsWithoutHTML as ContextMenuSubContentPropsWithoutHTML, + MenuPortalPropsWithoutHTML as ContextMenuPortalPropsWithoutHTML, } from "$lib/bits/menu/types.js"; 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 baa9e74bd..ae5e1fda3 100644 --- a/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts @@ -910,20 +910,11 @@ class ContextMenuTriggerState { this.#clearLongPressTimer(); }; - #ariaControls = $derived.by(() => { - if (this.#parentMenu.open.value && this.#parentMenu.contentNode.value) - return this.#parentMenu.contentNode.value.id; - return undefined; - }); - props = $derived.by( () => ({ id: this.#parentMenu.triggerId.value, disabled: this.#disabled.value, - "aria-haspopup": "menu", - "aria-expanded": getAriaExpanded(this.#parentMenu.open.value), - "aria-controls": this.#ariaControls, "data-disabled": getDataDisabled(this.#disabled.value), "data-state": getDataOpenClosed(this.#parentMenu.open.value), [TRIGGER_ATTR]: "", diff --git a/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer-anchor.svelte b/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer-anchor.svelte index e62f32100..61c416804 100644 --- a/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer-anchor.svelte +++ b/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer-anchor.svelte @@ -2,10 +2,14 @@ import { box } from "svelte-toolbelt"; import { useFloatingAnchorState } from "../useFloatingLayer.svelte.js"; import type { AnchorProps } from "./index.js"; + import type { Measurable } from "$lib/internal/floating-svelte/types.js"; let { id, children, virtualEl }: AnchorProps = $props(); - useFloatingAnchorState({ id: box.with(() => id), virtualEl: box.with(() => virtualEl) }); + useFloatingAnchorState({ + id: box.with(() => id), + virtualEl: box.with(() => virtualEl as unknown as Measurable | null), + }); {@render children?.()} diff --git a/packages/bits-ui/src/lib/bits/utilities/floating-layer/types.ts b/packages/bits-ui/src/lib/bits/utilities/floating-layer/types.ts index d9864c57f..bf42ed95a 100644 --- a/packages/bits-ui/src/lib/bits/utilities/floating-layer/types.ts +++ b/packages/bits-ui/src/lib/bits/utilities/floating-layer/types.ts @@ -1,5 +1,5 @@ import type { Snippet } from "svelte"; -import type { WritableBox } from "svelte-toolbelt"; +import type { ReadableBox, WritableBox } from "svelte-toolbelt"; import type { Align, Boundary, Side } from "./useFloatingLayer.svelte.js"; import type { Arrayable } from "$lib/internal/types.js"; import type { Direction, StyleProperties } from "$lib/shared/index.js"; @@ -122,5 +122,5 @@ export type FloatingLayerContentImplProps = { export type FloatingLayerAnchorProps = { id: string; children?: Snippet; - virtualEl?: Measurable; + virtualEl?: ReadableBox; }; diff --git a/packages/bits-ui/src/lib/bits/utilities/floating-layer/useFloatingLayer.svelte.ts b/packages/bits-ui/src/lib/bits/utilities/floating-layer/useFloatingLayer.svelte.ts index 9f4d15454..37f1c9694 100644 --- a/packages/bits-ui/src/lib/bits/utilities/floating-layer/useFloatingLayer.svelte.ts +++ b/packages/bits-ui/src/lib/bits/utilities/floating-layer/useFloatingLayer.svelte.ts @@ -309,7 +309,7 @@ class FloatingArrowState { type FloatingAnchorStateProps = ReadableBoxedValues<{ id: string; - virtualEl?: Measurable; + virtualEl?: Measurable | null; }>; class FloatingAnchorState { diff --git a/packages/bits-ui/src/lib/bits/utilities/focus-scope/focus-scope-stack.svelte.ts b/packages/bits-ui/src/lib/bits/utilities/focus-scope/focus-scope-stack.svelte.ts index e9c58a5d2..c23164017 100644 --- a/packages/bits-ui/src/lib/bits/utilities/focus-scope/focus-scope-stack.svelte.ts +++ b/packages/bits-ui/src/lib/bits/utilities/focus-scope/focus-scope-stack.svelte.ts @@ -20,11 +20,11 @@ export function createFocusScopeStack() { } // remove in case it already exists because it'll be added to the top - stack.value = removeFromArray(stack.value, focusScope); + stack.value = removeFromArray($state.snapshot(stack.value), focusScope); stack.value.unshift(focusScope); }, remove(focusScope: FocusScopeAPI) { - stack.value = removeFromArray(stack.value, focusScope); + stack.value = removeFromArray($state.snapshot(stack.value), focusScope); stack.value[0]?.resume(); }, }; diff --git a/packages/bits-ui/src/tests/context-menu/ContextMenu.spec.ts b/packages/bits-ui/src/tests/context-menu/ContextMenu.spec.ts index 11fadd5ed..0dd27a29e 100644 --- a/packages/bits-ui/src/tests/context-menu/ContextMenu.spec.ts +++ b/packages/bits-ui/src/tests/context-menu/ContextMenu.spec.ts @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from "@testing-library/svelte"; +import { render, screen, waitFor } from "@testing-library/svelte/svelte5"; import { userEvent } from "@testing-library/user-event"; import { axe } from "jest-axe"; import { describe, it } from "vitest"; @@ -12,7 +12,7 @@ const kbd = getTestKbd(); * Helper function to reduce boilerplate in tests */ function setup(props: ContextMenuTestProps = {}) { - const user = userEvent.setup(); + const user = userEvent.setup({ pointerEventsCheck: 0 }); const { getByTestId, queryByTestId } = render(ContextMenuTest, { ...props }); const trigger = getByTestId("trigger"); return { @@ -73,7 +73,6 @@ describe("context menu", () => { "checkbox-item", "radio-group", "radio-item", - "checkbox-indicator", ]; for (const part of parts) { @@ -123,20 +122,20 @@ describe("context menu", () => { const { getByTestId, user, trigger } = await open(); const checkedBinding = getByTestId("checked-binding"); const indicator = getByTestId("checkbox-indicator"); - expect(indicator).not.toHaveTextContent("checked"); + expect(indicator).not.toHaveTextContent("true"); expect(checkedBinding).toHaveTextContent("false"); const checkbox = getByTestId("checkbox-item"); await user.click(checkbox); expect(checkedBinding).toHaveTextContent("true"); await user.pointer([{ target: trigger }, { keys: "[MouseRight]", target: trigger }]); - expect(indicator).toHaveTextContent("checked"); + expect(indicator).toHaveTextContent("true"); await user.click(getByTestId("checkbox-item")); expect(checkedBinding).toHaveTextContent("false"); await user.click(checkedBinding); expect(checkedBinding).toHaveTextContent("true"); await user.pointer([{ target: trigger }, { keys: "[MouseRight]", target: trigger }]); - expect(getByTestId("checkbox-indicator")).toHaveTextContent("checked"); + expect(getByTestId("checkbox-indicator")).toHaveTextContent("true"); }); it("toggles checkbox items within submenus when clicked & respects binding", async () => { @@ -146,13 +145,13 @@ describe("context menu", () => { const subCheckedBinding = getByTestId("sub-checked-binding"); expect(subCheckedBinding).toHaveTextContent("false"); const indicator = getByTestId("sub-checkbox-indicator"); - expect(indicator).not.toHaveTextContent("checked"); + expect(indicator).not.toHaveTextContent("true"); const subCheckbox = getByTestId("sub-checkbox-item"); await user.click(subCheckbox); expect(subCheckedBinding).toHaveTextContent("true"); await user.pointer([{ target: trigger }, { keys: "[MouseRight]", target: trigger }]); await openSubmenu(props); - expect(getByTestId("sub-checkbox-indicator")).toHaveTextContent("checked"); + expect(getByTestId("sub-checkbox-indicator")).toHaveTextContent("true"); await user.click(getByTestId("sub-checkbox-item")); expect(subCheckedBinding).toHaveTextContent("false"); @@ -160,36 +159,30 @@ describe("context menu", () => { expect(subCheckedBinding).toHaveTextContent("true"); await user.pointer([{ target: trigger }, { keys: "[MouseRight]", target: trigger }]); await openSubmenu(props); - expect(getByTestId("sub-checkbox-indicator")).toHaveTextContent("checked"); + expect(getByTestId("sub-checkbox-indicator")).toHaveTextContent("true"); }); it("checks the radio item when clicked & respects binding", async () => { const { getByTestId, queryByTestId, user, trigger } = await open(); const radioBinding = getByTestId("radio-binding"); - const indicator = queryByTestId("radio-indicator-1"); - expect(indicator).toBeNull(); expect(radioBinding).toHaveTextContent(""); const radioItem1 = getByTestId("radio-item"); await user.click(radioItem1); expect(radioBinding).toHaveTextContent("1"); await user.pointer([{ target: trigger }, { keys: "[MouseRight]", target: trigger }]); const radioIndicator = getByTestId("radio-indicator-1"); - expect(radioIndicator).not.toBeNull(); - expect(radioIndicator).toHaveTextContent("checked"); + expect(radioIndicator).toHaveTextContent("true"); const radioItem2 = getByTestId("radio-item-2"); await user.click(radioItem2); expect(radioBinding).toHaveTextContent("2"); await user.pointer([{ target: trigger }, { keys: "[MouseRight]", target: trigger }]); - expect(queryByTestId("radio-indicator-1")).toBeNull(); - expect(queryByTestId("radio-indicator-2")).toHaveTextContent("checked"); + expect(queryByTestId("radio-indicator-2")).toHaveTextContent("true"); await user.keyboard(kbd.ESCAPE); expect(queryByTestId("content")).toBeNull(); await user.click(radioBinding); expect(radioBinding).toHaveTextContent(""); await user.pointer([{ target: trigger }, { keys: "[MouseRight]", target: trigger }]); - expect(queryByTestId("radio-indicator-1")).toBeNull(); - expect(queryByTestId("radio-indicator-2")).toBeNull(); }); it("skips over disabled items when navigating with the keyboard", async () => { @@ -203,8 +196,12 @@ describe("context menu", () => { expect(getByTestId("disabled-item-2")).not.toHaveFocus(); }); - it("doesnt loop through the menu items when the `loop` prop is set to false/undefined", async () => { - const { user, getByTestId } = await open(); + it("doesnt loop through the menu items when the `loop` prop is set to false", async () => { + const { user, getByTestId } = await open({ + contentProps: { + loop: false, + }, + }); await user.keyboard(kbd.ARROW_DOWN); await user.keyboard(kbd.ARROW_DOWN); await waitFor(() => expect(getByTestId("sub-trigger")).toHaveFocus()); @@ -221,7 +218,11 @@ describe("context menu", () => { }); it("loops through the menu items when the `loop` prop is set to true", async () => { - const { user, getByTestId } = await open({ loop: true }); + const { user, getByTestId } = await open({ + contentProps: { + loop: true, + }, + }); await user.keyboard(kbd.ARROW_DOWN); await waitFor(() => expect(getByTestId("item")).toHaveFocus()); await user.keyboard(kbd.ARROW_DOWN); @@ -244,39 +245,56 @@ describe("context menu", () => { expect(queryByTestId("content")).toBeNull(); }); - it("respects the `closeOnEscape` prop", async () => { - const { queryByTestId, user } = await open({ closeOnEscape: false }); + it("respects the `escapeKeydownBehavior: 'ignore'` prop", async () => { + const { queryByTestId, user } = await open({ + contentProps: { + escapeKeydownBehavior: "ignore", + }, + }); await user.keyboard(kbd.ESCAPE); expect(queryByTestId("content")).not.toBeNull(); }); - it("respects the `closeOnOutsideClick` prop", async () => { + it("respects the `interactOutsideBehavior: 'ignore'` prop", async () => { const { queryByTestId, user, getByTestId } = await open({ - closeOnOutsideClick: false, + contentProps: { + interactOutsideBehavior: "ignore", + }, }); const outside = getByTestId("outside"); await user.click(outside); expect(queryByTestId("content")).not.toBeNull(); }); - it("portals to the body if a `portal` prop is not passed", async () => { + it("portals to the body if a `to` prop is not passed to the Portal", async () => { const { getByTestId } = await open(); const content = getByTestId("content"); - expect(content.parentElement).toEqual(document.body); + const wrapper = content.parentElement; + expect(wrapper?.parentElement).toEqual(document.body); }); - it("portals to the portal target if a valid `portal` prop is passed", async () => { - const { getByTestId } = await open({ portal: "#portal-target" }); + it("portals to the portal target if a valid `to` prop is passed to the portal", async () => { + const { getByTestId } = await open({ + portalProps: { + to: "#portal-target", + }, + }); const content = getByTestId("content"); + const wrapper = content.parentElement; const portalTarget = getByTestId("portal-target"); - expect(content.parentElement).toEqual(portalTarget); + expect(wrapper?.parentElement).toEqual(portalTarget); }); - it("does not portal if `null` is passed as the portal prop", async () => { - const { getByTestId } = await open({ portal: null }); + it("does not portal if `disabled: true` is passed to the Portal", async () => { + const { getByTestId } = await open({ + portalProps: { + disabled: true, + }, + }); const content = getByTestId("content"); const ogContainer = getByTestId("non-portal-container"); - expect(content.parentElement).not.toEqual(document.body); - expect(content.parentElement).toEqual(ogContainer); + const wrapper = content.parentElement; + expect(wrapper?.parentElement).not.toEqual(document.body); + expect(wrapper?.parentElement).toEqual(ogContainer); }); }); diff --git a/packages/bits-ui/src/tests/context-menu/ContextMenuTest.svelte b/packages/bits-ui/src/tests/context-menu/ContextMenuTest.svelte index 8b978a505..c765c2258 100644 --- a/packages/bits-ui/src/tests/context-menu/ContextMenuTest.svelte +++ b/packages/bits-ui/src/tests/context-menu/ContextMenuTest.svelte @@ -1,102 +1,119 @@
outside
- + open - - - - Stuff - - item - - - - - - subtrigger - - - - Email + open + + + + + + Stuff + + item - - - checked - - sub checkbox - - - - disabled item - disabled item 2 - - - checked - - Checkbox Item - - item 2 - - - - checked - - Radio Item 1 - - - - checked - - Radio Item 2 - - - + + + + + subtrigger + + + + Email + + + {#snippet children({ checked })} + + {checked} + + sub checkbox + {/snippet} + + + + disabled item + disabled item 2 + + {#snippet children({ checked })} + + {checked} + + Checkbox Item + {/snippet} + + item 2 + + + {#snippet children({ checked })} + + {checked} + + Radio Item 1 + {/snippet} + + + {#snippet children({ checked })} + {checked} + Radio Item 2 + {/snippet} + + + +
- - - + + - - -
+