Skip to content

Commit 5ae6997

Browse files
committed
Toolbar: elevation + density as orthogonal axes
Replace the placement-named `variant` (floater/topbar/bottom-bar) with two axes named for what they actually express: - `elevation`: `raised` draws its own card (border + shadow) so it hovers over content; `flat` draws no surface and sits flush in a host bar. topbar and bottom-bar rendered identically, so they fold into one `flat`. - `density`: `compact` (24px hit targets) vs `comfortable` (28px). Both are required props, so the variant->density derivation table and the `density ?? ...` fallback are gone. "Fixed + compact" stops being a special case — it's just `elevation="flat" density="compact"`. Placement is the consumer's job, not the primitive's, so it no longer lives on the component. Also drop the now-dead `defaultVariants` on the internal item/dropdown recipes: density always arrives through context. Breaking: `variant` is removed. floater -> elevation="raised" density="compact"; topbar/bottom-bar -> elevation="flat" density="comfortable".
1 parent 560e26e commit 5ae6997

3 files changed

Lines changed: 70 additions & 79 deletions

File tree

packages/propel/src/components/toolbar/index.tsx

Lines changed: 30 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -17,65 +17,53 @@ import {
1717
type DropdownSeparatorProps,
1818
} from "../dropdown/index";
1919

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

43-
export type ToolbarVariant = NonNullable<VariantProps<typeof toolbarVariants>["variant"]>;
40+
export type ToolbarElevation = NonNullable<VariantProps<typeof toolbarVariants>["elevation"]>;
4441

45-
// The density of a toolbar defaults from its placement: the `floater` is compact
46-
// (24px hit targets), while `topbar`/`bottom-bar` are comfortable (28px). The
47-
// resolved density is shared with the child controls (buttons, toggles, dropdown
48-
// trigger) through context so they size themselves to match the root. Callers can
49-
// pass `density` on the root to override the placement default — e.g. Figma's flat
50-
// "fixed + compact" bar is a `topbar` forced to `compact`.
42+
// `density`: how tightly the controls pack. `compact` is 24px hit targets (a 14px
43+
// chevron), `comfortable` is 28px (a 16px chevron). It's shared with the child
44+
// controls (buttons, toggles, dropdown trigger) through context so they size
45+
// themselves to match the root. Both axes share the root `p-1.5` (6px) padding and
46+
// `gap-2` (8px) item gap; clusters inside `ToolbarGroup`/`ToolbarToggleGroup` keep a
47+
// tight `gap-0.5` (2px).
5148
type ToolbarDensity = "compact" | "comfortable";
5249

53-
const DENSITY_BY_VARIANT: Record<ToolbarVariant, ToolbarDensity> = {
54-
floater: "compact",
55-
topbar: "comfortable",
56-
"bottom-bar": "comfortable",
57-
};
58-
5950
const ToolbarDensityContext = React.createContext<ToolbarDensity>("compact");
6051

6152
export type ToolbarProps = Omit<
6253
React.ComponentProps<typeof BaseToolbar.Root>,
6354
"className" | "render" | "style"
6455
> & {
6556
/**
66-
* Where the toolbar is placed, which controls its surface and the default density.
67-
* `floater` is a self-contained card with a border + shadow that hovers over content
68-
* and packs its controls tightly (24px). `topbar` and `bottom-bar` are flat, sit flush
69-
* inside an existing bar, and default to the roomier 28px density.
57+
* Whether the toolbar draws its own surface. `raised` is a self-contained card
58+
* with a border + shadow that hovers over content; `flat` draws no surface and
59+
* sits flush inside an existing bar. Independent of `density`.
7060
*/
71-
variant: ToolbarVariant;
61+
elevation: ToolbarElevation;
7262
/**
73-
* Hit-target density of the toolbar's controls. Defaults to the placement default
74-
* (`floater` -> `compact`, `topbar`/`bottom-bar` -> `comfortable`); pass it to override
75-
* the default and decouple density from `variant` — e.g. a flat `topbar` forced to
76-
* `compact` for Figma's "fixed + compact" bar.
63+
* How tightly the controls pack. `compact` is 24px hit targets, `comfortable` is
64+
* 28px. Independent of `elevation`, so a flat bar can still be compact.
7765
*/
78-
density?: ToolbarDensity;
66+
density: ToolbarDensity;
7967
};
8068

