import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { CopyDuotoneIcon, CopyRegularIcon, } from "alouette-icons/phosphor-icons/Copy"; import { GearDuotoneIcon, GearRegularIcon, } from "alouette-icons/phosphor-icons/Gear"; import { PencilSimpleDuotoneIcon, PencilSimpleRegularIcon, } from "alouette-icons/phosphor-icons/PencilSimple"; import { SignOutDuotoneIcon, SignOutRegularIcon, } from "alouette-icons/phosphor-icons/SignOut"; import { TrashDuotoneIcon, TrashRegularIcon, } from "alouette-icons/phosphor-icons/Trash"; import { UserCircleDuotoneIcon, UserCircleRegularIcon, } from "alouette-icons/phosphor-icons/UserCircle"; import { type ReactNode, useState } from "react"; import { Text } from "../primitives/Text"; import { View } from "../primitives/View"; import { Separator } from "../stacks/Separator"; import { Story } from "../story-components/Story"; import { Button } from "./Button"; import { IconButton } from "./IconButton"; import { Menu } from "./Menu"; import { MenuItem } from "./MenuItem"; type ThisStory = StoryObj; export default { title: "alouette/Actions/Menu", component: Menu, parameters: { componentSubtitle: "A pressable that opens a list of actions: anchored under its trigger on web, an overlay on native.", docs: { description: { component: `### Composition ~~~tsx chris@example.com} render={(trigger) => ~~~ - \`render\` hands the trigger its \`ref\`, \`onPress\`, \`aria-haspopup\` and \`aria-expanded\` — spread them onto any pressable - Use it for actions that are secondary, rare or destructive; a primary action stays a \`Button\` in the bar - An item runs its \`onPress\` and closes the menu; \`accent\` colours its label and icon, \`href\` makes it a real link on web, \`activeIcon\` swaps the glyph while the row is hovered, focused or pressed - \`header\` renders outside the \`menu\` element, because a menu owns menu items only - The first item takes focus on open; arrows and Home/End move between items, the pointer moves it as it goes over a row, Escape closes and returns focus to the trigger`, }, }, }, } satisfies Meta; function AccountMenu(): ReactNode { return ( camille@example.com} render={(trigger) => ); } export const PreviewMenuStory: ThisStory = { name: "Menu Preview", parameters: { layout: "padded", chromatic: { disableSnapshot: true }, }, render: () => , }; export const VariantsMenuStory: ThisStory = { name: "Menu Variants", render: () => ( ( } size="sm" variant="soft" {...trigger} /> )} > } activeIcon={} onPress={fn()} /> } activeIcon={} onPress={fn()} /> } activeIcon={} accent="danger" onPress={fn()} /> The duotone twin is optional: a row with `icon` alone keeps one glyph weight through hover, focus and press. ( `checked` makes the rows a set of choices (`menuitemradio`), and `description` adds a line under the label. ( ( ), }; function MenuDemo(): ReactNode { const [lastAction, setLastAction] = useState("none"); return ( report.pdf} render={(trigger) => {`last action: ${lastAction}`} ); } export const TestsMenuStory: ThisStory = { name: "Menu Tests", render: () => ( ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); // The panel is portaled out of the canvas, so it is queried from `screen`. const trigger = canvas.getByRole("button", { name: "Actions" }); await expect(trigger).toHaveAttribute("aria-haspopup", "menu"); await expect(trigger).toHaveAttribute("aria-expanded", "false"); await expect(screen.queryByRole("menu")).not.toBeInTheDocument(); await userEvent.click(trigger); await expect(trigger).toHaveAttribute("aria-expanded", "true"); const menu = await screen.findByRole("menu", { name: "Document actions" }); const items = within(menu).getAllByRole("menuitem"); const [rename, archive, open] = items; if (!rename || !archive || !open) { throw new Error("expected four menu items"); } await expect(archive).toHaveAttribute("aria-disabled", "true"); // A real link on web, and a disabled item drops its href. await expect(open.tagName).toBe("A"); await expect(open).toHaveAttribute("href", "/open"); for (const item of items) { await expect(item.getBoundingClientRect().height).toBeGreaterThanOrEqual( 44, ); } // The panel is wider than the trigger it hangs off, and right-aligned to it. const triggerBox = trigger.getBoundingClientRect(); const menuBox = menu.getBoundingClientRect(); await expect(menuBox.width).toBeGreaterThan(triggerBox.width); await expect(Math.abs(menuBox.right - triggerBox.right)).toBeLessThan(20); // The header sits outside the menu element: a menu owns menu items only. await expect(within(menu).queryByText("report.pdf")).toBe(null); await expect(screen.getByText("report.pdf")).toBeTruthy(); // Focus opens on the first item, arrows move over the enabled ones only. await waitFor(() => expect(rename).toHaveFocus()); await userEvent.keyboard("{ArrowDown}"); await expect(open).toHaveFocus(); await userEvent.keyboard("{End}"); await expect( within(menu).getByRole("menuitem", { name: "Delete" }), ).toHaveFocus(); await userEvent.keyboard("{Home}"); await expect(rename).toHaveFocus(); // No focus ring on a menu row: the focus follows the pointer here, and the // row's own fill already shows where the cursor is. `outline-none` cannot // express that in this stack (react-native-css drops `outline-style: none`), // hence the zero-width outline this asserts. await expect(getComputedStyle(rename).outlineWidth).toBe("0px"); // The pointer carries the focus with it, so a hovered row is the only lit // one; a disabled row takes neither the focus nor the highlight. await userEvent.hover(open); await expect(open).toHaveFocus(); await userEvent.hover(archive); await expect(open).toHaveFocus(); // Escape closes and hands the focus back to the trigger. await userEvent.keyboard("{Escape}"); await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument(), ); await expect(trigger).toHaveFocus(); await expect(trigger).toHaveAttribute("aria-expanded", "false"); // An item press runs its handler and closes the menu. await userEvent.click(trigger); await userEvent.click( within(await screen.findByRole("menu")).getByRole("menuitem", { name: "Rename", }), ); await waitFor(() => expect(canvas.getByText("last action: rename")).toBeTruthy(), ); await expect(screen.queryByRole("menu")).not.toBeInTheDocument(); }, };