Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

next: context menu #558

Merged
merged 4 commits into from
May 30, 2024
Merged
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
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
Loading