8169
/**
@@ -85,10 +73,10 @@ export type ToolbarProps = Omit<
8573
* carries `role="toolbar"`. Compose it from `ToolbarGroup`, `ToolbarButton`,
8674
* `ToolbarToggle`, `ToolbarSeparator` and `ToolbarDropdown`.
8775
*/
88-
export function Toolbar({ variant, density, ...props }: ToolbarProps) {
76+
export function Toolbar({ elevation, density, ...props }: ToolbarProps) {
8977
return (
90-
<ToolbarDensityContext.Provider value={density ?? DENSITY_BY_VARIANT[variant]}>
91-
<BaseToolbar.Root className={toolbarVariants({ variant })} {...props} />
78+
<ToolbarDensityContext.Provider value={density}>
79+
<BaseToolbar.Root className={toolbarVariants({ elevation })} {...props} />
9280
</ToolbarDensityContext.Provider>
9381
);
9482
}
@@ -130,9 +118,6 @@ const itemVariants = cva(
130118
comfortable: "size-7 [&_svg]:size-4",
131119
},
132120
},
133-
defaultVariants: {
134-
density: "compact",
135-
},
136121
},
137122
);
138123

@@ -235,9 +220,6 @@ const dropdownTriggerVariants = cva(
235220
comfortable: "h-7",
236221
},
237222
},
238-
defaultVariants: {
239-
density: "compact",
240-
},
241223
},
242224
);
243225

@@ -248,9 +230,6 @@ const dropdownChevronVariants = cva("shrink-0 text-icon-secondary", {
248230
comfortable: "size-4",
249231
},
250232
},
251-
defaultVariants: {
252-
density: "compact",
253-
},
254233
});
255234

