diff --git a/src/shared/design-system/styles/components.css b/src/shared/design-system/styles/components.css index 6cc78186..d45ba4b1 100644 --- a/src/shared/design-system/styles/components.css +++ b/src/shared/design-system/styles/components.css @@ -692,6 +692,90 @@ } } +@layer components { + .buzz-menu-popup { + z-index: 1000; + min-width: 11.5rem; + max-width: min(20rem, calc(100vw - var(--space-4))); + max-height: var(--available-height); + overflow-y: auto; + padding: var(--space-1); + border: 1px solid var(--border-primary); + border-radius: var(--radius-control); + background: var(--bg-float); + color: var(--text-primary); + box-shadow: var(--shadow-sm); + transform-origin: var(--transform-origin); + transition: + opacity var(--duration-state) var(--easing-state), + transform var(--duration-state) var(--easing-state); + } + .buzz-menu-popup[data-starting-style], + .buzz-menu-popup[data-ending-style] { + opacity: 0; + transform: scale(0.98); + } + .buzz-menu-item { + display: flex; + min-height: var(--size-row); + align-items: center; + gap: var(--space-row-gap); + border-radius: var(--radius-row); + padding: var(--space-1) var(--space-2); + color: var(--text-primary); + cursor: default; + outline: none; + user-select: none; + } + .buzz-menu-item[data-highlighted] { + background: var(--neutral-4); + } + .buzz-menu-item[data-disabled] { + color: var(--text-disabled); + } + .buzz-menu-icon, + .buzz-menu-item-indicator { + display: grid; + width: 1rem; + height: 1rem; + flex: 0 0 auto; + place-items: center; + color: var(--text-secondary); + } + .buzz-menu-choice-label { + display: contents; + } + .buzz-menu-item-indicator:not([data-checked]) { + visibility: hidden; + } + .buzz-menu-radio-dot { + width: 0.375rem; + height: 0.375rem; + border-radius: var(--radius-pill); + background: currentColor; + } + .buzz-menu-submenu-arrow, + .buzz-menu-trailing { + margin-inline-start: auto; + flex: 0 0 auto; + color: var(--text-secondary); + } + .buzz-menu-group-label { + padding: var(--space-2) var(--space-2) var(--space-1); + color: var(--text-secondary); + } + .buzz-menu-separator { + height: 1px; + margin: var(--space-1) 0; + background: var(--border-primary); + } + @media (prefers-reduced-motion: reduce) { + .buzz-menu-popup { + transition-duration: 0ms; + } + } +} + @layer components { .buzz-select { display: flex; diff --git a/src/shared/design-system/ui/Menu.tsx b/src/shared/design-system/ui/Menu.tsx new file mode 100644 index 00000000..c5876312 --- /dev/null +++ b/src/shared/design-system/ui/Menu.tsx @@ -0,0 +1,172 @@ +import { ContextMenu as BaseContextMenu } from "@base-ui/react/context-menu"; +import { Menu as BaseMenu } from "@base-ui/react/menu"; +import { IconCheck, IconChevronRight } from "@tabler/icons-react"; +import type { ComponentProps, ReactNode } from "react"; + +export const MenuRoot = BaseMenu.Root; +export const MenuTrigger = BaseMenu.Trigger; +export const ContextMenuRoot = BaseContextMenu.Root; +export const ContextMenuTrigger = BaseContextMenu.Trigger; +export const MenuGroup = BaseMenu.Group; +export const MenuRadioGroup = BaseMenu.RadioGroup; + +export type MenuPosition = Pick< + ComponentProps, + | "align" + | "alignOffset" + | "anchor" + | "collisionAvoidance" + | "side" + | "sideOffset" + | "sticky" +>; + +type PopupProps = Omit, "className"> & + MenuPosition & { + children: ReactNode; + }; + +function PositionedPopup({ + align = "start", + alignOffset, + anchor, + children, + collisionAvoidance, + side = "bottom", + sideOffset = 4, + sticky, + ...props +}: PopupProps) { + return ( + + + + {children} + + + + ); +} + +/** An anchored action menu with collision handling and shared floating-surface styling. */ +export function MenuPopup(props: PopupProps) { + return ; +} + +export function MenuSubmenu({ children }: { children: ReactNode }) { + return {children}; +} + +export function MenuSubmenuPopup(props: PopupProps) { + return ; +} + +type ItemProps = Omit, "className">; + +export function MenuItem(props: ItemProps) { + return ; +} + +type LinkItemProps = Omit< + ComponentProps, + "className" +>; + +export function MenuLinkItem(props: LinkItemProps) { + return ; +} + +type SubmenuTriggerProps = Omit< + ComponentProps, + "className" +>; + +export function MenuSubmenuTrigger({ + children, + ...props +}: SubmenuTriggerProps) { + return ( + + {children} + + ); +} + +type CheckboxItemProps = Omit< + ComponentProps, + "className" +>; + +export function MenuCheckboxItem({ children, ...props }: CheckboxItemProps) { + return ( + + + + {children} + + ); +} + +type RadioItemProps = Omit< + ComponentProps, + "className" +>; + +export function MenuRadioItem({ children, ...props }: RadioItemProps) { + return ( + + + + + {children} + + ); +} + +export function MenuGroupLabel({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function MenuSeparator() { + return ; +} + +export function MenuIcon({ children }: { children: ReactNode }) { + return ( + + ); +} + +export function MenuTrailing({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/shared/design-system/ui/registry.ts b/src/shared/design-system/ui/registry.ts index 4cd26ef2..b73b0c5a 100644 --- a/src/shared/design-system/ui/registry.ts +++ b/src/shared/design-system/ui/registry.ts @@ -17,8 +17,14 @@ export type BaseUiPart = { export const BASE_UI_PARTS = { avatar: { name: "Avatar", docs: "avatar", module: "@base-ui/react/avatar" }, button: { name: "Button", docs: "button", module: "@base-ui/react/button" }, + contextMenu: { + name: "Context Menu", + docs: "context-menu", + module: "@base-ui/react/context-menu", + }, field: { name: "Field", docs: "field", module: "@base-ui/react/field" }, input: { name: "Input", docs: "input", module: "@base-ui/react/input" }, + menu: { name: "Menu", docs: "menu", module: "@base-ui/react/menu" }, select: { name: "Select", docs: "select", module: "@base-ui/react/select" }, switch: { name: "Switch", docs: "switch", module: "@base-ui/react/switch" }, tabs: { name: "Tabs", docs: "tabs", module: "@base-ui/react/tabs" }, @@ -337,6 +343,26 @@ export const COMPONENTS: readonly ComponentDefinition[] = [ baseUi: [BASE_UI_PARTS.accordion], composes: [], }, + { + slug: "menu", + name: "Menu", + purpose: "Present contextual actions and choices from a compact trigger.", + behavior: + "Base UI owns positioning, dismissal, keyboard navigation, selection, and nested submenus", + variants: [ + "actions", + "links", + "checkbox choices", + "radio choices", + "nested submenus", + ], + status: "proposed", + collection: "components", + owner: "desktop-new Design system", + source: "shared/design-system/ui/Menu.tsx", + baseUi: [BASE_UI_PARTS.menu, BASE_UI_PARTS.contextMenu], + composes: [], + }, { slug: "select", name: "Select", diff --git a/tests/fixtures/design-system/ui/componentSpecimens.tsx b/tests/fixtures/design-system/ui/componentSpecimens.tsx index a3a1731c..d2414bfc 100644 --- a/tests/fixtures/design-system/ui/componentSpecimens.tsx +++ b/tests/fixtures/design-system/ui/componentSpecimens.tsx @@ -3,6 +3,9 @@ import { PanelSwapPlaygrounds } from "./PanelSwapPlaygrounds"; import { FlexWorkspace } from "../../../../src/shared/design-system/ui/FlexWorkspace"; import { BentoSpecimen } from "./BentoSpecimen"; import { + IconArrowsSort, + IconBell, + IconCheck, IconDots, IconHash, IconMessageCircle, @@ -14,6 +17,21 @@ import { useState } from "react"; import { Switch } from "../../../../src/shared/design-system/ui/Switch"; import { Accordion } from "../../../../src/shared/design-system/ui/Accordion"; import { Select } from "../../../../src/shared/design-system/ui/Select"; +import { + MenuCheckboxItem, + MenuIcon, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuRoot, + MenuSeparator, + MenuSubmenu, + MenuSubmenuPopup, + MenuSubmenuTrigger, + MenuTrailing, + MenuTrigger, +} from "../../../../src/shared/design-system/ui/Menu"; import { Avatar } from "../../../../src/shared/design-system/ui/Avatar"; import { InlineChip } from "../../../../src/shared/design-system/ui/InlineChip"; import { Button } from "../../../../src/shared/design-system/ui/Button"; @@ -833,6 +851,60 @@ function SwitchSpecimen() { ); } +function MenuSpecimen() { + const [sort, setSort] = useState("recent"); + const [notifications, setNotifications] = useState(true); + return ( + + + ); +} + function SelectSpecimen() { const [value, setValue] = useState("channel"); return ( @@ -866,6 +938,7 @@ export const COMPONENT_SPECIMENS: Record ReactNode> = { "full-page-surface": FullPageSurfaceSpecimen, panel: PanelSpecimen, tabs: TabsSpecimen, + menu: MenuSpecimen, select: SelectSpecimen, switch: SwitchSpecimen, accordion: () => (