diff --git a/packages/propel/src/components/dropdown/dropdown.stories.tsx b/packages/propel/src/components/dropdown/dropdown.stories.tsx
index dce11668..9fb70620 100644
--- a/packages/propel/src/components/dropdown/dropdown.stories.tsx
+++ b/packages/propel/src/components/dropdown/dropdown.stories.tsx
@@ -210,7 +210,7 @@ export const Status: Story = {
}
+ inlineStartNode={}
label={`Add label "${trimmed}"`}
closeOnClick={false}
/>
@@ -271,7 +271,7 @@ export const Labels: Story = {
{visible.map((l) => (
}
+ inlineStartNode={}
label={l.label}
checked={Boolean(checked[l.key])}
onCheckedChange={(next) => setChecked((c) => ({ ...c, [l.key]: next }))}
@@ -312,13 +312,22 @@ export const ActionMenu: Story = {
Actions
- } label="Edit" />
- } label="Make a copy" />
- } label="Open in new tab" />
- } label="Copy link" value="⌘L" />
+ } label="Edit" />
+ } label="Make a copy" />
+ }
+ label="Open in new tab"
+ />
+ }
+ label="Copy link"
+ inlineEndNode={⌘L}
+ />
}
+ inlineStartNode={}
label="Archive"
variant="with-description"
description="Only completed or cancelled work items can be archived"
@@ -327,7 +336,7 @@ export const ActionMenu: Story = {
}
+ inlineStartNode={}
label={Delete}
/>
@@ -394,7 +403,7 @@ export const Description: Story = {
}
+ inlineStartNode={}
label="Private"
variant="with-description"
description="Accessible only by invite"
@@ -403,7 +412,7 @@ export const Description: Story = {
onClick={() => setSelected("private")}
/>
}
+ inlineStartNode={}
label="Public"
variant="with-description"
description="Anyone in the workspace except Guests can join"
@@ -448,7 +457,7 @@ export const Assignees: Story = {
{visible.map((a) => (
}
+ inlineStartNode={}
label={a.name}
checked={Boolean(checked[a.key])}
disabled={a.disabled}
@@ -547,7 +556,7 @@ export const Priority: Story = {
{visible.map((p) => (
setChecked((c) => ({ ...c, [p.key]: next }))}
@@ -592,7 +601,7 @@ export const CheckedFillVisible: Story = {
{PRIORITIES.map((p) => (
setChecked((c) => ({ ...c, [p.key]: next }))}
@@ -701,12 +710,13 @@ export const Filters: Story = {
{/* The category heading is itself a menuitem (valid `role="menu"`
child) so its collapse chevron stays interactive without breaking
- ARIA. The label is the section title; the chevron is the endIcon. */}
+ ARIA. The label is the section title; the chevron is the
+ inlineEndNode. */}
) : (
@@ -720,7 +730,7 @@ export const Filters: Story = {
? visibleItems.map((i) => (
{visible.length > 0 ? (
visible.map((s) => (
-
+
))
) : (
No matching results
@@ -863,7 +878,7 @@ export const Submenu: Story = {
5
@@ -874,7 +889,7 @@ export const Submenu: Story = {
@@ -884,7 +899,7 @@ export const Submenu: Story = {
5
@@ -895,7 +910,7 @@ export const Submenu: Story = {
@@ -905,7 +920,7 @@ export const Submenu: Story = {
5
@@ -916,7 +931,9 @@ export const Submenu: Story = {
}
+ inlineStartNode={
+
+ }
label={a.name}
closeOnClick={false}
/>
diff --git a/packages/propel/src/components/dropdown/index.tsx b/packages/propel/src/components/dropdown/index.tsx
index 8e3efc3a..f48473e5 100644
--- a/packages/propel/src/components/dropdown/index.tsx
+++ b/packages/propel/src/components/dropdown/index.tsx
@@ -2,6 +2,7 @@ import { Menu } from "@base-ui/react/menu";
import { cva, cx, type VariantProps } from "class-variance-authority";
import { Check, ChevronRight, Search } from "lucide-react";
import * as React from "react";
+import { nodeSlotClass } from "../../internal/node-slot";
import { OverlayPanel, type OverlayPanelWidth } from "../../internal/overlay-panel";
import { CheckboxVisual } from "../checkbox/index";
@@ -123,14 +124,15 @@ export function DropdownContent({
);
}
-// One row. `variant` is the Figma layout axis: `default` is a single line,
-// `with-description` stacks a muted second line, `with-value` reserves a trailing
-// value column. `selected`/`disabled` are primitive state, not variants. Hover &
-// keyboard-highlight share the subtle transparent overlay; disabled fades the row.
+// One row. `variant` is the Figma layout axis: `default` is a single line and
+// `with-description` stacks a muted second line. `selected`/`disabled` are primitive
+// state, not variants. Hover & keyboard-highlight share the subtle transparent
+// overlay; disabled fades the row.
const dropdownItemVariants = cva(
cx(
- // Items are 34px tall (Figma); `rounded-md` radius per design.
- "group/item flex w-full select-none gap-2 rounded-md px-2 text-13 outline-none",
+ // Items are 34px tall (Figma); `rounded-md` radius per design. `--node-size` sizes
+ // the icons dropped into the inline-start/end node slots (16px), like Button.
+ "group/item flex w-full select-none gap-2 rounded-md px-2 text-13 outline-none [--node-size:1rem]",
"text-secondary",
"data-disabled:pointer-events-none data-disabled:text-disabled",
),
@@ -143,7 +145,6 @@ const dropdownItemVariants = cva(
// (align-start) so it sits with the first line; the row grows for the
// description and keeps a matching 6px bottom.
"with-description": "min-h-[34px] items-start py-1.5",
- "with-value": "h-[34px] items-center",
},
// `default` is a normal selectable row (highlight on hover/keyboard). `link`
// is a "View all"-style affordance: a pointer cursor and no hover background.
@@ -164,36 +165,30 @@ export type DropdownItemProps = Omit<
> & {
/**
* Row layout (required). `default` is a single line; `with-description` stacks a
- * muted second line under the label; `with-value` reserves a trailing value slot.
- * Selected/disabled are state props, not variants.
+ * muted second line under the label. Selected/disabled are state props, not
+ * variants.
*/
variant: DropdownItemVariant;
- /** Leading content, typically an icon. Rendered before the label. */
- icon?: React.ReactNode;
/**
- * Leading control rendered *before* the icon at full size (no icon box) — use
- * for a composed propel control such as a `Checkbox`, `Radio`, `Avatar`, or a
- * color swatch. Single-select rows should use `selected` instead.
+ * Leading content before the label — an icon (sized to 16px), or a full-size control
+ * such as a `Checkbox`, `Radio`, `Avatar`, or a color swatch. Single-select rows
+ * should use `selected` instead. (Logical node slot, like Button's `inlineStartNode`.)
*/
- leading?: React.ReactNode;
+ inlineStartNode?: React.ReactNode;
/** The primary text of the row. */
label?: React.ReactNode;
/** Muted secondary line under the label (use with `variant="with-description"`). */
description?: React.ReactNode;
/**
* Muted text shown inline after the label (e.g. a language's English name). Sits
- * between the label and any trailing value, on the same line.
+ * between the label and the trailing node, on the same line as the label.
*/
secondaryText?: React.ReactNode;
- /** Trailing value text (use with `variant="with-value"`). */
- value?: React.ReactNode;
/**
- * Trailing content after the value slot (e.g. a `Badge` count, a chevron, or a
- * keyboard shortcut). Use instead of (or alongside) `value` for rich content.
+ * Trailing content after the label — a `Badge` count, a keyboard shortcut, value
+ * text, or an icon (sized to 16px). (Logical node slot, like Button's `inlineEndNode`.)
*/
- trailing?: React.ReactNode;
- /** Trailing content after the value slot, typically an icon or shortcut. */
- endIcon?: React.ReactNode;
+ inlineEndNode?: React.ReactNode;
/**
* Single-select selected state: keeps the row's own icon and marks the selection
* with a trailing checkmark (the row's icon is never replaced). Distinct from the
@@ -209,36 +204,28 @@ export type DropdownItemProps = Omit<
};
/**
- * A selectable menu row. Closes the menu when clicked (Base UI default). An item is
- * an optional leading control/icon + label (+ optional description / inline
- * secondary text / trailing value / badge / end icon) — all of it content, laid out
- * by `variant`. Pass `selected` for the single-select leading-checkmark pattern.
+ * A selectable menu row: an optional `inlineStartNode` (icon or full-size control) +
+ * label (+ optional description / inline secondary text / `inlineEndNode`) — all of it
+ * content, laid out by `variant`. Closes the menu when clicked (Base UI default). Pass
+ * `selected` for the single-select trailing-checkmark pattern.
*/
export function DropdownItem({
variant,
emphasis = "default",
- icon,
- leading,
+ inlineStartNode,
label,
description,
secondaryText,
- value,
- trailing,
- endIcon,
+ inlineEndNode,
selected,
children,
...props
}: DropdownItemProps) {
return (
- {leading != null ? {leading} : null}
- {icon ? (
- // 16px icon centered in a 20px-tall box so it aligns with the first text line
- // even when the row is align-start (top-aligned) for a two-line layout.
-
- {icon}
-
- ) : null}
+ {/* The node slots size icons to `--node-size` (16px) and let full-size controls
+ (Checkbox/Avatar/swatch) size themselves; the row's text color cascades. */}
+ {inlineStartNode != null ? {inlineStartNode} : null}
{label ?? children}
@@ -254,14 +241,8 @@ export function DropdownItem({
) : null}
- {value != null ? {value} : null}
- {trailing != null ? {trailing} : null}
- {endIcon ? (
-
- {endIcon}
-
- ) : null}
- {/* Selection is marked with a trailing check — the row's own icon is kept. */}
+ {inlineEndNode != null ? {inlineEndNode} : null}
+ {/* Selection is marked with a trailing check — the row's own node is kept. */}
{selected ? (
@@ -276,15 +257,15 @@ export type DropdownCheckboxItemProps = Omit<
"className" | "style" | "label"
> & {
/**
- * Leading content shown after the checkbox — typically an icon, a color swatch,
- * an `Avatar`, or a priority glyph. Use it to compose the propel components a
- * multi-select demo calls for (Avatar for assignees, swatch for labels, etc.).
+ * Leading content shown after the checkbox — an icon (16px), a color swatch, an
+ * `Avatar`, or a priority glyph (Avatar for assignees, swatch for labels, etc.).
+ * (Logical node slot, like Button's `inlineStartNode`.)
*/
- icon?: React.ReactNode;
+ inlineStartNode?: React.ReactNode;
/** The primary text of the row. */
label?: React.ReactNode;
- /** Trailing value text. */
- value?: React.ReactNode;
+ /** Trailing content — typically value text or a count. (Logical node slot.) */
+ inlineEndNode?: React.ReactNode;
};
/**
@@ -295,9 +276,9 @@ export type DropdownCheckboxItemProps = Omit<
* with `checked` + `onCheckedChange`, or leave it uncontrolled with `defaultChecked`.
*/
export function DropdownCheckboxItem({
- icon,
+ inlineStartNode,
label,
- value,
+ inlineEndNode,
checked,
defaultChecked,
onCheckedChange,
@@ -315,7 +296,7 @@ export function DropdownCheckboxItem({
return (
- {icon ? (
-
- {icon}
-
- ) : null}
+ {inlineStartNode != null ? {inlineStartNode} : null}
{label ?? children}
- {value != null ? {value} : null}
+ {inlineEndNode != null ? (
+ {inlineEndNode}
+ ) : null}
);
}
@@ -377,10 +356,10 @@ export type DropdownLabelProps = Omit<
"className" | "style"
> & {
/**
- * Optional trailing slot on the heading row — e.g. a "View all" link or a count.
+ * Optional trailing content on the heading row — e.g. a "View all" link or a count.
* Sits at the end of the label line (the Figma "Dropdown header" trailing slot).
*/
- action?: React.ReactNode;
+ inlineEndNode?: React.ReactNode;
children?: React.ReactNode;
};
@@ -388,16 +367,16 @@ export type DropdownLabelProps = Omit<
* A non-interactive section heading for a group of items (the Figma "Dropdown
* header": `text/12`, `text/tertiary`, title-case). Must be rendered inside a
* `DropdownGroup`, as the first child, to label the items that follow it. Pass
- * `action` for a trailing "View all" link or count.
+ * `inlineEndNode` for a trailing "View all" link or count.
*/
-export function DropdownLabel({ action, children, ...props }: DropdownLabelProps) {
+export function DropdownLabel({ inlineEndNode, children, ...props }: DropdownLabelProps) {
return (
{children}
- {action != null ? {action} : null}
+ {inlineEndNode != null ? {inlineEndNode} : null}
);
}
@@ -491,43 +470,39 @@ export type DropdownSubTriggerProps = Omit<
React.ComponentProps,
"className" | "style" | "label"
> & {
- /** Leading content, typically an icon. */
- icon?: React.ReactNode;
+ /** Leading content before the label — an icon (16px) or control. (Logical node slot.) */
+ inlineStartNode?: React.ReactNode;
/** The primary text of the row. */
label?: React.ReactNode;
- /** Trailing content before the chevron — e.g. a `Badge` count. */
- trailing?: React.ReactNode;
+ /** Trailing content before the chevron — e.g. a `Badge` count. (Logical node slot.) */
+ inlineEndNode?: React.ReactNode;
};
/**
* The row that opens a submenu. Looks like a `DropdownItem` with a trailing chevron;
- * pass `trailing` for a count `Badge` before the chevron. Must be rendered inside a
- * `DropdownSub`, paired with a `DropdownSubContent`.
+ * pass `inlineEndNode` for a count `Badge` before the chevron. Must be rendered inside
+ * a `DropdownSub`, paired with a `DropdownSubContent`.
*/
export function DropdownSubTrigger({
- icon,
+ inlineStartNode,
label,
- trailing,
+ inlineEndNode,
children,
...props
}: DropdownSubTriggerProps) {
return (
- {icon ? (
-
- {icon}
-
- ) : null}
+ {inlineStartNode != null ? {inlineStartNode} : null}
{label ?? children}
- {trailing != null ? {trailing} : null}
+ {inlineEndNode != null ? {inlineEndNode} : null}
Text
- } label="Paragraph" selected />
- } label="Heading 1" />
- } label="Heading 2" />
- } label="Heading 3" />
+ }
+ label="Paragraph"
+ selected
+ />
+ } label="Heading 1" />
+ } label="Heading 2" />
+ } label="Heading 3" />
- } label="Code block" disabled />
+ }
+ label="Code block"
+ disabled
+ />