Skip to content
Merged
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
30 changes: 19 additions & 11 deletions packages/propel/src/components/context-menu/context-menu-item.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
import { Check } from "lucide-react";
import type * as React from "react";

import { NodeSlot } from "../../internal/node-slot";
import {
ContextMenuItemIcon,
ContextMenuItemIndicator,
ContextMenuItemLabel,
ContextMenuItem as ContextMenuItemRoot,
type ContextMenuItemProps as ContextMenuItemRootProps,
ContextMenuItemShortcut,
} from "../../ui/context-menu";

export type ContextMenuItemProps = Omit<ContextMenuItemRootProps, "label"> & {
/** Leading content before the label. */
/** Leading icon before the label. */
inlineStartNode?: React.ReactNode;
/** The primary text of the row. */
label?: React.ReactNode;
/** Trailing content after the label. */
/** Trailing keyboard-shortcut hint after the label. */
inlineEndNode?: React.ReactNode;
/** Single-select selected state. */
selected?: boolean;
};

/**
* The ready-made menu row: composes the atomic `ContextMenuItem` and lays out optional leading and
* trailing content slots, the label, and a trailing check for single-select selected state.
* The ready-made menu row: composes the atomic `ContextMenuItem` and its region parts — a leading
* icon, the label, an optional trailing shortcut hint, and a trailing check for single-select
* selected state. Pass `tone="danger"` for destructive actions.
*/
export function ContextMenuItem({
inlineStartNode,
Expand All @@ -32,13 +36,17 @@ export function ContextMenuItem({
}: ContextMenuItemProps) {
return (
<ContextMenuItemRoot {...props}>
{inlineStartNode != null ? <NodeSlot>{inlineStartNode}</NodeSlot> : null}
<span className="min-w-0 flex-1 truncate">{label ?? children}</span>
{inlineEndNode != null ? <NodeSlot>{inlineEndNode}</NodeSlot> : null}
{inlineStartNode != null ? (
<ContextMenuItemIcon>{inlineStartNode}</ContextMenuItemIcon>
) : null}
<ContextMenuItemLabel>{label ?? children}</ContextMenuItemLabel>
{inlineEndNode != null ? (
<ContextMenuItemShortcut>{inlineEndNode}</ContextMenuItemShortcut>
) : null}
{selected ? (
<span className="flex size-4 shrink-0 items-center justify-center">
<Check className="size-4 text-icon-accent-primary" aria-hidden="true" />
</span>
<ContextMenuItemIndicator>
<Check />
</ContextMenuItemIndicator>
) : null}
</ContextMenuItemRoot>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import type * as React from "react";

import { NodeSlot } from "../../internal/node-slot";
import {
ContextMenuItemIcon,
ContextMenuItemLabel,
ContextMenuLinkItem as ContextMenuLinkItemRoot,
type ContextMenuLinkItemProps as ContextMenuLinkItemRootProps,
ContextMenuItemShortcut,
} from "../../ui/context-menu";

export type ContextMenuLinkItemProps = Omit<ContextMenuLinkItemRootProps, "label"> & {
/** Leading content before the label. */
/** Leading icon before the label. */
inlineStartNode?: React.ReactNode;
/** The primary text of the row. */
label?: React.ReactNode;
/** Trailing content after the label. */
/** Trailing hint after the label. */
inlineEndNode?: React.ReactNode;
};

/**
* The ready-made navigational menu row: composes the atomic `ContextMenuLinkItem` and lays out
* optional leading and trailing content slots around the label.
* The ready-made navigational menu row: composes the atomic `ContextMenuLinkItem` and its region
* parts — a leading icon, the label, and an optional trailing hint.
*/
export function ContextMenuLinkItem({
inlineStartNode,
Expand All @@ -28,9 +30,13 @@ export function ContextMenuLinkItem({
}: ContextMenuLinkItemProps) {
return (
<ContextMenuLinkItemRoot {...props}>
{inlineStartNode != null ? <NodeSlot>{inlineStartNode}</NodeSlot> : null}
<span className="min-w-0 flex-1 truncate">{label ?? children}</span>
{inlineEndNode != null ? <NodeSlot>{inlineEndNode}</NodeSlot> : null}
{inlineStartNode != null ? (
<ContextMenuItemIcon>{inlineStartNode}</ContextMenuItemIcon>
) : null}
<ContextMenuItemLabel>{label ?? children}</ContextMenuItemLabel>
{inlineEndNode != null ? (
<ContextMenuItemShortcut>{inlineEndNode}</ContextMenuItemShortcut>
) : null}
</ContextMenuLinkItemRoot>
);
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
import { ChevronRight } from "lucide-react";
import type * as React from "react";

import { NodeSlot } from "../../internal/node-slot";
import {
ContextMenuItemIcon,
ContextMenuItemLabel,
ContextMenuItemShortcut,
ContextMenuSubmenuTrigger as ContextMenuSubmenuTriggerRoot,
type ContextMenuSubmenuTriggerProps as ContextMenuSubmenuTriggerRootProps,
ContextMenuSubmenuTriggerIndicator,
} from "../../ui/context-menu";

export type ContextMenuSubmenuTriggerProps = Omit<ContextMenuSubmenuTriggerRootProps, "label"> & {
/** Leading content before the label. */
/** Leading icon before the label. */
inlineStartNode?: React.ReactNode;
/** The primary text of the row. */
label?: React.ReactNode;
/** Trailing content before the chevron. */
/** Trailing hint before the submenu caret. */
inlineEndNode?: React.ReactNode;
};

/**
* The ready-made submenu trigger: composes the atomic `ContextMenuSubmenuTrigger` and lays out
* optional leading and trailing content slots around the label, plus the chevron that points toward
* The ready-made submenu trigger: composes the atomic `ContextMenuSubmenuTrigger` and its region
* parts — a leading icon, the label, an optional trailing hint, and the caret that points toward
* the submenu.
*/
export function ContextMenuSubmenuTrigger({
Expand All @@ -30,13 +33,16 @@ export function ContextMenuSubmenuTrigger({
}: ContextMenuSubmenuTriggerProps) {
return (
<ContextMenuSubmenuTriggerRoot {...props}>
{inlineStartNode != null ? <NodeSlot>{inlineStartNode}</NodeSlot> : null}
<span className="min-w-0 flex-1 truncate">{label ?? children}</span>
{inlineEndNode != null ? <NodeSlot>{inlineEndNode}</NodeSlot> : null}
<ChevronRight
className="size-4 shrink-0 text-icon-tertiary group-data-disabled/item:text-icon-disabled rtl:-scale-x-100"
aria-hidden="true"
/>
{inlineStartNode != null ? (
<ContextMenuItemIcon>{inlineStartNode}</ContextMenuItemIcon>
) : null}
<ContextMenuItemLabel>{label ?? children}</ContextMenuItemLabel>
{inlineEndNode != null ? (
<ContextMenuItemShortcut>{inlineEndNode}</ContextMenuItemShortcut>
) : null}
<ContextMenuSubmenuTriggerIndicator>
<ChevronRight />
</ContextMenuSubmenuTriggerIndicator>
</ContextMenuSubmenuTriggerRoot>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { expect, fireEvent, waitFor } from "storybook/test";
import {
ContextMenu,
ContextMenuItem,
ContextMenuItemIcon,
ContextMenuItemIndicator,
ContextMenuItemLabel,
ContextMenuItemShortcut,
ContextMenuPopup,
ContextMenuPortal,
ContextMenuPositioner,
Expand All @@ -24,6 +28,10 @@ const meta = {
ContextMenuPositioner,
ContextMenuPopup,
ContextMenuItem,
ContextMenuItemIcon,
ContextMenuItemLabel,
ContextMenuItemShortcut,
ContextMenuItemIndicator,
ContextMenuSeparator,
},
} satisfies Meta<typeof ContextMenu>;
Expand All @@ -45,25 +53,25 @@ export const Default: Story = {
<ContextMenuPositioner>
<ContextMenuPopup>
<ContextMenuItem
tone="neutral"
inlineStartNode={<Scissors />}
label="Cut"
inlineEndNode={<span className="text-12 text-tertiary">⌘X</span>}
inlineEndNode="⌘X"
/>
<ContextMenuItem
tone="neutral"
inlineStartNode={<Copy />}
label="Copy"
inlineEndNode={<span className="text-12 text-tertiary">⌘C</span>}
inlineEndNode="⌘C"
/>
<ContextMenuItem
tone="neutral"
inlineStartNode={<ClipboardPaste />}
label="Paste"
inlineEndNode={<span className="text-12 text-tertiary">⌘V</span>}
inlineEndNode="⌘V"
/>
<ContextMenuSeparator />
<ContextMenuItem
inlineStartNode={<Trash2 className="text-danger-primary" />}
label={<span className="text-danger-primary">Delete</span>}
/>
<ContextMenuItem tone="danger" inlineStartNode={<Trash2 />} label="Delete" />
</ContextMenuPopup>
</ContextMenuPositioner>
</ContextMenuPortal>
Expand Down
10 changes: 10 additions & 0 deletions packages/propel/src/components/context-menu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ export {
ContextMenuGroupLabel,
type ContextMenuGroupLabelProps,
type ContextMenuGroupProps,
ContextMenuItemIcon,
type ContextMenuItemIconProps,
ContextMenuItemIndicator,
type ContextMenuItemIndicatorProps,
ContextMenuItemLabel,
type ContextMenuItemLabelProps,
ContextMenuItemShortcut,
type ContextMenuItemShortcutProps,
ContextMenuPopup,
type ContextMenuPopupProps,
ContextMenuPortal,
Expand All @@ -35,6 +43,8 @@ export {
type ContextMenuSeparatorProps,
ContextMenuSubmenuRoot,
type ContextMenuSubmenuRootProps,
ContextMenuSubmenuTriggerIndicator,
type ContextMenuSubmenuTriggerIndicatorProps,
ContextMenuTrigger,
type ContextMenuTriggerProps,
} from "../../ui/context-menu";
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { ContextMenu as BaseContextMenu } from "@base-ui/react/context-menu";
import { Check } from "lucide-react";

import { contextMenuItemIndicatorVariants } from "./variants";

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

/** Shows whether the checkbox item is ticked. Wraps `ContextMenu.CheckboxItemIndicator` 1:1. */
/**
* Shows whether the checkbox item is ticked. Sizes its single child (the tick icon) to the row's
* `--node-size`. Wraps `ContextMenu.CheckboxItemIndicator` 1:1.
*/
export function ContextMenuCheckboxItemIndicator(props: ContextMenuCheckboxItemIndicatorProps) {
return (
<BaseContextMenu.CheckboxItemIndicator
className={contextMenuItemIndicatorVariants()}
{...props}
>
{props.children ?? <Check className="size-4" aria-hidden="true" />}
</BaseContextMenu.CheckboxItemIndicator>
/>
);
}
14 changes: 11 additions & 3 deletions packages/propel/src/ui/context-menu/context-menu-checkbox-item.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { ContextMenu as BaseContextMenu } from "@base-ui/react/context-menu";
import { type VariantProps } from "class-variance-authority";

import { contextMenuItemVariants } from "./variants";

type ContextMenuCheckboxItemTone = NonNullable<
VariantProps<typeof contextMenuItemVariants>["tone"]
>;

export type ContextMenuCheckboxItemProps = Omit<
BaseContextMenu.CheckboxItem.Props,
"className" | "style"
>;
> & {
/** Color palette for the row. `neutral` for standard toggles; `danger` for destructive ones. */
tone: ContextMenuCheckboxItemTone;
};

/** A menu row that toggles a setting on or off. Wraps `ContextMenu.CheckboxItem` 1:1. */
export function ContextMenuCheckboxItem(props: ContextMenuCheckboxItemProps) {
return <BaseContextMenu.CheckboxItem className={contextMenuItemVariants()} {...props} />;
export function ContextMenuCheckboxItem({ tone, ...props }: ContextMenuCheckboxItemProps) {
return <BaseContextMenu.CheckboxItem className={contextMenuItemVariants({ tone })} {...props} />;
}
17 changes: 17 additions & 0 deletions packages/propel/src/ui/context-menu/context-menu-item-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type * as React from "react";

import { contextMenuItemIconVariants } from "./variants";

export type ContextMenuItemIconProps = Omit<
React.ComponentPropsWithoutRef<"span">,
"className" | "style"
>;

/**
* The leading icon region of a menu row. Sizes its single child to the row's `--node-size`, so
* callers pass a bare icon. Decorative (the row's label carries the accessible name), so it is
* `aria-hidden`.
*/
export function ContextMenuItemIcon(props: ContextMenuItemIconProps) {
return <span aria-hidden className={contextMenuItemIconVariants()} {...props} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type * as React from "react";

import { contextMenuItemIndicatorVariants } from "./variants";

export type ContextMenuItemIndicatorProps = Omit<
React.ComponentPropsWithoutRef<"span">,
"className" | "style"
>;

/**
* The trailing selection-check region of a single-select `ContextMenuItem`. Sizes its single child
* to the row's `--node-size` and tints it accent. Decorative (the row carries the selected state),
* so it is `aria-hidden`.
*/
export function ContextMenuItemIndicator(props: ContextMenuItemIndicatorProps) {
return <span aria-hidden className={contextMenuItemIndicatorVariants()} {...props} />;
}
16 changes: 16 additions & 0 deletions packages/propel/src/ui/context-menu/context-menu-item-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type * as React from "react";

import { contextMenuItemLabelVariants } from "./variants";

export type ContextMenuItemLabelProps = Omit<
React.ComponentPropsWithoutRef<"span">,
"className" | "style"
>;

/**
* The label region of a menu row. Grows to fill the row so trailing regions (shortcut, indicator,
* submenu caret) sit at the inline-end; truncates rather than overflowing.
*/
export function ContextMenuItemLabel(props: ContextMenuItemLabelProps) {
return <span className={contextMenuItemLabelVariants()} {...props} />;
}
16 changes: 16 additions & 0 deletions packages/propel/src/ui/context-menu/context-menu-item-shortcut.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type * as React from "react";

import { contextMenuItemShortcutVariants } from "./variants";

export type ContextMenuItemShortcutProps = Omit<
React.ComponentPropsWithoutRef<"span">,
"className" | "style"
>;

/**
* The keyboard-shortcut text region of a menu row, sitting at the row's inline-end. Decorative hint
* (the row's label carries the accessible name), so it is `aria-hidden`.
*/
export function ContextMenuItemShortcut(props: ContextMenuItemShortcutProps) {
return <span aria-hidden className={contextMenuItemShortcutVariants()} {...props} />;
}
12 changes: 9 additions & 3 deletions packages/propel/src/ui/context-menu/context-menu-item.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { ContextMenu as BaseContextMenu } from "@base-ui/react/context-menu";
import { type VariantProps } from "class-variance-authority";

import { contextMenuItemVariants } from "./variants";

export type ContextMenuItemProps = Omit<BaseContextMenu.Item.Props, "className" | "style">;
type ContextMenuItemTone = NonNullable<VariantProps<typeof contextMenuItemVariants>["tone"]>;

export type ContextMenuItemProps = Omit<BaseContextMenu.Item.Props, "className" | "style"> & {
/** Color palette for the row. `neutral` for standard actions; `danger` for destructive ones. */
tone: ContextMenuItemTone;
};

/** An interactive menu row. Wraps `ContextMenu.Item` 1:1. */
export function ContextMenuItem(props: ContextMenuItemProps) {
return <BaseContextMenu.Item className={contextMenuItemVariants()} {...props} />;
export function ContextMenuItem({ tone, ...props }: ContextMenuItemProps) {
return <BaseContextMenu.Item className={contextMenuItemVariants({ tone })} {...props} />;
}
Loading