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
78 changes: 33 additions & 45 deletions packages/propel/src/components/toolbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,42 +17,35 @@ import {
type DropdownSeparatorProps,
} from "../dropdown/index";

// The Figma "Toolbar" component has a single `variant` axis describing where the
// toolbar is placed (Figma: Floater / Pages - Topbar / Comments bottom bar). The
// variants differ in two ways: their *surface* and their *density*. Only the
// floater is a self-contained surface — a white `surface-1` card with a subtle
// border, `radius/lg` corners and an `Overlay-100` shadow — so it can hover over
// content. Topbar and bottom-bar sit flush inside an existing bar, so they're flat
// (no surface, no shadow) and are therefore visually identical to each other.
// A toolbar is described by two orthogonal axes — `elevation` and `density` — named
// for what they actually express rather than where the toolbar is placed (placement
// is the consumer's job, not a primitive's). The Figma "Toolbar" component models
// these as placements (Floater / Pages - Topbar / Comments bottom bar), but those
// collapse to combinations of the two axes here.
//
// Density differs too: the floater packs controls tighter (24px hit targets, a
// 14px chevron) while the topbar/bottom-bar are roomier (28px hit targets, a 16px
// chevron). Every placement shares the root `p-1.5` (6px) padding and `gap-2` (8px)
// item gap; clusters inside `ToolbarGroup`/`ToolbarToggleGroup` keep a tight
// `gap-0.5` (2px).
// `elevation`: whether the toolbar draws its own surface. `raised` is a
// self-contained card — a white `surface-1` fill with a subtle border, `radius/lg`
// corners and an `Overlay-100` shadow — so it can hover over content (the Figma
// floater). `flat` draws no surface and sits flush inside an existing bar (the Figma
// topbar / bottom-bar, which were visually identical).
const toolbarVariants = cva("flex w-fit items-center gap-2 p-1.5 text-secondary", {
variants: {
variant: {
floater: surfaceVariants({ elevation: "raised", radius: "lg" }),
topbar: "",
"bottom-bar": "",
elevation: {
raised: surfaceVariants({ elevation: "raised", radius: "lg" }),
flat: "",
},
},
});

export type ToolbarVariant = NonNullable<VariantProps<typeof toolbarVariants>["variant"]>;
export type ToolbarElevation = NonNullable<VariantProps<typeof toolbarVariants>["elevation"]>;

// The density of a toolbar follows its placement: the `floater` is compact (24px
// hit targets), while `topbar`/`bottom-bar` are comfortable (28px). It's derived
// from `variant` and shared with the child controls (buttons, toggles, dropdown
// trigger) through context so they size themselves to match the root.
type ToolbarDensity = "compact" | "comfortable";

const DENSITY_BY_VARIANT: Record<ToolbarVariant, ToolbarDensity> = {
floater: "compact",
topbar: "comfortable",
"bottom-bar": "comfortable",
};
// `density`: how tightly the controls pack. `compact` is 24px hit targets (a 14px
// chevron), `comfortable` is 28px (a 16px chevron). It's shared with the child
// controls (buttons, toggles, dropdown trigger) through context so they size
// themselves to match the root. Both axes share the root `p-1.5` (6px) padding and
// `gap-2` (8px) item gap; clusters inside `ToolbarGroup`/`ToolbarToggleGroup` keep a
// tight `gap-0.5` (2px).
export type ToolbarDensity = "compact" | "comfortable";

const ToolbarDensityContext = React.createContext<ToolbarDensity>("compact");

Expand All @@ -61,12 +54,16 @@ export type ToolbarProps = Omit<
"className" | "render" | "style"
> & {
/**
* Where the toolbar is placed, which controls its surface and density. `floater`
* is a self-contained card with a border + shadow that hovers over content and
* packs its controls tightly (24px). `topbar` and `bottom-bar` are flat, sit flush
* inside an existing bar, and use the roomier 28px density.
* Whether the toolbar draws its own surface. `raised` is a self-contained card
* with a border + shadow that hovers over content; `flat` draws no surface and
* sits flush inside an existing bar. Independent of `density`.
*/
elevation: ToolbarElevation;
/**
* How tightly the controls pack. `compact` is 24px hit targets, `comfortable` is
* 28px. Independent of `elevation`, so a flat bar can still be compact.
*/
variant: ToolbarVariant;
density: ToolbarDensity;
};

/**
Expand All @@ -76,10 +73,10 @@ export type ToolbarProps = Omit<
* carries `role="toolbar"`. Compose it from `ToolbarGroup`, `ToolbarButton`,
* `ToolbarToggle`, `ToolbarSeparator` and `ToolbarDropdown`.
*/
export function Toolbar({ variant, ...props }: ToolbarProps) {
export function Toolbar({ elevation, density, ...props }: ToolbarProps) {
return (
<ToolbarDensityContext.Provider value={DENSITY_BY_VARIANT[variant]}>
<BaseToolbar.Root className={toolbarVariants({ variant })} {...props} />
<ToolbarDensityContext.Provider value={density}>
<BaseToolbar.Root className={toolbarVariants({ elevation })} {...props} />
</ToolbarDensityContext.Provider>
);
}
Expand Down Expand Up @@ -121,9 +118,6 @@ const itemVariants = cva(
comfortable: "size-7 [&_svg]:size-4",
},
},
defaultVariants: {
density: "compact",
},
},
);

