Skip to content

Commit 5b3df06

Browse files
Align context-menu to architecture goals (#198)
* Align context-menu to architecture goals (tone, cva-only styling) Add a `tone` variant (neutral/danger) to all item-row parts so destructive actions are expressed via a prop rather than ad-hoc colored children. Move the submenu trigger's popup-open highlight out of an inline cx() call into a dedicated `contextMenuSubmenuTriggerVariants` in variants.ts — component files now contain no cx() calls. Update both the ui- and components-tier stories to pass explicit tone props. Closes #125 * Fix color contrast on danger-tone context-menu items text-danger-secondary (red-700, #e7000b) on bg-layer-1 yields 4.36:1 — just below the 4.5:1 WCAG AA threshold and fails the axe a11y gate. Promote the resting state to text-danger-primary (red-800) which passes, keeping the same token used in badge, banner and field for danger text. * Extend context-menu item anatomy into single-element ui parts The menu rows still composed their layout in the components tier with raw className spans (label, shortcut, trailing check) and baked a sized icon into the submenu trigger, so the components tier carried styling and the checkbox/ radio indicators baked in a default Check child. Add the missing item-region parts as ui parts, each rendering one element with its cva in ui/context-menu/variants.ts: - ContextMenuItemIcon — leading icon slot (node-slot, sized to the row --node-size) - ContextMenuItemLabel — the growing label - ContextMenuItemShortcut — trailing keyboard-shortcut text - ContextMenuItemIndicator — trailing single-select check slot (node-slot) - ContextMenuSubmenuTriggerIndicator — submenu caret slot (node-slot, RTL-mirrored) The checkbox/radio item indicators no longer bake a Check child or a size literal; they size whatever icon is passed via the shared node-slot class. The components tier now only composes these parts, so it holds no className, cx or cva. Stories compose the new parts and register them as subcomponents.
1 parent 861e232 commit 5b3df06

20 files changed

Lines changed: 341 additions & 80 deletions

packages/propel/src/components/context-menu/context-menu-item.tsx

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,30 @@
11
import { Check } from "lucide-react";
22
import type * as React from "react";
33

4-
import { NodeSlot } from "../../internal/node-slot";
54
import {
5+
ContextMenuItemIcon,
6+
ContextMenuItemIndicator,
7+
ContextMenuItemLabel,
68
ContextMenuItem as ContextMenuItemRoot,
79
type ContextMenuItemProps as ContextMenuItemRootProps,
10+
ContextMenuItemShortcut,
811
} from "../../ui/context-menu";
912

1013
export type ContextMenuItemProps = Omit<ContextMenuItemRootProps, "label"> & {
11-
/** Leading content before the label. */
14+
/** Leading icon before the label. */
1215
inlineStartNode?: React.ReactNode;
1316
/** The primary text of the row. */
1417
label?: React.ReactNode;
15-
/** Trailing content after the label. */
18+
/** Trailing keyboard-shortcut hint after the label. */
1619
inlineEndNode?: React.ReactNode;
1720
/** Single-select selected state. */
1821
selected?: boolean;
1922
};
2023

2124
/**
22-
* The ready-made menu row: composes the atomic `ContextMenuItem` and lays out optional leading and
23-
* trailing content slots, the label, and a trailing check for single-select selected state.
25+
* The ready-made menu row: composes the atomic `ContextMenuItem` and its region parts — a leading
26+
* icon, the label, an optional trailing shortcut hint, and a trailing check for single-select
27+
* selected state. Pass `tone="danger"` for destructive actions.
2428
*/
2529
export function ContextMenuItem({
2630
inlineStartNode,
@@ -32,13 +36,17 @@ export function ContextMenuItem({
3236
}: ContextMenuItemProps) {
3337
return (
3438
<ContextMenuItemRoot {...props}>
35-
{inlineStartNode != null ? <NodeSlot>{inlineStartNode}</NodeSlot> : null}
36-
<span className="min-w-0 flex-1 truncate">{label ?? children}</span>
37-
{inlineEndNode != null ? <NodeSlot>{inlineEndNode}</NodeSlot> : null}
39+
{inlineStartNode != null ? (
40+
<ContextMenuItemIcon>{inlineStartNode}</ContextMenuItemIcon>
41+
) : null}
42+
<ContextMenuItemLabel>{label ?? children}</ContextMenuItemLabel>
43+
{inlineEndNode != null ? (
44+
<ContextMenuItemShortcut>{inlineEndNode}</ContextMenuItemShortcut>
45+
) : null}
3846
{selected ? (
39-
<span className="flex size-4 shrink-0 items-center justify-center">
40-
<Check className="size-4 text-icon-accent-primary" aria-hidden="true" />
41-
</span>
47+
<ContextMenuItemIndicator>
48+
<Check />
49+
</ContextMenuItemIndicator>
4250
) : null}
4351
</ContextMenuItemRoot>
4452
);
Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
11
import type * as React from "react";
22

3-
import { NodeSlot } from "../../internal/node-slot";
43
import {
4+
ContextMenuItemIcon,
5+
ContextMenuItemLabel,
56
ContextMenuLinkItem as ContextMenuLinkItemRoot,
67
type ContextMenuLinkItemProps as ContextMenuLinkItemRootProps,
8+
ContextMenuItemShortcut,
79
} from "../../ui/context-menu";
810

911
export type ContextMenuLinkItemProps = Omit<ContextMenuLinkItemRootProps, "label"> & {
10-
/** Leading content before the label. */
12+
/** Leading icon before the label. */
1113
inlineStartNode?: React.ReactNode;
1214
/** The primary text of the row. */
1315
label?: React.ReactNode;
14-
/** Trailing content after the label. */
16+
/** Trailing hint after the label. */
1517
inlineEndNode?: React.ReactNode;
1618
};
1719

1820
/**
19-
* The ready-made navigational menu row: composes the atomic `ContextMenuLinkItem` and lays out
20-
* optional leading and trailing content slots around the label.
21+
* The ready-made navigational menu row: composes the atomic `ContextMenuLinkItem` and its region
22+
* parts — a leading icon, the label, and an optional trailing hint.
2123
*/
2224
export function ContextMenuLinkItem({
2325
inlineStartNode,
@@ -28,9 +30,13 @@ export function ContextMenuLinkItem({
2830
}: ContextMenuLinkItemProps) {
2931
return (
3032
<ContextMenuLinkItemRoot {...props}>
31-
{inlineStartNode != null ? <NodeSlot>{inlineStartNode}</NodeSlot> : null}
32-
<span className="min-w-0 flex-1 truncate">{label ?? children}</span>
33-
{inlineEndNode != null ? <NodeSlot>{inlineEndNode}</NodeSlot> : null}
33+
{inlineStartNode != null ? (
34+
<ContextMenuItemIcon>{inlineStartNode}</ContextMenuItemIcon>
35+
) : null}
36+
<ContextMenuItemLabel>{label ?? children}</ContextMenuItemLabel>
37+
{inlineEndNode != null ? (
38+
<ContextMenuItemShortcut>{inlineEndNode}</ContextMenuItemShortcut>
39+
) : null}
3440
</ContextMenuLinkItemRoot>
3541
);
3642
}
Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,27 @@
11
import { ChevronRight } from "lucide-react";
22
import type * as React from "react";
33

4-
import { NodeSlot } from "../../internal/node-slot";
54
import {
5+
ContextMenuItemIcon,
6+
ContextMenuItemLabel,
7+
ContextMenuItemShortcut,
68
ContextMenuSubmenuTrigger as ContextMenuSubmenuTriggerRoot,
79
type ContextMenuSubmenuTriggerProps as ContextMenuSubmenuTriggerRootProps,
10+
ContextMenuSubmenuTriggerIndicator,
811
} from "../../ui/context-menu";
912

1013
export type ContextMenuSubmenuTriggerProps = Omit<ContextMenuSubmenuTriggerRootProps, "label"> & {
11-
/** Leading content before the label. */
14+
/** Leading icon before the label. */
1215
inlineStartNode?: React.ReactNode;
1316
/** The primary text of the row. */
1417
label?: React.ReactNode;
15-
/** Trailing content before the chevron. */
18+
/** Trailing hint before the submenu caret. */
1619
inlineEndNode?: React.ReactNode;
1720
};
1821

1922
/**
20-
* The ready-made submenu trigger: composes the atomic `ContextMenuSubmenuTrigger` and lays out
21-
* optional leading and trailing content slots around the label, plus the chevron that points toward
23+
* The ready-made submenu trigger: composes the atomic `ContextMenuSubmenuTrigger` and its region
24+
* parts — a leading icon, the label, an optional trailing hint, and the caret that points toward
2225
* the submenu.
2326
*/
2427
export function ContextMenuSubmenuTrigger({
@@ -30,13 +33,16 @@ export function ContextMenuSubmenuTrigger({
3033
}: ContextMenuSubmenuTriggerProps) {
3134
return (
3235
<ContextMenuSubmenuTriggerRoot {...props}>
33-
{inlineStartNode != null ? <NodeSlot>{inlineStartNode}</NodeSlot> : null}
34-
<span className="min-w-0 flex-1 truncate">{label ?? children}</span>
35-
{inlineEndNode != null ? <NodeSlot>{inlineEndNode}</NodeSlot> : null}
36-
<ChevronRight
37-
className="size-4 shrink-0 text-icon-tertiary group-data-disabled/item:text-icon-disabled rtl:-scale-x-100"
38-
aria-hidden="true"
39-
/>
36+
{inlineStartNode != null ? (
37+
<ContextMenuItemIcon>{inlineStartNode}</ContextMenuItemIcon>
38+
) : null}
39+
<ContextMenuItemLabel>{label ?? children}</ContextMenuItemLabel>
40+
{inlineEndNode != null ? (
41+
<ContextMenuItemShortcut>{inlineEndNode}</ContextMenuItemShortcut>
42+
) : null}
43+
<ContextMenuSubmenuTriggerIndicator>
44+
<ChevronRight />
45+
</ContextMenuSubmenuTriggerIndicator>
4046
</ContextMenuSubmenuTriggerRoot>
4147
);
4248
}

packages/propel/src/components/context-menu/context-menu.stories.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { expect, fireEvent, waitFor } from "storybook/test";
55
import {
66
ContextMenu,
77
ContextMenuItem,
8+
ContextMenuItemIcon,
9+
ContextMenuItemIndicator,
10+
ContextMenuItemLabel,
11+
ContextMenuItemShortcut,
812
ContextMenuPopup,
913
ContextMenuPortal,
1014
ContextMenuPositioner,
@@ -24,6 +28,10 @@ const meta = {
2428
ContextMenuPositioner,
2529
ContextMenuPopup,
2630
ContextMenuItem,
31+
ContextMenuItemIcon,
32+
ContextMenuItemLabel,
33+
ContextMenuItemShortcut,
34+
ContextMenuItemIndicator,
2735
ContextMenuSeparator,
2836
},
2937
} satisfies Meta<typeof ContextMenu>;
@@ -45,25 +53,25 @@ export const Default: Story = {
4553
<ContextMenuPositioner>
4654
<ContextMenuPopup>
4755
<ContextMenuItem
56+
tone="neutral"
4857
inlineStartNode={<Scissors />}
4958
label="Cut"
50-
inlineEndNode={<span className="text-12 text-tertiary">⌘X</span>}
59+
inlineEndNode="⌘X"
5160
/>
5261
<ContextMenuItem
62+
tone="neutral"
5363
inlineStartNode={<Copy />}
5464
label="Copy"
55-
inlineEndNode={<span className="text-12 text-tertiary">⌘C</span>}
65+
inlineEndNode="⌘C"
5666
/>
5767
<ContextMenuItem
68+
tone="neutral"
5869
inlineStartNode={<ClipboardPaste />}
5970
label="Paste"
60-
inlineEndNode={<span className="text-12 text-tertiary">⌘V</span>}
71+
inlineEndNode="⌘V"
6172
/>
6273
<ContextMenuSeparator />
63-
<ContextMenuItem
64-
inlineStartNode={<Trash2 className="text-danger-primary" />}
65-
label={<span className="text-danger-primary">Delete</span>}
66-
/>
74+
<ContextMenuItem tone="danger" inlineStartNode={<Trash2 />} label="Delete" />
6775
</ContextMenuPopup>
6876
</ContextMenuPositioner>
6977
</ContextMenuPortal>

packages/propel/src/components/context-menu/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ export {
1919
ContextMenuGroupLabel,
2020
type ContextMenuGroupLabelProps,
2121
type ContextMenuGroupProps,
22+
ContextMenuItemIcon,
23+
type ContextMenuItemIconProps,
24+
ContextMenuItemIndicator,
25+
type ContextMenuItemIndicatorProps,
26+
ContextMenuItemLabel,
27+
type ContextMenuItemLabelProps,
28+
ContextMenuItemShortcut,
29+
type ContextMenuItemShortcutProps,
2230
ContextMenuPopup,
2331
type ContextMenuPopupProps,
2432
ContextMenuPortal,
@@ -35,6 +43,8 @@ export {
3543
type ContextMenuSeparatorProps,
3644
ContextMenuSubmenuRoot,
3745
type ContextMenuSubmenuRootProps,
46+
ContextMenuSubmenuTriggerIndicator,
47+
type ContextMenuSubmenuTriggerIndicatorProps,
3848
ContextMenuTrigger,
3949
type ContextMenuTriggerProps,
4050
} from "../../ui/context-menu";
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { ContextMenu as BaseContextMenu } from "@base-ui/react/context-menu";
2-
import { Check } from "lucide-react";
32

43
import { contextMenuItemIndicatorVariants } from "./variants";
54

@@ -8,14 +7,15 @@ export type ContextMenuCheckboxItemIndicatorProps = Omit<
87
"className" | "style"
98
>;
109

11-
/** Shows whether the checkbox item is ticked. Wraps `ContextMenu.CheckboxItemIndicator` 1:1. */
10+
/**
11+
* Shows whether the checkbox item is ticked. Sizes its single child (the tick icon) to the row's
12+
* `--node-size`. Wraps `ContextMenu.CheckboxItemIndicator` 1:1.
13+
*/
1214
export function ContextMenuCheckboxItemIndicator(props: ContextMenuCheckboxItemIndicatorProps) {
1315
return (
1416
<BaseContextMenu.CheckboxItemIndicator
1517
className={contextMenuItemIndicatorVariants()}
1618
{...props}
17-
>
18-
{props.children ?? <Check className="size-4" aria-hidden="true" />}
19-
</BaseContextMenu.CheckboxItemIndicator>
19+
/>
2020
);
2121
}
Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import { ContextMenu as BaseContextMenu } from "@base-ui/react/context-menu";
2+
import { type VariantProps } from "class-variance-authority";
23

34
import { contextMenuItemVariants } from "./variants";
45

6+
type ContextMenuCheckboxItemTone = NonNullable<
7+
VariantProps<typeof contextMenuItemVariants>["tone"]
8+
>;
9+
510
export type ContextMenuCheckboxItemProps = Omit<
611
BaseContextMenu.CheckboxItem.Props,
712
"className" | "style"
8-
>;
13+
> & {
14+
/** Color palette for the row. `neutral` for standard toggles; `danger` for destructive ones. */
15+
tone: ContextMenuCheckboxItemTone;
16+
};
917

1018
/** A menu row that toggles a setting on or off. Wraps `ContextMenu.CheckboxItem` 1:1. */
11-
export function ContextMenuCheckboxItem(props: ContextMenuCheckboxItemProps) {
12-
return <BaseContextMenu.CheckboxItem className={contextMenuItemVariants()} {...props} />;
19+
export function ContextMenuCheckboxItem({ tone, ...props }: ContextMenuCheckboxItemProps) {
20+
return <BaseContextMenu.CheckboxItem className={contextMenuItemVariants({ tone })} {...props} />;
1321
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type * as React from "react";
2+
3+
import { contextMenuItemIconVariants } from "./variants";
4+
5+
export type ContextMenuItemIconProps = Omit<
6+
React.ComponentPropsWithoutRef<"span">,
7+
"className" | "style"
8+
>;
9+
10+
/**
11+
* The leading icon region of a menu row. Sizes its single child to the row's `--node-size`, so
12+
* callers pass a bare icon. Decorative (the row's label carries the accessible name), so it is
13+
* `aria-hidden`.
14+
*/
15+
export function ContextMenuItemIcon(props: ContextMenuItemIconProps) {
16+
return <span aria-hidden className={contextMenuItemIconVariants()} {...props} />;
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type * as React from "react";
2+
3+
import { contextMenuItemIndicatorVariants } from "./variants";
4+
5+
export type ContextMenuItemIndicatorProps = Omit<
6+
React.ComponentPropsWithoutRef<"span">,
7+
"className" | "style"
8+
>;
9+
10+
/**
11+
* The trailing selection-check region of a single-select `ContextMenuItem`. Sizes its single child
12+
* to the row's `--node-size` and tints it accent. Decorative (the row carries the selected state),
13+
* so it is `aria-hidden`.
14+
*/
15+
export function ContextMenuItemIndicator(props: ContextMenuItemIndicatorProps) {
16+
return <span aria-hidden className={contextMenuItemIndicatorVariants()} {...props} />;
17+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type * as React from "react";
2+
3+
import { contextMenuItemLabelVariants } from "./variants";
4+
5+
export type ContextMenuItemLabelProps = Omit<
6+
React.ComponentPropsWithoutRef<"span">,
7+
"className" | "style"
8+
>;
9+
10+
/**
11+
* The label region of a menu row. Grows to fill the row so trailing regions (shortcut, indicator,
12+
* submenu caret) sit at the inline-end; truncates rather than overflowing.
13+
*/
14+
export function ContextMenuItemLabel(props: ContextMenuItemLabelProps) {
15+
return <span className={contextMenuItemLabelVariants()} {...props} />;
16+
}

0 commit comments

Comments
 (0)