Skip to content
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
46 changes: 36 additions & 10 deletions packages/propel/src/components/menubar/menubar.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Menubar>;

export default meta;
type Story = StoryObj<typeof meta>;

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: {
Expand All @@ -36,7 +52,12 @@ export const Default: Story = {
render: () => (
<Menubar>
<Menu>
<MenuTrigger render={<button type="button" className={triggerClass} />}>File</MenuTrigger>
<MenubarTrigger>
<MenubarTriggerIcon>
<FilePen />
</MenubarTriggerIcon>
<MenubarTriggerLabel>File</MenubarTriggerLabel>
</MenubarTrigger>
<MenuContent width="sm">
<MenuItem variant="default" inlineStartNode={<FilePlus />} label="New file" />
<MenuItem variant="default" inlineStartNode={<FolderOpen />} label="Open…" />
Expand All @@ -45,7 +66,12 @@ export const Default: Story = {
</MenuContent>
</Menu>
<Menu>
<MenuTrigger render={<button type="button" className={triggerClass} />}>Edit</MenuTrigger>
<MenubarTrigger>
<MenubarTriggerIcon>
<Pencil />
</MenubarTriggerIcon>
<MenubarTriggerLabel>Edit</MenubarTriggerLabel>
</MenubarTrigger>
<MenuContent width="sm">
<MenuItem variant="default" inlineStartNode={<Undo2 />} label="Undo" />
<MenuItem variant="default" inlineStartNode={<Redo2 />} label="Redo" />
Expand Down
3 changes: 3 additions & 0 deletions packages/propel/src/ui/menubar/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
18 changes: 18 additions & 0 deletions packages/propel/src/ui/menubar/menubar-trigger-icon.tsx
Original file line number Diff line number Diff line change
@@ -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 <span aria-hidden className={menubarTriggerIconVariants()} {...props} />;
}
13 changes: 13 additions & 0 deletions packages/propel/src/ui/menubar/menubar-trigger-label.tsx
Original file line number Diff line number Diff line change
@@ -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 <span className={menubarTriggerLabelVariants()} {...props} />;
}
16 changes: 16 additions & 0 deletions packages/propel/src/ui/menubar/menubar-trigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Menu as BaseMenu } from "@base-ui/react/menu";

import { menubarTriggerVariants } from "./variants";

export type MenubarTriggerProps = Omit<BaseMenu.Trigger.Props, "className" | "style">;

/**
* 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 <BaseMenu.Trigger className={menubarTriggerVariants()} {...props} />;
}
30 changes: 20 additions & 10 deletions packages/propel/src/ui/menubar/menubar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Menubar>;

export default meta;
type Story = StoryObj<typeof meta>;

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: {
Expand All @@ -43,7 +49,9 @@ export const Default: Story = {
render: () => (
<Menubar>
<Menu>
<MenuTrigger render={<button type="button" className={triggerClass} />}>File</MenuTrigger>
<MenubarTrigger>
<MenubarTriggerLabel>File</MenubarTriggerLabel>
</MenubarTrigger>
<MenuPortal>
<MenuPositioner sideOffset={4}>
<MenuPopup>
Expand All @@ -56,7 +64,9 @@ export const Default: Story = {
</MenuPortal>
</Menu>
<Menu>
<MenuTrigger render={<button type="button" className={triggerClass} />}>Edit</MenuTrigger>
<MenubarTrigger>
<MenubarTriggerLabel>Edit</MenubarTriggerLabel>
</MenubarTrigger>
<MenuPortal>
<MenuPositioner sideOffset={4}>
<MenuPopup>
Expand Down
33 changes: 32 additions & 1 deletion packages/propel/src/ui/menubar/variants.ts
Original file line number Diff line number Diff line change
@@ -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-*`
Expand All @@ -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");