Expand Down Expand Up @@ -226,9 +220,6 @@ const dropdownTriggerVariants = cva(
comfortable: "h-7",
},
},
defaultVariants: {
density: "compact",
},
},
);

Expand All @@ -239,9 +230,6 @@ const dropdownChevronVariants = cva("shrink-0 text-icon-secondary", {
comfortable: "size-4",
},
},
defaultVariants: {
density: "compact",
},
});

/**
Expand Down
76 changes: 66 additions & 10 deletions packages/propel/src/components/toolbar/toolbar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ const meta = {
ToolbarDropdownItem,
ToolbarDropdownSeparator,
},
args: { variant: "floater" },
args: { elevation: "raised", density: "compact" },
render: (args) => <FormattingToolbar {...args} />,
parameters: {
design: {
Expand All @@ -156,21 +156,77 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;

/** The default `floater`: a self-contained card with a border + shadow. */
/** The default: a `raised`, `compact` floater — a self-contained card with a border + shadow. */
export const Default: Story = {};

/** The three placements: a floating card, a flat topbar, and a flat bottom bar. */
export const Variants: Story = {
parameters: { controls: { disable: true } },
render: () => (
/**
* The `elevation` axis: `raised` draws its own card (border + shadow) so it can hover
* over content; `flat` draws no surface and sits flush inside an existing bar.
* Independent of `density` — both rows keep the story's current density.
*/
export const Elevations: Story = {
argTypes: { elevation: { control: false } },
render: (args) => (
<div className="flex flex-col gap-6">
<FormattingToolbar {...args} elevation="raised" />
<FormattingToolbar {...args} elevation="flat" />
</div>
),
};

/**
* The `density` axis: `compact` packs the controls to 24px hit targets, `comfortable`
* gives them 28px. Independent of `elevation` — both rows keep the story's current
* elevation.
*/
export const Densities: Story = {
argTypes: { density: { control: false } },
render: (args) => (
<div className="flex flex-col gap-6">
<FormattingToolbar variant="floater" />
<FormattingToolbar variant="topbar" />
<FormattingToolbar variant="bottom-bar" />
<FormattingToolbar {...args} density="compact" />
<FormattingToolbar {...args} density="comfortable" />
</div>
),
};

/**
* `elevation` and `density` are orthogonal: a `flat` bar can still be `compact`. This
* is Figma's "fixed + compact" bar — a non-floating surface at the tight 24px density
* the raised floater also uses.
*/
export const FlatCompact: Story = {
args: { elevation: "flat", density: "compact" },
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/ioN74zM1xMGbcPemsxs4J1/Global-components?node-id=2842-3905",
},
},
};

/**
* Density wiring check that runs in the browser: `density` drives the child controls'
* size through context, independent of `elevation`, so a `flat` + `compact` toolbar
* renders 24px controls. Tagged out of the sidebar/docs/manifest — it's a test, not a
* designer- or agent-facing example — but still runs under the default `test` tag.
*/
export const DensityDrivesControlSize: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
render: () => (
<Toolbar elevation="flat" density="compact">
<ToolbarToggle aria-label="Bold">
<Bold aria-hidden />
</ToolbarToggle>
</Toolbar>
),
play: async ({ canvas }) => {
// density="compact" sizes the controls to 24px regardless of the flat elevation.
const bold = canvas.getByRole("button", { name: "Bold" });
await expect(bold).toHaveClass("size-6");
await expect(getComputedStyle(bold).height).toBe("24px");
},
};

/**
* Because `ToolbarDropdown` composes propel's `Dropdown`, a toolbar menu can hold
* richer rows than the old `items[]` config allowed: per-row leading icons, a
Expand Down Expand Up @@ -240,7 +296,7 @@ export const Behavior: Story = {
export const KeyboardRovingFocus: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
render: () => (
<Toolbar variant="floater">
<Toolbar elevation="raised" density="compact">
<ToolbarToggle aria-label="Bold">
<Bold aria-hidden />
</ToolbarToggle>
Expand Down
4 changes: 2 additions & 2 deletions packages/propel/src/patterns/comment.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const bodyVariants = cva(
// editor; here they just compose propel's Toolbar parts.
function FormattingToolbar() {
return (
<Toolbar variant="bottom-bar" aria-label="Comment formatting">
<Toolbar elevation="flat" density="compact" aria-label="Comment formatting">
<ToolbarGroup aria-label="Insert">
<ToolbarButton aria-label="Mention someone">
<AtSign aria-hidden />
Expand Down Expand Up @@ -248,7 +248,7 @@ const RECIPE_SOURCE = `function CommentComposer() {
className="min-h-10 w-full resize-none bg-transparent p-3 text-14 leading-snug text-primary outline-none placeholder:text-placeholder"
/>
<div className="flex min-h-9 items-center justify-between gap-2 py-1 pe-1.5 ps-1">
<Toolbar variant="bottom-bar" aria-label="Comment formatting">
<Toolbar elevation="flat" density="compact" aria-label="Comment formatting">
<ToolbarGroup aria-label="Insert">
<ToolbarButton aria-label="Mention someone">
<AtSign aria-hidden />
Expand Down