, "className" | "style">;
-
-/** A non-interactive footer pinned below a `MenuContent` menu popup. */
-export function MenuFooter(props: MenuFooterProps) {
- return (
-
- );
-}
+export { MenuFooter, type MenuFooterProps } from "../../ui/menu";
diff --git a/packages/propel/src/components/menu/menu-item.tsx b/packages/propel/src/components/menu/menu-item.tsx
index 2eaa2d29..a9b5a633 100644
--- a/packages/propel/src/components/menu/menu-item.tsx
+++ b/packages/propel/src/components/menu/menu-item.tsx
@@ -1,8 +1,18 @@
import { Check } from "lucide-react";
import type * as React from "react";
-import { NodeSlot } from "../../internal/node-slot";
-import { MenuItem as MenuItemRoot, type MenuItemProps as MenuItemRootProps } from "../../ui/menu";
+import {
+ MenuItem as MenuItemRoot,
+ MenuItemContent,
+ MenuItemDescription,
+ MenuItemIcon,
+ MenuItemSecondaryText,
+ MenuItemSelectedIndicator,
+ MenuItemTitle,
+ MenuItemTitleRow,
+ MenuItemTrailing,
+ type MenuItemProps as MenuItemRootProps,
+} from "../../ui/menu";
export type MenuItemProps = MenuItemRootProps & {
/** Leading content before the label. */
@@ -35,27 +45,21 @@ export function MenuItem({
}: MenuItemProps) {
return (
- {inlineStartNode != null ? {inlineStartNode} : null}
-
-
- {label ?? children}
+ {inlineStartNode != null ? {inlineStartNode} : null}
+
+
+ {label ?? children}
{secondaryText != null ? (
-
- {secondaryText}
-
+ {secondaryText}
) : null}
-
- {description != null ? (
-
- {description}
-
- ) : null}
-
- {inlineEndNode != null ? {inlineEndNode} : null}
+
+ {description != null ? {description} : null}
+
+ {inlineEndNode != null ? {inlineEndNode} : null}
{selected ? (
-
-
-
+
+
+
) : null}
);
diff --git a/packages/propel/src/components/menu/menu-label.tsx b/packages/propel/src/components/menu/menu-label.tsx
new file mode 100644
index 00000000..75ad925a
--- /dev/null
+++ b/packages/propel/src/components/menu/menu-label.tsx
@@ -0,0 +1,24 @@
+import type * as React from "react";
+
+import {
+ MenuLabel as MenuLabelRoot,
+ MenuLabelMeta,
+ type MenuLabelProps as MenuLabelRootProps,
+ MenuLabelTitle,
+} from "../../ui/menu";
+
+export type MenuLabelProps = MenuLabelRootProps & {
+ /** Optional inline-end content on the heading row. */
+ inlineEndNode?: React.ReactNode;
+ children?: React.ReactNode;
+};
+
+/** A non-interactive section heading for a group of menu items. */
+export function MenuLabel({ inlineEndNode, children, ...props }: MenuLabelProps) {
+ return (
+
+ {children}
+ {inlineEndNode != null ? {inlineEndNode} : null}
+
+ );
+}
diff --git a/packages/propel/src/components/menu/menu-link-item.tsx b/packages/propel/src/components/menu/menu-link-item.tsx
index 1021fe02..a8b107e8 100644
--- a/packages/propel/src/components/menu/menu-link-item.tsx
+++ b/packages/propel/src/components/menu/menu-link-item.tsx
@@ -1,7 +1,11 @@
import type * as React from "react";
-import { NodeSlot } from "../../internal/node-slot";
import {
+ MenuItemContent,
+ MenuItemIcon,
+ MenuItemTitle,
+ MenuItemTitleRow,
+ MenuItemTrailing,
MenuLinkItem as MenuLinkItemRoot,
type MenuLinkItemProps as MenuLinkItemRootProps,
} from "../../ui/menu";
@@ -28,9 +32,13 @@ export function MenuLinkItem({
}: MenuLinkItemProps) {
return (
- {inlineStartNode != null ? {inlineStartNode} : null}
- {label ?? children}
- {inlineEndNode != null ? {inlineEndNode} : null}
+ {inlineStartNode != null ? {inlineStartNode} : null}
+
+
+ {label ?? children}
+
+
+ {inlineEndNode != null ? {inlineEndNode} : null}
);
}
diff --git a/packages/propel/src/components/menu/menu-search.tsx b/packages/propel/src/components/menu/menu-search.tsx
index 494a1a36..1c86fadb 100644
--- a/packages/propel/src/components/menu/menu-search.tsx
+++ b/packages/propel/src/components/menu/menu-search.tsx
@@ -1,8 +1,10 @@
import { Search } from "lucide-react";
import type * as React from "react";
+import { MenuSearch as MenuSearchRoot, MenuSearchIcon, MenuSearchInput } from "../../ui/menu";
+
export type MenuSearchProps = Omit<
- React.ComponentProps<"input">,
+ React.ComponentPropsWithoutRef<"input">,
"className" | "style" | "onChange" | "value" | "type"
> & {
/** Current search text. */
@@ -21,17 +23,17 @@ export function MenuSearch({
...props
}: MenuSearchProps) {
return (
-
-
-
+
+
+
+ event.stopPropagation()}
value={value}
onChange={(event) => onValueChange?.(event.target.value)}
placeholder={placeholder}
- className="min-w-0 flex-1 bg-transparent text-13 text-secondary outline-none placeholder:text-placeholder"
{...props}
/>
-
+
);
}
diff --git a/packages/propel/src/components/menu/menu-sub-trigger.tsx b/packages/propel/src/components/menu/menu-sub-trigger.tsx
index 11de0d2e..58d0d4a4 100644
--- a/packages/propel/src/components/menu/menu-sub-trigger.tsx
+++ b/packages/propel/src/components/menu/menu-sub-trigger.tsx
@@ -1,8 +1,13 @@
import { ChevronRight } from "lucide-react";
import type * as React from "react";
-import { NodeSlot } from "../../internal/node-slot";
import {
+ MenuItemContent,
+ MenuItemIcon,
+ MenuItemSubmenuIndicator,
+ MenuItemTitle,
+ MenuItemTitleRow,
+ MenuItemTrailing,
MenuSubTrigger as MenuSubTriggerRoot,
type MenuSubTriggerProps as MenuSubTriggerRootProps,
} from "../../ui/menu";
@@ -18,7 +23,7 @@ export type MenuSubTriggerProps = MenuSubTriggerRootProps & {
/**
* The ready-made submenu trigger row: composes the atomic `MenuSubTrigger` and lays out optional
- * leading/trailing nodes, the label, and the chevron.
+ * leading/trailing nodes, the label, and the submenu chevron indicator.
*/
export function MenuSubTrigger({
inlineStartNode,
@@ -29,13 +34,16 @@ export function MenuSubTrigger({
}: MenuSubTriggerProps) {
return (
- {inlineStartNode != null ? {inlineStartNode} : null}
- {label ?? children}
- {inlineEndNode != null ? {inlineEndNode} : null}
-
+ {inlineStartNode != null ? {inlineStartNode} : null}
+
+
+ {label ?? children}
+
+
+ {inlineEndNode != null ? {inlineEndNode} : null}
+
+
+
);
}
diff --git a/packages/propel/src/ui/menu/index.tsx b/packages/propel/src/ui/menu/index.tsx
index 1c1f488b..164a22be 100644
--- a/packages/propel/src/ui/menu/index.tsx
+++ b/packages/propel/src/ui/menu/index.tsx
@@ -6,10 +6,30 @@ export {
MenuCheckboxItemIndicator,
type MenuCheckboxItemIndicatorProps,
} from "./menu-checkbox-item-indicator";
+export { MenuFooter, type MenuFooterProps } from "./menu-footer";
export { MenuGroup, type MenuGroupProps } from "./menu-group";
export { MenuGroupLabel, type MenuGroupLabelProps } from "./menu-group-label";
export { MenuItem, type MenuItemProps } from "./menu-item";
+export { MenuItemContent, type MenuItemContentProps } from "./menu-item-content";
+export { MenuItemControl, type MenuItemControlProps } from "./menu-item-control";
+export { MenuItemDescription, type MenuItemDescriptionProps } from "./menu-item-description";
+export { MenuItemIcon, type MenuItemIconProps } from "./menu-item-icon";
+export { MenuItemMeta, type MenuItemMetaProps } from "./menu-item-meta";
+export { MenuItemSecondaryText, type MenuItemSecondaryTextProps } from "./menu-item-secondary-text";
+export {
+ MenuItemSelectedIndicator,
+ type MenuItemSelectedIndicatorProps,
+} from "./menu-item-selected-indicator";
+export {
+ MenuItemSubmenuIndicator,
+ type MenuItemSubmenuIndicatorProps,
+} from "./menu-item-submenu-indicator";
+export { MenuItemTitle, type MenuItemTitleProps } from "./menu-item-title";
+export { MenuItemTrailing, type MenuItemTrailingProps } from "./menu-item-trailing";
+export { MenuItemTitleRow, type MenuItemTitleRowProps } from "./menu-item-title-row";
export { MenuLabel, type MenuLabelProps } from "./menu-label";
+export { MenuLabelMeta, type MenuLabelMetaProps } from "./menu-label-meta";
+export { MenuLabelTitle, type MenuLabelTitleProps } from "./menu-label-title";
export { MenuLinkItem, type MenuLinkItemProps } from "./menu-link-item";
export { MenuPopup, type MenuPopupProps } from "./menu-popup";
export { MenuPortal } from "./menu-portal";
@@ -20,6 +40,9 @@ export {
MenuRadioItemIndicator,
type MenuRadioItemIndicatorProps,
} from "./menu-radio-item-indicator";
+export { MenuSearch, type MenuSearchProps } from "./menu-search";
+export { MenuSearchIcon, type MenuSearchIconProps } from "./menu-search-icon";
+export { MenuSearchInput, type MenuSearchInputProps } from "./menu-search-input";
export { MenuSeparator, type MenuSeparatorProps } from "./menu-separator";
export { MenuSub, type MenuSubProps } from "./menu-sub";
export { MenuSubTrigger, type MenuSubTriggerProps } from "./menu-sub-trigger";
diff --git a/packages/propel/src/ui/menu/menu-checkbox-item-indicator.tsx b/packages/propel/src/ui/menu/menu-checkbox-item-indicator.tsx
index 445f722d..c013c276 100644
--- a/packages/propel/src/ui/menu/menu-checkbox-item-indicator.tsx
+++ b/packages/propel/src/ui/menu/menu-checkbox-item-indicator.tsx
@@ -1,5 +1,4 @@
import { Menu as BaseMenu } from "@base-ui/react/menu";
-import { Check } from "lucide-react";
import { menuItemIndicatorVariants } from "./variants";
@@ -10,9 +9,5 @@ export type MenuCheckboxItemIndicatorProps = Omit<
/** Shows whether the checkbox item is ticked. Wraps `Menu.CheckboxItemIndicator` 1:1. */
export function MenuCheckboxItemIndicator(props: MenuCheckboxItemIndicatorProps) {
- return (
-
- {props.children ?? }
-
- );
+ return ;
}
diff --git a/packages/propel/src/ui/menu/menu-checkbox-item.tsx b/packages/propel/src/ui/menu/menu-checkbox-item.tsx
index c544dc51..12cdfea3 100644
--- a/packages/propel/src/ui/menu/menu-checkbox-item.tsx
+++ b/packages/propel/src/ui/menu/menu-checkbox-item.tsx
@@ -1,5 +1,6 @@
import { Menu as BaseMenu } from "@base-ui/react/menu";
-import { cx } from "class-variance-authority";
+
+import { menuCheckboxItemVariants } from "./variants";
export type MenuCheckboxItemProps = Omit<
BaseMenu.CheckboxItem.Props,
@@ -8,15 +9,5 @@ export type MenuCheckboxItemProps = Omit<
/** A toggleable multi-select menu row with `role="menuitemcheckbox"`. Wraps `Menu.CheckboxItem` 1:1. */
export function MenuCheckboxItem(props: MenuCheckboxItemProps) {
- return (
-
- );
+ return ;
}
diff --git a/packages/propel/src/ui/menu/menu-footer.tsx b/packages/propel/src/ui/menu/menu-footer.tsx
new file mode 100644
index 00000000..ef78b1d2
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-footer.tsx
@@ -0,0 +1,10 @@
+import type * as React from "react";
+
+import { menuFooterVariants } from "./variants";
+
+export type MenuFooterProps = Omit, "className" | "style">;
+
+/** A non-interactive footer pinned below a menu popup list (sticky chrome outside `role="menu"`). */
+export function MenuFooter(props: MenuFooterProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/menu/menu-item-content.tsx b/packages/propel/src/ui/menu/menu-item-content.tsx
new file mode 100644
index 00000000..678f6d3e
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-item-content.tsx
@@ -0,0 +1,16 @@
+import type * as React from "react";
+
+import { menuItemContentVariants } from "./variants";
+
+export type MenuItemContentProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/**
+ * The text column of a row. Grows to fill the row so trailing nodes/indicators sit at the
+ * inline-end, and stacks a `MenuItemTitleRow` over an optional `MenuItemDescription`.
+ */
+export function MenuItemContent(props: MenuItemContentProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/menu/menu-item-control.tsx b/packages/propel/src/ui/menu/menu-item-control.tsx
new file mode 100644
index 00000000..7c207e33
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-item-control.tsx
@@ -0,0 +1,13 @@
+import type * as React from "react";
+
+import { menuItemControlVariants } from "./variants";
+
+export type MenuItemControlProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/** The leading control slot of a checkbox/radio row, holding its visual toggle. */
+export function MenuItemControl(props: MenuItemControlProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/menu/menu-item-description.tsx b/packages/propel/src/ui/menu/menu-item-description.tsx
new file mode 100644
index 00000000..447e6cb3
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-item-description.tsx
@@ -0,0 +1,13 @@
+import type * as React from "react";
+
+import { menuItemDescriptionVariants } from "./variants";
+
+export type MenuItemDescriptionProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/** A muted second line beneath the title, holding the item's description. */
+export function MenuItemDescription(props: MenuItemDescriptionProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/menu/menu-item-icon.tsx b/packages/propel/src/ui/menu/menu-item-icon.tsx
new file mode 100644
index 00000000..3bd60e1a
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-item-icon.tsx
@@ -0,0 +1,14 @@
+import type * as React from "react";
+
+import { menuItemIconVariants } from "./variants";
+
+export type MenuItemIconProps = Omit, "className" | "style">;
+
+/**
+ * A decorative leading icon at a row's inline-start. Sizes its single child to the row's
+ * `--node-size`, so callers pass a bare icon. Decorative (the row carries the accessible name), so
+ * it is `aria-hidden`.
+ */
+export function MenuItemIcon(props: MenuItemIconProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/menu/menu-item-meta.tsx b/packages/propel/src/ui/menu/menu-item-meta.tsx
new file mode 100644
index 00000000..ce44e493
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-item-meta.tsx
@@ -0,0 +1,10 @@
+import type * as React from "react";
+
+import { menuItemMetaVariants } from "./variants";
+
+export type MenuItemMetaProps = Omit, "className" | "style">;
+
+/** Trailing muted metadata after the title column (e.g. a keyboard shortcut). */
+export function MenuItemMeta(props: MenuItemMetaProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/menu/menu-item-secondary-text.tsx b/packages/propel/src/ui/menu/menu-item-secondary-text.tsx
new file mode 100644
index 00000000..5b778cc1
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-item-secondary-text.tsx
@@ -0,0 +1,13 @@
+import type * as React from "react";
+
+import { menuItemSecondaryTextVariants } from "./variants";
+
+export type MenuItemSecondaryTextProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/** Muted text shown inline after the title (e.g. a translation or a hint). */
+export function MenuItemSecondaryText(props: MenuItemSecondaryTextProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/menu/menu-item-selected-indicator.tsx b/packages/propel/src/ui/menu/menu-item-selected-indicator.tsx
new file mode 100644
index 00000000..309b47f7
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-item-selected-indicator.tsx
@@ -0,0 +1,21 @@
+import type * as React from "react";
+
+import { menuItemSelectedIndicatorVariants } from "./variants";
+
+export type MenuItemSelectedIndicatorProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/**
+ * The single-select check slot shown at a row's inline-end. Decorative (the row carries the
+ * selected state), so it is `aria-hidden`. Renders and sizes its single child; pass the glyph as
+ * `children`.
+ */
+export function MenuItemSelectedIndicator({ children, ...props }: MenuItemSelectedIndicatorProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/propel/src/ui/menu/menu-item-submenu-indicator.tsx b/packages/propel/src/ui/menu/menu-item-submenu-indicator.tsx
new file mode 100644
index 00000000..005b34dc
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-item-submenu-indicator.tsx
@@ -0,0 +1,21 @@
+import type * as React from "react";
+
+import { menuItemSubmenuIndicatorVariants } from "./variants";
+
+export type MenuItemSubmenuIndicatorProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/**
+ * The chevron slot marking a submenu trigger, pinned at the row's inline-end and mirrored under
+ * RTL. Decorative (the trigger carries `aria-haspopup`), so it is `aria-hidden`. Renders and sizes
+ * its single child; pass the glyph as `children`.
+ */
+export function MenuItemSubmenuIndicator({ children, ...props }: MenuItemSubmenuIndicatorProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/propel/src/ui/menu/menu-item-title-row.tsx b/packages/propel/src/ui/menu/menu-item-title-row.tsx
new file mode 100644
index 00000000..2d1f5197
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-item-title-row.tsx
@@ -0,0 +1,13 @@
+import type * as React from "react";
+
+import { menuItemTitleRowVariants } from "./variants";
+
+export type MenuItemTitleRowProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/** The baseline-aligned line holding a `MenuItemTitle` and any inline `MenuItemSecondaryText`. */
+export function MenuItemTitleRow(props: MenuItemTitleRowProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/menu/menu-item-title.tsx b/packages/propel/src/ui/menu/menu-item-title.tsx
new file mode 100644
index 00000000..0365aa63
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-item-title.tsx
@@ -0,0 +1,13 @@
+import type * as React from "react";
+
+import { menuItemTitleVariants } from "./variants";
+
+export type MenuItemTitleProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/** A row's primary label. Truncates rather than wrapping. */
+export function MenuItemTitle(props: MenuItemTitleProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/menu/menu-item-trailing.tsx b/packages/propel/src/ui/menu/menu-item-trailing.tsx
new file mode 100644
index 00000000..0423b525
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-item-trailing.tsx
@@ -0,0 +1,17 @@
+import type * as React from "react";
+
+import { menuItemTrailingVariants } from "./variants";
+
+export type MenuItemTrailingProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/**
+ * A trailing slot at the row's inline-end for arbitrary content (a shortcut, a count, a chevron).
+ * Sizes any icon child to the row's `--node-size`; unlike `MenuItemIcon` it adds no tint, so passed
+ * content keeps its own color.
+ */
+export function MenuItemTrailing(props: MenuItemTrailingProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/menu/menu-label-meta.tsx b/packages/propel/src/ui/menu/menu-label-meta.tsx
new file mode 100644
index 00000000..7041415c
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-label-meta.tsx
@@ -0,0 +1,13 @@
+import type * as React from "react";
+
+import { menuLabelMetaVariants } from "./variants";
+
+export type MenuLabelMetaProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/** Trailing metadata pinned at the inline-end of a `MenuLabel`. */
+export function MenuLabelMeta(props: MenuLabelMetaProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/menu/menu-label-title.tsx b/packages/propel/src/ui/menu/menu-label-title.tsx
new file mode 100644
index 00000000..534edc98
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-label-title.tsx
@@ -0,0 +1,13 @@
+import type * as React from "react";
+
+import { menuLabelTitleVariants } from "./variants";
+
+export type MenuLabelTitleProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/** The growing title within a `MenuLabel`. Grows so a trailing `MenuLabelMeta` sits at the edge. */
+export function MenuLabelTitle(props: MenuLabelTitleProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/menu/menu-label.tsx b/packages/propel/src/ui/menu/menu-label.tsx
index a35be193..a4fda6f3 100644
--- a/packages/propel/src/ui/menu/menu-label.tsx
+++ b/packages/propel/src/ui/menu/menu-label.tsx
@@ -1,21 +1,10 @@
import { Menu as BaseMenu } from "@base-ui/react/menu";
-import type * as React from "react";
-export type MenuLabelProps = Omit & {
- /** Optional inline-end content on the heading row. */
- inlineEndNode?: React.ReactNode;
- children?: React.ReactNode;
-};
+import { menuLabelVariants } from "./variants";
-/** A non-interactive section heading for a group of menu items. */
-export function MenuLabel({ inlineEndNode, children, ...props }: MenuLabelProps) {
- return (
-
- {children}
- {inlineEndNode != null ? {inlineEndNode} : null}
-
- );
+export type MenuLabelProps = Omit;
+
+/** A non-interactive section heading for a group of menu items. Wraps `Menu.GroupLabel` 1:1. */
+export function MenuLabel(props: MenuLabelProps) {
+ return ;
}
diff --git a/packages/propel/src/ui/menu/menu-link-item.tsx b/packages/propel/src/ui/menu/menu-link-item.tsx
index 118202a6..1e41fd08 100644
--- a/packages/propel/src/ui/menu/menu-link-item.tsx
+++ b/packages/propel/src/ui/menu/menu-link-item.tsx
@@ -1,10 +1,15 @@
import { Menu as BaseMenu } from "@base-ui/react/menu";
-import { menuItemVariants } from "./variants";
+import { menuRowVariants } from "./variants";
export type MenuLinkItemProps = Omit;
/** A navigational `` menu row. Wraps `Menu.LinkItem` 1:1. */
export function MenuLinkItem(props: MenuLinkItemProps) {
- return ;
+ return (
+
+ );
}
diff --git a/packages/propel/src/ui/menu/menu-popup.tsx b/packages/propel/src/ui/menu/menu-popup.tsx
index 01caac01..0ee6907e 100644
--- a/packages/propel/src/ui/menu/menu-popup.tsx
+++ b/packages/propel/src/ui/menu/menu-popup.tsx
@@ -1,10 +1,19 @@
import { Menu as BaseMenu } from "@base-ui/react/menu";
+import { type VariantProps } from "class-variance-authority";
import { menuPopupVariants } from "./variants";
-export type MenuPopupProps = Omit;
+type MenuPopupSurface = NonNullable["surface"]>;
+
+export type MenuPopupProps = Omit & {
+ /**
+ * Whether the popup paints its own chrome. `raised` is the standalone elevated surface; `inset`
+ * is the padded list inside an `OverlayPanel` shell.
+ */
+ surface: MenuPopupSurface;
+};
/** The menu surface that contains the items. Wraps `Menu.Popup` 1:1. */
-export function MenuPopup(props: MenuPopupProps) {
- return ;
+export function MenuPopup({ surface, ...props }: MenuPopupProps) {
+ return ;
}
diff --git a/packages/propel/src/ui/menu/menu-radio-item-indicator.tsx b/packages/propel/src/ui/menu/menu-radio-item-indicator.tsx
index ebbd5b0f..55c21e1d 100644
--- a/packages/propel/src/ui/menu/menu-radio-item-indicator.tsx
+++ b/packages/propel/src/ui/menu/menu-radio-item-indicator.tsx
@@ -1,5 +1,4 @@
import { Menu as BaseMenu } from "@base-ui/react/menu";
-import { Check } from "lucide-react";
import { menuItemIndicatorVariants } from "./variants";
@@ -10,9 +9,5 @@ export type MenuRadioItemIndicatorProps = Omit<
/** Shows whether the radio item is selected. Wraps `Menu.RadioItemIndicator` 1:1. */
export function MenuRadioItemIndicator(props: MenuRadioItemIndicatorProps) {
- return (
-
- {props.children ?? }
-
- );
+ return ;
}
diff --git a/packages/propel/src/ui/menu/menu-radio-item.tsx b/packages/propel/src/ui/menu/menu-radio-item.tsx
index 9beed01d..db4b47a8 100644
--- a/packages/propel/src/ui/menu/menu-radio-item.tsx
+++ b/packages/propel/src/ui/menu/menu-radio-item.tsx
@@ -1,10 +1,15 @@
import { Menu as BaseMenu } from "@base-ui/react/menu";
-import { menuItemVariants } from "./variants";
+import { menuRowVariants } from "./variants";
export type MenuRadioItemProps = Omit;
/** A menu row that behaves like a radio button. Wraps `Menu.RadioItem` 1:1. */
export function MenuRadioItem(props: MenuRadioItemProps) {
- return ;
+ return (
+
+ );
}
diff --git a/packages/propel/src/ui/menu/menu-search-icon.tsx b/packages/propel/src/ui/menu/menu-search-icon.tsx
new file mode 100644
index 00000000..6c0fd414
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-search-icon.tsx
@@ -0,0 +1,20 @@
+import type * as React from "react";
+
+import { menuSearchIconVariants } from "./variants";
+
+export type MenuSearchIconProps = Omit<
+ React.ComponentPropsWithoutRef<"span">,
+ "className" | "style"
+>;
+
+/**
+ * The leading icon slot inside `MenuSearch`. Sizes its single child; decorative, so it is
+ * `aria-hidden`. Renders and sizes its single child; pass the glyph as `children`.
+ */
+export function MenuSearchIcon({ children, ...props }: MenuSearchIconProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/propel/src/ui/menu/menu-search-input.tsx b/packages/propel/src/ui/menu/menu-search-input.tsx
new file mode 100644
index 00000000..3b7e036a
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-search-input.tsx
@@ -0,0 +1,25 @@
+import type * as React from "react";
+
+import { menuSearchInputVariants } from "./variants";
+
+export type MenuSearchInputProps = Omit<
+ React.ComponentPropsWithoutRef<"input">,
+ "className" | "style"
+>;
+
+/**
+ * The borderless text field inside `MenuSearch`. Stops keydown from bubbling so typing does not
+ * trigger the menu's own type-ahead navigation.
+ */
+export function MenuSearchInput({ onKeyDown, ...props }: MenuSearchInputProps) {
+ return (
+ {
+ event.stopPropagation();
+ onKeyDown?.(event);
+ }}
+ {...props}
+ />
+ );
+}
diff --git a/packages/propel/src/ui/menu/menu-search.tsx b/packages/propel/src/ui/menu/menu-search.tsx
new file mode 100644
index 00000000..d36c9d44
--- /dev/null
+++ b/packages/propel/src/ui/menu/menu-search.tsx
@@ -0,0 +1,10 @@
+import type * as React from "react";
+
+import { menuSearchVariants } from "./variants";
+
+export type MenuSearchProps = Omit, "className" | "style">;
+
+/** The sticky search row pinned above a menu popup list (a `MenuSearchIcon` + `MenuSearchInput`). */
+export function MenuSearch(props: MenuSearchProps) {
+ return ;
+}
diff --git a/packages/propel/src/ui/menu/menu-separator.tsx b/packages/propel/src/ui/menu/menu-separator.tsx
index 4a6a47fb..9a024bd2 100644
--- a/packages/propel/src/ui/menu/menu-separator.tsx
+++ b/packages/propel/src/ui/menu/menu-separator.tsx
@@ -1,8 +1,10 @@
import { Menu as BaseMenu } from "@base-ui/react/menu";
+import { menuSeparatorVariants } from "./variants";
+
export type MenuSeparatorProps = Omit;
/** A thin divider between groups of items. */
export function MenuSeparator(props: MenuSeparatorProps) {
- return ;
+ return ;
}
diff --git a/packages/propel/src/ui/menu/menu-sub-trigger.tsx b/packages/propel/src/ui/menu/menu-sub-trigger.tsx
index 8c5c6300..aca4c13e 100644
--- a/packages/propel/src/ui/menu/menu-sub-trigger.tsx
+++ b/packages/propel/src/ui/menu/menu-sub-trigger.tsx
@@ -1,5 +1,6 @@
import { Menu as BaseMenu } from "@base-ui/react/menu";
-import { cx } from "class-variance-authority";
+
+import { menuSubTriggerVariants } from "./variants";
export type MenuSubTriggerProps = Omit<
BaseMenu.SubmenuTrigger.Props,
@@ -8,15 +9,5 @@ export type MenuSubTriggerProps = Omit<
/** The row that opens a submenu. Wraps `Menu.SubmenuTrigger` 1:1. */
export function MenuSubTrigger(props: MenuSubTriggerProps) {
- return (
-
- );
+ return ;
}
diff --git a/packages/propel/src/ui/menu/menu-viewport.tsx b/packages/propel/src/ui/menu/menu-viewport.tsx
index 1cc41ca5..edb1cfeb 100644
--- a/packages/propel/src/ui/menu/menu-viewport.tsx
+++ b/packages/propel/src/ui/menu/menu-viewport.tsx
@@ -1,5 +1,7 @@
import { Menu as BaseMenu } from "@base-ui/react/menu";
+import { menuViewportVariants } from "./variants";
+
export type MenuViewportProps = Omit;
/**
@@ -7,5 +9,5 @@ export type MenuViewportProps = Omit;
+ return ;
}
diff --git a/packages/propel/src/ui/menu/menu.stories.tsx b/packages/propel/src/ui/menu/menu.stories.tsx
index 4dd73c00..f96069f2 100644
--- a/packages/propel/src/ui/menu/menu.stories.tsx
+++ b/packages/propel/src/ui/menu/menu.stories.tsx
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
-import { Check } from "lucide-react";
+import { Check, CircleDot, Copy, Pencil, Trash2 } from "lucide-react";
import * as React from "react";
import { expect, userEvent, waitFor } from "storybook/test";
@@ -10,6 +10,13 @@ import {
MenuGroup,
MenuGroupLabel,
MenuItem,
+ MenuItemContent,
+ MenuItemControl,
+ MenuItemIcon,
+ MenuItemMeta,
+ MenuItemSelectedIndicator,
+ MenuItemTitle,
+ MenuItemTitleRow,
MenuPopup,
MenuPortal,
MenuPositioner,
@@ -18,10 +25,11 @@ import {
} from "./index";
// UI-tier story: assemble the ATOMIC menu parts by hand — Root › Trigger, then the
-// portal/positioner/popup surface wrapping items, separators and groups. The
+// portal/positioner/popup surface wrapping items, separators and groups. Each row is
+// composed from its own single-element parts (icon slot, the title column, the
+// trailing check/meta), so the trigger and rows hold no raw layout. The
// components-tier `Menu` story uses the ready-made `MenuContent` plus the rich
-// `MenuItem`/`MenuCheckboxItem` rows; here each part renders a single element and
-// you wire the layout yourself.
+// `MenuItem`/`MenuCheckboxItem` rows that compose these same parts for you.
const meta = {
title: "UI/Menu",
component: Menu,
@@ -31,6 +39,13 @@ const meta = {
MenuPositioner,
MenuPopup,
MenuItem,
+ MenuItemIcon,
+ MenuItemContent,
+ MenuItemTitleRow,
+ MenuItemTitle,
+ MenuItemMeta,
+ MenuItemSelectedIndicator,
+ MenuItemControl,
MenuCheckboxItem,
MenuCheckboxItemIndicator,
MenuSeparator,
@@ -54,11 +69,42 @@ export const Default: Story = {
}>Actions
-
-
-
+
+
+
-
+
@@ -79,17 +125,41 @@ export const Grouped: Story = {
}>View
-
+
Layout
-
-
+
+
Density
-
-
+
+
@@ -118,7 +188,7 @@ export const Checkboxes: Story = {
}>Notify
-
+
{rows.map((row) => (
setChecked((c) => ({ ...c, [row.key]: next }))}
closeOnClick={false}
>
- {row.label}
+
+
+ {row.label}
+
+
-
+
))}
diff --git a/packages/propel/src/ui/menu/variants.ts b/packages/propel/src/ui/menu/variants.ts
index e532febf..663c424d 100644
--- a/packages/propel/src/ui/menu/variants.ts
+++ b/packages/propel/src/ui/menu/variants.ts
@@ -1,48 +1,136 @@
import { cva, cx } from "class-variance-authority";
+import { nodeSlotClass } from "../../internal/node-slot";
+
/** Positioner: anchors the popup at the pointer with overlay stacking. */
export const menuPositionerVariants = cva("z-50 outline-none");
-/** Popup: the menu surface, animated from its pointer-anchored transform origin. */
-export const menuPopupVariants = cva(
+/**
+ * Popup: the menu surface.
+ *
+ * - `surface="raised"` is the standalone surface — its own elevation, radius, border and animated
+ * transform origin (used when the popup is the menu's outer chrome, e.g. the UI-tier story).
+ * - `surface="inset"` is the padded list that sits _inside_ an `OverlayPanel` (the components-tier
+ * `MenuContent` provides the elevated, scrollable shell), so the popup only contributes padding.
+ */
+export const menuPopupVariants = cva("outline-none", {
+ variants: {
+ surface: {
+ raised: cx(
+ "min-w-40 rounded-lg border-sm border-subtle bg-layer-1 p-1 shadow-overlay-100",
+ "origin-(--transform-origin) transition-[opacity,transform] duration-150",
+ "data-starting-style:scale-95 data-starting-style:opacity-0",
+ "data-ending-style:scale-95 data-ending-style:opacity-0",
+ ),
+ inset: "p-1",
+ },
+ },
+});
+
+/** Viewport: content wrapper that morphs/clips during submenu transitions. */
+export const menuViewportVariants = cva("relative");
+
+// All interactive rows (item, link, radio, checkbox, submenu trigger) share one row base:
+// the same height, padding, radius, gap, text + disabled treatment. `variant` switches between
+// a single-line row and a taller top-aligned row that fits a description; `emphasis` switches
+// the hover/cursor affordance.
+const menuRowBase = cx(
+ "group/item flex w-full gap-2 rounded-md px-2 text-13 outline-none select-none [--node-size:1rem]",
+ "text-secondary",
+ "data-disabled:pointer-events-none data-disabled:text-disabled",
+);
+
+/** Standalone `MenuItem` row: variant-driven layout + emphasis. */
+export const menuRowVariants = cva(menuRowBase, {
+ variants: {
+ variant: {
+ default: "h-[34px] items-center",
+ "with-description": "min-h-[34px] items-start py-1.5",
+ },
+ emphasis: {
+ default: "cursor-default data-highlighted:bg-layer-transparent-hover",
+ link: "cursor-pointer data-highlighted:bg-layer-transparent-hover",
+ },
+ },
+});
+
+/**
+ * CheckboxItem row: the shared row base, fixed to the single-line/default-emphasis pairing (a
+ * checkbox row never carries a description).
+ */
+export const menuCheckboxItemVariants = cva(
cx(
- "min-w-40 rounded-lg border-sm border-subtle bg-layer-1 p-1 shadow-overlay-100 outline-none",
- "origin-(--transform-origin) transition-[opacity,transform] duration-150",
- "data-starting-style:scale-95 data-starting-style:opacity-0",
- "data-ending-style:scale-95 data-ending-style:opacity-0",
+ menuRowBase,
+ "h-[34px] cursor-default items-center data-highlighted:bg-layer-transparent-hover",
),
);
-/** Item rows: shared styling for Item, LinkItem, CheckboxItem, RadioItem and SubmenuTrigger. */
-export const menuItemVariants = cva(
+/**
+ * SubmenuTrigger row: the shared row base; also highlights while its submenu popup is open
+ * (`data-popup-open`).
+ */
+export const menuSubTriggerVariants = cva(
cx(
- "flex h-8 cursor-default items-center gap-2 rounded-md px-2 text-13 text-secondary outline-none select-none",
- "data-highlighted:bg-layer-transparent-hover data-highlighted:text-primary",
- "data-disabled:pointer-events-none data-disabled:text-disabled",
+ menuRowBase,
+ "h-[34px] cursor-default items-center",
+ "data-highlighted:bg-layer-transparent-hover data-popup-open:bg-layer-transparent-hover",
),
);
-/** Standalone `MenuItem` row: variant-driven layout + emphasis, distinct from the shared row base. */
-export const menuRowVariants = cva(
+/** Leading icon slot inside a row: sizes its single child to the row's `--node-size` and tints it. */
+export const menuItemIconVariants = cva(cx(nodeSlotClass, "text-icon-secondary"));
+
+/**
+ * The text column of a row. Grows to fill the row (so trailing nodes/indicators sit at the
+ * inline-end), stacks the title row over an optional description, and clips overflow.
+ */
+export const menuItemContentVariants = cva("flex min-w-0 flex-1 flex-col");
+
+/** The baseline-aligned row holding the title and any inline secondary text. */
+export const menuItemTitleRowVariants = cva("flex min-w-0 items-baseline gap-1.5");
+
+/** The row's primary label. Truncates rather than wrapping. */
+export const menuItemTitleVariants = cva("truncate");
+
+/** Muted text shown inline after the title (e.g. a translation, a hint). */
+export const menuItemSecondaryTextVariants = cva(
+ "shrink-0 truncate text-12 text-tertiary group-data-disabled/item:text-disabled",
+);
+
+/** A muted second line beneath the title (the item description). */
+export const menuItemDescriptionVariants = cva(
+ "truncate text-12 text-tertiary group-data-disabled/item:text-disabled",
+);
+
+/** Trailing muted metadata shown after the title column (e.g. a keyboard shortcut). */
+export const menuItemMetaVariants = cva("shrink-0 text-12 text-tertiary");
+
+/** A trailing slot for arbitrary content. Sizes an icon child to `--node-size`; adds no tint. */
+export const menuItemTrailingVariants = cva(nodeSlotClass);
+
+/**
+ * The single-select check shown at a row's inline-end. Sizes its single child to `--node-size` and
+ * tints it with the accent. Decorative — the row carries the selected state.
+ */
+export const menuItemSelectedIndicatorVariants = cva(
+ cx(nodeSlotClass, "h-5 w-4 text-icon-accent-primary"),
+);
+
+/**
+ * The chevron that marks a submenu trigger, pinned at the row's inline-end. Sizes its single child
+ * to `--node-size`, tints it, and mirrors under RTL. Decorative; renders the glyph passed as its
+ * child.
+ */
+export const menuItemSubmenuIndicatorVariants = cva(
cx(
- "group/item flex w-full gap-2 rounded-md px-2 text-13 outline-none select-none [--node-size:1rem]",
- "text-secondary",
- "data-disabled:pointer-events-none data-disabled:text-disabled",
+ nodeSlotClass,
+ "text-icon-tertiary group-data-disabled/item:text-icon-disabled rtl:-scale-x-100",
),
- {
- variants: {
- variant: {
- default: "h-[34px] items-center",
- "with-description": "min-h-[34px] items-start py-1.5",
- },
- emphasis: {
- default: "cursor-default data-highlighted:bg-layer-transparent-hover",
- link: "cursor-pointer",
- },
- },
- },
);
+/** The leading control slot of a checkbox/radio row, holding the visual toggle. */
+export const menuItemControlVariants = cva("flex shrink-0 items-center");
+
/** Indicator slot inside radio/checkbox items. */
export const menuItemIndicatorVariants = cva(
"flex size-4 items-center justify-center text-icon-accent-primary",
@@ -54,5 +142,37 @@ export const menuSeparatorVariants = cva("-mx-1 my-1 border-t border-subtle");
/** GroupLabel: a non-interactive section heading. */
export const menuGroupLabelVariants = cva("px-2 py-1 text-12 font-medium text-tertiary");
+/**
+ * Label: a non-interactive section heading row that can carry inline-end metadata. Lays out a
+ * growing `MenuLabelTitle` and an optional `MenuLabelMeta`.
+ */
+export const menuLabelVariants = cva("flex items-center gap-1.5 px-2 py-1.5 text-12 text-tertiary");
+
+/** The growing title within a `MenuLabel`. */
+export const menuLabelTitleVariants = cva("min-w-0 flex-1 truncate");
+
+/** Trailing metadata pinned at the inline-end of a `MenuLabel`. */
+export const menuLabelMetaVariants = cva("shrink-0");
+
+/** Search: the sticky search row pinned above the popup list. */
+export const menuSearchVariants = cva(
+ "flex shrink-0 items-center gap-1.5 border-b border-subtle bg-surface-1 px-3 py-2",
+);
+
+/** Search icon: the leading magnifier glyph in `MenuSearch`. Sizes its child to `--node-size`. */
+export const menuSearchIconVariants = cva(
+ cx(nodeSlotClass, "text-icon-tertiary [--node-size:1rem]"),
+);
+
+/** Search input: the borderless text field in `MenuSearch`. */
+export const menuSearchInputVariants = cva(
+ "min-w-0 flex-1 bg-transparent text-13 text-secondary outline-none placeholder:text-placeholder",
+);
+
+/** Footer: a non-interactive footer pinned below the popup list. */
+export const menuFooterVariants = cva(
+ "shrink-0 border-t border-subtle bg-layer-2 px-3 py-2 text-12 text-tertiary",
+);
+
/** Arrow: a small caret matching the popup surface color. */
export const menuArrowVariants = cva("text-layer-1");
diff --git a/packages/propel/src/ui/menubar/menubar.stories.tsx b/packages/propel/src/ui/menubar/menubar.stories.tsx
index 13a27399..0b4275d2 100644
--- a/packages/propel/src/ui/menubar/menubar.stories.tsx
+++ b/packages/propel/src/ui/menubar/menubar.stories.tsx
@@ -46,7 +46,7 @@ export const Default: Story = {
}>File
-
+
@@ -59,7 +59,7 @@ export const Default: Story = {
}>Edit
-
+