Skip to content

Commit

Permalink
next: context menu (#558)
Browse files Browse the repository at this point in the history
  • Loading branch information
huntabyte authored May 30, 2024
1 parent b2e5b50 commit 28c1c45
Show file tree
Hide file tree
Showing 10 changed files with 155 additions and 117 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -62,6 +65,7 @@
sideOffset={2}
align="start"
present={state.parentMenu.open.value || forceMount}
{onInteractOutsideStart}
onInteractOutside={(e) => {
onInteractOutside(e);
if (e.defaultPrevented) return;
Expand Down
2 changes: 2 additions & 0 deletions packages/bits-ui/src/lib/bits/context-menu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,4 +29,5 @@ export type {
ContextMenuSubTriggerProps as SubTriggerProps,
ContextMenuContentProps as ContentProps,
ContextMenuTriggerProps as TriggerProps,
ContextMenuPortalProps as PortalProps,
} from "./types.js";
2 changes: 2 additions & 0 deletions packages/bits-ui/src/lib/bits/context-menu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -42,4 +43,5 @@ export type {
MenuSubPropsWithoutHTML as ContextMenuSubPropsWithoutHTML,
MenuSubTriggerPropsWithoutHTML as ContextMenuSubTriggerPropsWithoutHTML,
MenuSubContentPropsWithoutHTML as ContextMenuSubContentPropsWithoutHTML,
MenuPortalPropsWithoutHTML as ContextMenuPortalPropsWithoutHTML,
} from "$lib/bits/menu/types.js";
9 changes: 0 additions & 9 deletions packages/bits-ui/src/lib/bits/menu/menu.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
</script>

{@render children?.()}
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -122,5 +122,5 @@ export type FloatingLayerContentImplProps = {
export type FloatingLayerAnchorProps = {
id: string;
children?: Snippet;
virtualEl?: Measurable;
virtualEl?: ReadableBox<Measurable | null>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ class FloatingArrowState {

type FloatingAnchorStateProps = ReadableBoxedValues<{
id: string;
virtualEl?: Measurable;
virtualEl?: Measurable | null;
}>;

class FloatingAnchorState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
};
Expand Down
84 changes: 51 additions & 33 deletions packages/bits-ui/src/tests/context-menu/ContextMenu.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -73,7 +73,6 @@ describe("context menu", () => {
"checkbox-item",
"radio-group",
"radio-item",
"checkbox-indicator",
];

for (const part of parts) {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -146,50 +145,44 @@ 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");

await user.click(subCheckedBinding);
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 () => {
Expand All @@ -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());
Expand All @@ -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);
Expand All @@ -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);
});
});
Loading

0 comments on commit 28c1c45

Please sign in to comment.