256235
/**

packages/propel/src/components/toolbar/toolbar.stories.tsx

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ const meta = {
143143
ToolbarDropdownItem,
144144
ToolbarDropdownSeparator,
145145
},
146-
args: { variant: "floater" },
146+
args: { elevation: "raised", density: "compact" },
147147
render: (args) => <FormattingToolbar {...args} />,
148148
parameters: {
149149
design: {
@@ -156,33 +156,46 @@ const meta = {
156156
export default meta;
157157
type Story = StoryObj<typeof meta>;
158158

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

162162
/**
163-
* Each placement at its default density: the floating card is `compact` (24px), the
164-
* flat topbar and bottom bar are `comfortable` (28px). Density follows `variant`
165-
* unless overridden (see `FixedCompact`).
163+
* The `elevation` axis: `raised` draws its own card (border + shadow) so it can hover
164+
* over content; `flat` draws no surface and sits flush inside an existing bar.
165+
* Independent of `density` — both rows keep the story's current density.
166166
*/
167-
export const Variants: Story = {
168-
parameters: { controls: { disable: true } },
169-
render: () => (
167+
export const Elevations: Story = {
168+
argTypes: { elevation: { control: false } },
169+
render: (args) => (
170+
<div className="flex flex-col gap-6">
171+
<FormattingToolbar {...args} elevation="raised" />
172+
<FormattingToolbar {...args} elevation="flat" />
173+
</div>
174+
),
175+
};
176+
177+
/**
178+
* The `density` axis: `compact` packs the controls to 24px hit targets, `comfortable`
179+
* gives them 28px. Independent of `elevation` — both rows keep the story's current
180+
* elevation.
181+
*/
182+
export const Densities: Story = {
183+
argTypes: { density: { control: false } },
184+
render: (args) => (
170185
<div className="flex flex-col gap-6">
171-
<FormattingToolbar variant="floater" />
172-
<FormattingToolbar variant="topbar" />
173-
<FormattingToolbar variant="bottom-bar" />
186+
<FormattingToolbar {...args} density="compact" />
187+
<FormattingToolbar {...args} density="comfortable" />
174188
</div>
175189
),
176190
};
177191

178192
/**
179-
* Density is its own axis: pass `density` to decouple it from `variant`. Here a flat
180-
* `topbar` is forced to `compact` (24px) for Figma's "fixed + compact" bar — a
181-
* non-floating surface at the tight floater density. Without the override a `topbar`
182-
* would default to `comfortable` (28px).
193+
* `elevation` and `density` are orthogonal: a `flat` bar can still be `compact`. This
194+
* is Figma's "fixed + compact" bar — a non-floating surface at the tight 24px density
195+
* the raised floater also uses.
183196
*/
184-
export const FixedCompact: Story = {
185-
args: { variant: "topbar", density: "compact" },
197+
export const FlatCompact: Story = {
198+
args: { elevation: "flat", density: "compact" },
186199
parameters: {
187200
design: {
188201
type: "figma",
@@ -192,23 +205,22 @@ export const FixedCompact: Story = {
192205
};
193206

194207
/**
195-
* Density override check that runs in the browser: a flat `topbar` defaults to
196-
* `comfortable` (28px), but passing `density="compact"` decouples density from
197-
* `variant` and shrinks its controls to 24px. Tagged out of the sidebar/docs/manifest
198-
* — it's a test, not a designer- or agent-facing example — but still runs under the
199-
* default `test` tag.
208+
* Density wiring check that runs in the browser: `density` drives the child controls'
209+
* size through context, independent of `elevation`, so a `flat` + `compact` toolbar
210+
* renders 24px controls. Tagged out of the sidebar/docs/manifest — it's a test, not a
211+
* designer- or agent-facing example — but still runs under the default `test` tag.
200212
*/
201-
export const DensityOverride: Story = {
213+
export const DensityDrivesControlSize: Story = {
202214
tags: ["!dev", "!autodocs", "!manifest"],
203215
render: () => (
204-
<Toolbar variant="topbar" density="compact">
216+
<Toolbar elevation="flat" density="compact">
205217
<ToolbarToggle aria-label="Bold">
206218
<Bold aria-hidden />
207219
</ToolbarToggle>
208220
</Toolbar>
209221
),
210222
play: async ({ canvas }) => {
211-
// A flat topbar would default to comfortable (28px); the compact override wins.
223+
// density="compact" sizes the controls to 24px regardless of the flat elevation.
212224
const bold = canvas.getByRole("button", { name: "Bold" });
213225
await expect(bold).toHaveClass("size-6");
214226
await expect(getComputedStyle(bold).height).toBe("24px");
@@ -284,7 +296,7 @@ export const Behavior: Story = {
284296
export const KeyboardRovingFocus: Story = {
285297
tags: ["!dev", "!autodocs", "!manifest"],
286298
render: () => (
287-
<Toolbar variant="floater">
299+
<Toolbar elevation="raised" density="compact">
288300
<ToolbarToggle aria-label="Bold">
289301
<Bold aria-hidden />
290302
</ToolbarToggle>

packages/propel/src/patterns/comment.stories.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ const bodyVariants = cva(
7070
// editor; here they just compose propel's Toolbar parts.
7171
function FormattingToolbar() {
7272
return (
73-
<Toolbar variant="bottom-bar" aria-label="Comment formatting">
73+
<Toolbar elevation="flat" density="comfortable" aria-label="Comment formatting">
7474
<ToolbarGroup aria-label="Insert">
7575
<ToolbarButton aria-label="Mention someone">
7676
<AtSign aria-hidden />
@@ -248,7 +248,7 @@ const RECIPE_SOURCE = `function CommentComposer() {
248248
className="min-h-10 w-full resize-none bg-transparent p-3 text-14 leading-snug text-primary outline-none placeholder:text-placeholder"
249249
/>
250250
<div className="flex min-h-9 items-center justify-between gap-2 py-1 pe-1.5 ps-1">
251-
<Toolbar variant="bottom-bar" aria-label="Comment formatting">
251+
<Toolbar elevation="flat" density="comfortable" aria-label="Comment formatting">
252252
<ToolbarGroup aria-label="Insert">
253253
<ToolbarButton aria-label="Mention someone">
254254
<AtSign aria-hidden />

0 commit comments

Comments
 (0)