diff --git a/packages/propel/src/components/menubar/menubar.stories.tsx b/packages/propel/src/components/menubar/menubar.stories.tsx index 67aa6150..ee596c29 100644 --- a/packages/propel/src/components/menubar/menubar.stories.tsx +++ b/packages/propel/src/components/menubar/menubar.stories.tsx @@ -1,25 +1,41 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Copy, FilePlus, FolderOpen, Redo2, Save, Scissors, Undo2 } from "lucide-react"; +import { + Copy, + FilePlus, + FilePen, + FolderOpen, + Pencil, + Redo2, + Save, + Scissors, + Undo2, +} from "lucide-react"; import { expect, userEvent, waitFor } from "storybook/test"; -import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "../menu/index"; -import { Menubar } from "./index"; +import { Menu, MenuContent, MenuItem, MenuSeparator } from "../menu/index"; +import { Menubar, MenubarTrigger, MenubarTriggerIcon, MenubarTriggerLabel } from "./index"; // Components-tier story: the `Menubar` container hosts a row of `Menu` roots, each // using the ready-made `MenuContent` surface plus the rich `MenuItem` rows -// (icon + label). The UI-tier story assembles the popup parts by hand. +// (icon + label). Each trigger composes its anatomy — a leading `MenubarTriggerIcon` +// (the designer's "whether items have icons" axis) and a `MenubarTriggerLabel`. The +// UI-tier story assembles the popup parts by hand. const meta = { title: "Components/Menubar", component: Menubar, - subcomponents: { Menu, MenuTrigger, MenuContent, MenuItem }, + subcomponents: { + MenubarTrigger, + MenubarTriggerIcon, + MenubarTriggerLabel, + Menu, + MenuContent, + MenuItem, + }, } satisfies Meta; export default meta; type Story = StoryObj; -const triggerClass = - "inline-flex h-7 items-center rounded-sm px-2.5 text-13 text-secondary outline-none data-popup-open:bg-layer-transparent-hover"; - /** A row of menus, each opening a ready-made `MenuContent` of icon rows. */ export const Default: Story = { parameters: { @@ -36,7 +52,12 @@ export const Default: Story = { render: () => ( - }>File + + + + + File + } label="New file" /> } label="Open…" /> @@ -45,7 +66,12 @@ export const Default: Story = { - }>Edit + + + + + Edit + } label="Undo" /> } label="Redo" /> diff --git a/packages/propel/src/ui/menubar/index.tsx b/packages/propel/src/ui/menubar/index.tsx index 30783ccc..22fcdf78 100644 --- a/packages/propel/src/ui/menubar/index.tsx +++ b/packages/propel/src/ui/menubar/index.tsx @@ -1 +1,4 @@ export { Menubar, type MenubarProps } from "./menubar"; +export { MenubarTrigger, type MenubarTriggerProps } from "./menubar-trigger"; +export { MenubarTriggerIcon, type MenubarTriggerIconProps } from "./menubar-trigger-icon"; +export { MenubarTriggerLabel, type MenubarTriggerLabelProps } from "./menubar-trigger-label"; diff --git a/packages/propel/src/ui/menubar/menubar-trigger-icon.tsx b/packages/propel/src/ui/menubar/menubar-trigger-icon.tsx new file mode 100644 index 00000000..b07677d1 --- /dev/null +++ b/packages/propel/src/ui/menubar/menubar-trigger-icon.tsx @@ -0,0 +1,18 @@ +import type * as React from "react"; + +import { menubarTriggerIconVariants } from "./variants"; + +export type MenubarTriggerIconProps = Omit< + React.ComponentPropsWithoutRef<"span">, + "className" | "style" +>; + +/** + * A decorative leading icon at a menu bar trigger's inline-start. Sizes its single child to the + * trigger's `--node-size`, so callers pass a bare icon. Decorative (the trigger carries the + * accessible name), so it is `aria-hidden`. Compose it only when the item has an icon — the + * designer's "whether items have icons" axis. + */ +export function MenubarTriggerIcon(props: MenubarTriggerIconProps) { + return ; +} diff --git a/packages/propel/src/ui/menubar/menubar-trigger-label.tsx b/packages/propel/src/ui/menubar/menubar-trigger-label.tsx new file mode 100644 index 00000000..d1d0ec3c --- /dev/null +++ b/packages/propel/src/ui/menubar/menubar-trigger-label.tsx @@ -0,0 +1,13 @@ +import type * as React from "react"; + +import { menubarTriggerLabelVariants } from "./variants"; + +export type MenubarTriggerLabelProps = Omit< + React.ComponentPropsWithoutRef<"span">, + "className" | "style" +>; + +/** The label of a menu bar trigger. Truncates instead of overflowing the bar. */ +export function MenubarTriggerLabel(props: MenubarTriggerLabelProps) { + return ; +} diff --git a/packages/propel/src/ui/menubar/menubar-trigger.tsx b/packages/propel/src/ui/menubar/menubar-trigger.tsx new file mode 100644 index 00000000..a1efbc6c --- /dev/null +++ b/packages/propel/src/ui/menubar/menubar-trigger.tsx @@ -0,0 +1,16 @@ +import { Menu as BaseMenu } from "@base-ui/react/menu"; + +import { menubarTriggerVariants } from "./variants"; + +export type MenubarTriggerProps = Omit; + +/** + * The styled trigger for a top-level menu item in the menu bar. A flex row that lays out its + * anatomy parts — an optional leading `MenubarTriggerIcon` and a `MenubarTriggerLabel`. Bakes in + * all the "always the same" chrome from the designer's spec: height, inline padding, font style, + * focus ring, and the active/hover highlight when the popup is open. Renders inside a `Menu` root + * and receives keyboard navigation from the enclosing `Menubar`. + */ +export function MenubarTrigger(props: MenubarTriggerProps) { + return ; +} diff --git a/packages/propel/src/ui/menubar/menubar.stories.tsx b/packages/propel/src/ui/menubar/menubar.stories.tsx index 13a27399..4706ec61 100644 --- a/packages/propel/src/ui/menubar/menubar.stories.tsx +++ b/packages/propel/src/ui/menubar/menubar.stories.tsx @@ -8,25 +8,31 @@ import { MenuPortal, MenuPositioner, MenuSeparator, - MenuTrigger, } from "../menu/index"; -import { Menubar } from "./index"; +import { Menubar, MenubarTrigger, MenubarTriggerLabel } from "./index"; // UI-tier story: the `Menubar` container hosts a row of atomic `Menu` roots, each -// assembled from raw parts (Trigger › Portal › Positioner › Popup › Item). The -// components-tier story swaps the popup assembly for the ready-made `MenuContent`. +// assembled from raw parts (Trigger › Portal › Positioner › Popup › Item). Each +// trigger composes its own anatomy (`MenubarTriggerLabel`, plus an optional +// `MenubarTriggerIcon`). The components-tier story swaps the popup assembly for the +// ready-made `MenuContent`. const meta = { title: "UI/Menubar", component: Menubar, - subcomponents: { Menu, MenuTrigger, MenuPortal, MenuPositioner, MenuPopup, MenuItem }, + subcomponents: { + MenubarTrigger, + MenubarTriggerLabel, + Menu, + MenuPortal, + MenuPositioner, + MenuPopup, + MenuItem, + }, } satisfies Meta; export default meta; type Story = StoryObj; -const triggerClass = - "inline-flex h-7 items-center rounded-sm px-2.5 text-13 text-secondary outline-none data-popup-open:bg-layer-transparent-hover"; - /** A row of menus sharing arrow-key navigation and single-open behavior. */ export const Default: Story = { parameters: { @@ -43,7 +49,9 @@ export const Default: Story = { render: () => ( - }>File + + File + @@ -56,7 +64,9 @@ export const Default: Story = { - }>Edit + + Edit + diff --git a/packages/propel/src/ui/menubar/variants.ts b/packages/propel/src/ui/menubar/variants.ts index ea64f208..e19d404c 100644 --- a/packages/propel/src/ui/menubar/variants.ts +++ b/packages/propel/src/ui/menubar/variants.ts @@ -1,4 +1,6 @@ -import { cva } from "class-variance-authority"; +import { cva, cx } from "class-variance-authority"; + +import { nodeSlotClass } from "../../internal/node-slot"; // Menubar is a verbatim wrapping of Base UI's single `Menubar` container, which // hosts a row of `Menu.Root`s. Base UI drives all state through `data-*` @@ -9,3 +11,32 @@ import { cva } from "class-variance-authority"; export const menubarVariants = cva( "inline-flex items-center gap-1 rounded-md border-sm border-subtle bg-layer-1 p-1", ); + +// MenubarTrigger: the styled button for each top-level menu in the bar. +// Per the designer's spec (issue #133), all of the following are "always the same": +// – horizontal layout + height +// – item padding (horizontal and vertical) +// – font style +// – active/hover background highlight (`data-popup-open`) +// – focus ring style +// The trigger is a flex row that lays out its anatomy parts (an optional leading +// `MenubarTriggerIcon`, then the `MenubarTriggerLabel`); the spec's adjustable +// "whether items have icons" axis is served by composing — or omitting — the icon +// part, not by a cva variant. `--node-size` sizes any leading icon to 1rem. +export const menubarTriggerVariants = cva( + cx( + "group inline-flex h-7 items-center gap-1.5 rounded-sm px-2.5 [--node-size:1rem]", + "text-13 text-secondary", + "cursor-default outline-none focus-visible:ring-2 focus-visible:ring-accent-strong", + "data-popup-open:bg-layer-transparent-hover data-popup-open:text-primary", + "data-disabled:pointer-events-none data-disabled:text-disabled", + ), +); + +// The decorative leading icon at the trigger's inline-start. Sizes its single child +// to the trigger's `--node-size` (via the shared node-slot class) and tints it. +export const menubarTriggerIconVariants = cva(cx(nodeSlotClass, "text-icon-secondary")); + +// The trigger's label. A single text element; `min-w-0` lets long labels truncate +// instead of overflowing the bar. +export const menubarTriggerLabelVariants = cva("min-w-0 truncate");