From 8cae10fb09e126f61c6f0c7b07d57d754963c8b1 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 23 Jun 2026 16:02:59 +0700 Subject: [PATCH 1/2] Add MenubarTrigger part; bake always-same item styles into cva Per the designer's spec (issue #133), the menubar item trigger's height, padding, font style, and popup-open highlight are all "always the same". Move them out of the stories' manual triggerClass string and into a proper MenubarTrigger ui part backed by menubarTriggerVariants in variants.ts. Export MenubarTrigger from the ui and components index; update both story tiers to use it (dropping the now-unnecessary triggerClass constant and the now-unused MenuTrigger import). Also adds MenubarTrigger to each story's subcomponents map. --- .../src/components/menubar/menubar.stories.tsx | 13 +++++-------- packages/propel/src/ui/menubar/index.tsx | 1 + .../propel/src/ui/menubar/menubar-trigger.tsx | 15 +++++++++++++++ .../propel/src/ui/menubar/menubar.stories.tsx | 12 ++++-------- packages/propel/src/ui/menubar/variants.ts | 16 +++++++++++++++- 5 files changed, 40 insertions(+), 17 deletions(-) create mode 100644 packages/propel/src/ui/menubar/menubar-trigger.tsx diff --git a/packages/propel/src/components/menubar/menubar.stories.tsx b/packages/propel/src/components/menubar/menubar.stories.tsx index 67aa6150..fbeaa377 100644 --- a/packages/propel/src/components/menubar/menubar.stories.tsx +++ b/packages/propel/src/components/menubar/menubar.stories.tsx @@ -2,8 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { Copy, FilePlus, FolderOpen, 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 } 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 @@ -11,15 +11,12 @@ import { Menubar } from "./index"; const meta = { title: "Components/Menubar", component: Menubar, - subcomponents: { Menu, MenuTrigger, MenuContent, MenuItem }, + subcomponents: { MenubarTrigger, 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 +33,7 @@ export const Default: Story = { render: () => ( - }>File + File } label="New file" /> } label="Open…" /> @@ -45,7 +42,7 @@ 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..f2a75239 100644 --- a/packages/propel/src/ui/menubar/index.tsx +++ b/packages/propel/src/ui/menubar/index.tsx @@ -1 +1,2 @@ export { Menubar, type MenubarProps } from "./menubar"; +export { MenubarTrigger, type MenubarTriggerProps } from "./menubar-trigger"; 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..e7812c12 --- /dev/null +++ b/packages/propel/src/ui/menubar/menubar-trigger.tsx @@ -0,0 +1,15 @@ +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. Bakes in all the "always the same" + * chrome from the designer's spec: height, inline padding, font style, 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..73533f44 100644 --- a/packages/propel/src/ui/menubar/menubar.stories.tsx +++ b/packages/propel/src/ui/menubar/menubar.stories.tsx @@ -8,9 +8,8 @@ import { MenuPortal, MenuPositioner, MenuSeparator, - MenuTrigger, } from "../menu/index"; -import { Menubar } from "./index"; +import { Menubar, MenubarTrigger } 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 @@ -18,15 +17,12 @@ import { Menubar } from "./index"; const meta = { title: "UI/Menubar", component: Menubar, - subcomponents: { Menu, MenuTrigger, MenuPortal, MenuPositioner, MenuPopup, MenuItem }, + subcomponents: { MenubarTrigger, 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 +39,7 @@ export const Default: Story = { render: () => ( - }>File + File @@ -56,7 +52,7 @@ 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..d7a29958 100644 --- a/packages/propel/src/ui/menubar/variants.ts +++ b/packages/propel/src/ui/menubar/variants.ts @@ -1,4 +1,4 @@ -import { cva } from "class-variance-authority"; +import { cva, cx } from "class-variance-authority"; // 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 +9,17 @@ 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, 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`) +// There are no adjustable axes, so no cva variants are needed. +export const menubarTriggerVariants = cva( + cx( + "inline-flex h-7 items-center rounded-sm px-2.5 text-13 text-secondary outline-none", + "data-popup-open:bg-layer-transparent-hover", + ), +); From 1bc1bd2f6fef3df40c48796a48cd754a82a40a31 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Tue, 23 Jun 2026 16:35:09 +0700 Subject: [PATCH 2/2] Extract MenubarTrigger anatomy: TriggerIcon + TriggerLabel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trigger was a single styled element with raw text children, leaving the designer's adjustable "whether items have icons" axis (issue #133) with nowhere to live — a consumer would have had to drop unstyled markup inside the trigger. Mirror the accordion exemplar: make MenubarTrigger a flex row that composes its anatomy parts, and add two single-element ui parts backed by their own cva: - MenubarTriggerIcon — the optional decorative leading icon slot (node-slot sizing to the trigger's --node-size; aria-hidden), the icon axis. - MenubarTriggerLabel — the trigger's label (truncates). Bake the remaining "always the same" chrome into menubarTriggerVariants (height, padding, gap, font, focus ring, popup-open + disabled states). Export both new parts and compose them in the UI and components stories, registering them in the subcomponents maps. No styling lives in the components tier. --- .../components/menubar/menubar.stories.tsx | 41 ++++++++++++++++--- packages/propel/src/ui/menubar/index.tsx | 2 + .../src/ui/menubar/menubar-trigger-icon.tsx | 18 ++++++++ .../src/ui/menubar/menubar-trigger-label.tsx | 13 ++++++ .../propel/src/ui/menubar/menubar-trigger.tsx | 9 ++-- .../propel/src/ui/menubar/menubar.stories.tsx | 26 +++++++++--- packages/propel/src/ui/menubar/variants.ts | 25 +++++++++-- 7 files changed, 114 insertions(+), 20 deletions(-) create mode 100644 packages/propel/src/ui/menubar/menubar-trigger-icon.tsx create mode 100644 packages/propel/src/ui/menubar/menubar-trigger-label.tsx diff --git a/packages/propel/src/components/menubar/menubar.stories.tsx b/packages/propel/src/components/menubar/menubar.stories.tsx index fbeaa377..ee596c29 100644 --- a/packages/propel/src/components/menubar/menubar.stories.tsx +++ b/packages/propel/src/components/menubar/menubar.stories.tsx @@ -1,17 +1,36 @@ 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 } from "../menu/index"; -import { Menubar, MenubarTrigger } from "./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: { MenubarTrigger, Menu, MenuContent, MenuItem }, + subcomponents: { + MenubarTrigger, + MenubarTriggerIcon, + MenubarTriggerLabel, + Menu, + MenuContent, + MenuItem, + }, } satisfies Meta; export default meta; @@ -33,7 +52,12 @@ export const Default: Story = { render: () => ( - File + + + + + File + } label="New file" /> } label="Open…" /> @@ -42,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 f2a75239..22fcdf78 100644 --- a/packages/propel/src/ui/menubar/index.tsx +++ b/packages/propel/src/ui/menubar/index.tsx @@ -1,2 +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 index e7812c12..a1efbc6c 100644 --- a/packages/propel/src/ui/menubar/menubar-trigger.tsx +++ b/packages/propel/src/ui/menubar/menubar-trigger.tsx @@ -5,10 +5,11 @@ import { menubarTriggerVariants } from "./variants"; export type MenubarTriggerProps = Omit; /** - * The styled trigger for a top-level menu item in the menu bar. Bakes in all the "always the same" - * chrome from the designer's spec: height, inline padding, font style, and the active/hover - * highlight when the popup is open. Renders inside a `Menu` root and receives keyboard navigation - * from the enclosing `Menubar`. + * 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 73533f44..4706ec61 100644 --- a/packages/propel/src/ui/menubar/menubar.stories.tsx +++ b/packages/propel/src/ui/menubar/menubar.stories.tsx @@ -9,15 +9,25 @@ import { MenuPositioner, MenuSeparator, } from "../menu/index"; -import { Menubar, MenubarTrigger } 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: { MenubarTrigger, Menu, MenuPortal, MenuPositioner, MenuPopup, MenuItem }, + subcomponents: { + MenubarTrigger, + MenubarTriggerLabel, + Menu, + MenuPortal, + MenuPositioner, + MenuPopup, + MenuItem, + }, } satisfies Meta; export default meta; @@ -39,7 +49,9 @@ export const Default: Story = { render: () => ( - File + + File + @@ -52,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 d7a29958..e19d404c 100644 --- a/packages/propel/src/ui/menubar/variants.ts +++ b/packages/propel/src/ui/menubar/variants.ts @@ -1,5 +1,7 @@ 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-*` // attributes, so there are no styling axes (variant/tone/magnitude) to expose. @@ -11,15 +13,30 @@ export const menubarVariants = cva( ); // MenubarTrigger: the styled button for each top-level menu in the bar. -// Per the designer's spec, all of the following are "always the same": +// 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`) -// There are no adjustable axes, so no cva variants are needed. +// – 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( - "inline-flex h-7 items-center rounded-sm px-2.5 text-13 text-secondary outline-none", - "data-popup-open:bg-layer-transparent-hover", + "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");