diff --git a/packages/propel/src/components/menu/index.tsx b/packages/propel/src/components/menu/index.tsx index e8d32380..f6563860 100644 --- a/packages/propel/src/components/menu/index.tsx +++ b/packages/propel/src/components/menu/index.tsx @@ -2,6 +2,7 @@ export { MenuCheckboxItem, type MenuCheckboxItemProps } from "./menu-checkbox-it export { MenuContent, type MenuContentProps } from "./menu-content"; export { MenuFooter, type MenuFooterProps } from "./menu-footer"; export { MenuItem, type MenuItemProps } from "./menu-item"; +export { MenuLabel, type MenuLabelProps } from "./menu-label"; export { MenuLinkItem, type MenuLinkItemProps } from "./menu-link-item"; export { MenuSearch, type MenuSearchProps } from "./menu-search"; export { MenuSubContent, type MenuSubContentProps } from "./menu-sub-content"; @@ -20,8 +21,6 @@ export { type MenuGroupProps, MenuGroupLabel, type MenuGroupLabelProps, - MenuLabel, - type MenuLabelProps, MenuPopup, type MenuPopupProps, MenuPortal, diff --git a/packages/propel/src/components/menu/menu-checkbox-item.tsx b/packages/propel/src/components/menu/menu-checkbox-item.tsx index 6db57877..f60a9147 100644 --- a/packages/propel/src/components/menu/menu-checkbox-item.tsx +++ b/packages/propel/src/components/menu/menu-checkbox-item.tsx @@ -1,11 +1,16 @@ import type * as React from "react"; import { useControllableState } from "../../hooks/use-controllable-state/index"; -import { NodeSlot } from "../../internal/node-slot"; import { CheckboxVisual } from "../../ui/checkbox/index"; import { MenuCheckboxItem as MenuCheckboxItemRoot, type MenuCheckboxItemProps as MenuCheckboxItemRootProps, + MenuItemContent, + MenuItemControl, + MenuItemIcon, + MenuItemMeta, + MenuItemTitle, + MenuItemTitleRow, } from "../../ui/menu"; export type MenuCheckboxItemProps = MenuCheckboxItemRootProps & { @@ -46,14 +51,16 @@ export function MenuCheckboxItem({ }} {...props} > - + - - {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-content.shared.tsx b/packages/propel/src/components/menu/menu-content.shared.tsx index 3c6b1bb2..9bbe7903 100644 --- a/packages/propel/src/components/menu/menu-content.shared.tsx +++ b/packages/propel/src/components/menu/menu-content.shared.tsx @@ -2,6 +2,7 @@ import { Menu as BaseMenu } from "@base-ui/react/menu"; import type * as React from "react"; import { OverlayPanel, type OverlayPanelWidth } from "../../internal/overlay-panel"; +import { MenuPopup, MenuPortal, MenuPositioner } from "../../ui/menu"; export type MenuContentWidth = OverlayPanelWidth; @@ -34,17 +35,12 @@ export function MenuContentSurface({ align: BaseMenu.Positioner.Props["align"]; }) { return ( - - + + - + - - + + ); } diff --git a/packages/propel/src/components/menu/menu-footer.tsx b/packages/propel/src/components/menu/menu-footer.tsx index 66ba7c83..6cd4a8ab 100644 --- a/packages/propel/src/components/menu/menu-footer.tsx +++ b/packages/propel/src/components/menu/menu-footer.tsx @@ -1,13 +1 @@ -import type * as React from "react"; - -export type MenuFooterProps = Omit, "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 ( -
-
+ ); } 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} - ); } 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 - - Edit - Duplicate + + + + + + + + Edit + + + ⌘E + + + + + + + + Duplicate + + + + + + - Delete + + + + + + + Delete + + + @@ -79,17 +125,41 @@ export const Grouped: Story = { }>View - + Layout - List - Board + + + + List + + + + + + + Board + + + Density - Comfortable - Compact + + + + Comfortable + + + + + + + Compact + + + @@ -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 - + New file Open… @@ -59,7 +59,7 @@ export const Default: Story = { }>Edit - + Undo Redo