Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions src/shared/design-system/styles/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
172 changes: 172 additions & 0 deletions src/shared/design-system/ui/Menu.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof BaseMenu.Positioner>,
| "align"
| "alignOffset"
| "anchor"
| "collisionAvoidance"
| "side"
| "sideOffset"
| "sticky"
>;

type PopupProps = Omit<ComponentProps<typeof BaseMenu.Popup>, "className"> &
MenuPosition & {
children: ReactNode;
};

function PositionedPopup({
align = "start",
alignOffset,
anchor,
children,
collisionAvoidance,
side = "bottom",
sideOffset = 4,
sticky,
...props
}: PopupProps) {
return (
<BaseMenu.Portal>
<BaseMenu.Positioner
align={align}
alignOffset={alignOffset}
anchor={anchor}
collisionAvoidance={collisionAvoidance}
side={side}
sideOffset={sideOffset}
sticky={sticky}
>
<BaseMenu.Popup
{...props}
data-buzz-ui=""
className="buzz-menu-popup text-body-sm"
>
{children}
</BaseMenu.Popup>
</BaseMenu.Positioner>
</BaseMenu.Portal>
);
}

/** An anchored action menu with collision handling and shared floating-surface styling. */
export function MenuPopup(props: PopupProps) {
return <PositionedPopup {...props} />;
}

export function MenuSubmenu({ children }: { children: ReactNode }) {
return <BaseMenu.SubmenuRoot>{children}</BaseMenu.SubmenuRoot>;
}

export function MenuSubmenuPopup(props: PopupProps) {
return <PositionedPopup side="right" align="start" {...props} />;
}

type ItemProps = Omit<ComponentProps<typeof BaseMenu.Item>, "className">;

export function MenuItem(props: ItemProps) {
return <BaseMenu.Item {...props} className="buzz-menu-item" />;
}

type LinkItemProps = Omit<
ComponentProps<typeof BaseMenu.LinkItem>,
"className"
>;

export function MenuLinkItem(props: LinkItemProps) {
return <BaseMenu.LinkItem {...props} className="buzz-menu-item" />;
}

type SubmenuTriggerProps = Omit<
ComponentProps<typeof BaseMenu.SubmenuTrigger>,
"className"
>;

export function MenuSubmenuTrigger({
children,
...props
}: SubmenuTriggerProps) {
return (
<BaseMenu.SubmenuTrigger {...props} className="buzz-menu-item">
{children}
<IconChevronRight
className="buzz-menu-submenu-arrow"
size={16}
aria-hidden="true"
/>
</BaseMenu.SubmenuTrigger>
);
}

type CheckboxItemProps = Omit<
ComponentProps<typeof BaseMenu.CheckboxItem>,
"className"
>;

export function MenuCheckboxItem({ children, ...props }: CheckboxItemProps) {
return (
<BaseMenu.CheckboxItem {...props} className="buzz-menu-item">
<BaseMenu.CheckboxItemIndicator
className="buzz-menu-item-indicator"
keepMounted
>
<IconCheck size={14} aria-hidden="true" />
</BaseMenu.CheckboxItemIndicator>
<span className="buzz-menu-choice-label">{children}</span>
</BaseMenu.CheckboxItem>
);
}

type RadioItemProps = Omit<
ComponentProps<typeof BaseMenu.RadioItem>,
"className"
>;

export function MenuRadioItem({ children, ...props }: RadioItemProps) {
return (
<BaseMenu.RadioItem {...props} className="buzz-menu-item">
<BaseMenu.RadioItemIndicator
className="buzz-menu-item-indicator"
keepMounted
>
<span className="buzz-menu-radio-dot" />
</BaseMenu.RadioItemIndicator>
<span className="buzz-menu-choice-label">{children}</span>
</BaseMenu.RadioItem>
);
}

export function MenuGroupLabel({ children }: { children: ReactNode }) {
return (
<BaseMenu.GroupLabel className="buzz-menu-group-label">
{children}
</BaseMenu.GroupLabel>
);
}

export function MenuSeparator() {
return <BaseMenu.Separator className="buzz-menu-separator" />;
}

export function MenuIcon({ children }: { children: ReactNode }) {
return (
<span className="buzz-menu-icon" aria-hidden="true">
{children}
</span>
);
}

export function MenuTrailing({ children }: { children: ReactNode }) {
return <span className="buzz-menu-trailing">{children}</span>;
}
26 changes: 26 additions & 0 deletions src/shared/design-system/ui/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading