Add v2 Dropdown menu component

Implement the Dropdown Menu family from the Probo Radix UI Figma on
top of Base UI's Menu primitive, exported as flat Dropdown* parts:
Dropdown (root), DropdownTrigger, DropdownPopup, DropdownItem (with
shortcut and accent/error color), DropdownCheckboxItem,
DropdownRadioGroup/RadioItem, DropdownGroup/GroupLabel,
DropdownSeparator, and DropdownSubmenu/SubmenuTrigger.

The popup carries size (1-2), variant (solid/soft highlight), and
highContrast to its items through a small context. Styling uses
tailwind-variants/lite with data-[highlighted] selectors; item
shortcuts inherit the contrast color on highlight.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-24 22:49:59 +02:00
parent f1ccd4e6e0
commit 1992421c91
15 changed files with 666 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useState } from "react";
import { Button } from "../Button/Button";
import { Dropdown } from "./Dropdown";
import { DropdownCheckboxItem } from "./DropdownCheckboxItem";
import { DropdownGroup } from "./DropdownGroup";
import { DropdownGroupLabel } from "./DropdownGroupLabel";
import { DropdownItem } from "./DropdownItem";
import { DropdownPopup } from "./DropdownPopup";
import { DropdownRadioGroup } from "./DropdownRadioGroup";
import { DropdownRadioItem } from "./DropdownRadioItem";
import { DropdownSeparator } from "./DropdownSeparator";
import { DropdownSubmenu } from "./DropdownSubmenu";
import { DropdownSubmenuTrigger } from "./DropdownSubmenuTrigger";
import { DropdownTrigger } from "./DropdownTrigger";
export default {
title: "v2/Dropdown",
component: Dropdown,
};
export function Default() {
return (
<Dropdown>
<DropdownTrigger render={<Button variant="soft" color="neutral">Open menu</Button>} />
<DropdownPopup>
<DropdownItem shortcut="⌘ E">Edit</DropdownItem>
<DropdownItem shortcut="⌘ D">Duplicate</DropdownItem>
<DropdownSeparator />
<DropdownSubmenu>
<DropdownSubmenuTrigger>More</DropdownSubmenuTrigger>
<DropdownPopup side="right" sideOffset={4}>
<DropdownItem>Move</DropdownItem>
<DropdownItem>Archive</DropdownItem>
</DropdownPopup>
</DropdownSubmenu>
<DropdownSeparator />
<DropdownItem color="error" shortcut="⌘ ⌫">Delete</DropdownItem>
</DropdownPopup>
</Dropdown>
);
}
export function Soft() {
return (
<Dropdown>
<DropdownTrigger render={<Button variant="soft" color="neutral">Soft highlight</Button>} />
<DropdownPopup variant="soft">
<DropdownItem shortcut="⌘ E">Edit</DropdownItem>
<DropdownItem shortcut="⌘ D">Duplicate</DropdownItem>
<DropdownSeparator />
<DropdownItem color="error">Delete</DropdownItem>
</DropdownPopup>
</Dropdown>
);
}
export function Size1() {
return (
<Dropdown>
<DropdownTrigger render={<Button size={1} variant="soft" color="neutral">Small</Button>} />
<DropdownPopup size={1}>
<DropdownItem shortcut="⌘ E">Edit</DropdownItem>
<DropdownItem shortcut="⌘ D">Duplicate</DropdownItem>
</DropdownPopup>
</Dropdown>
);
}
export function WithGroupLabel() {
return (
<Dropdown>
<DropdownTrigger render={<Button variant="soft" color="neutral">Account</Button>} />
<DropdownPopup>
<DropdownGroup>
<DropdownGroupLabel>Account</DropdownGroupLabel>
<DropdownItem>Profile</DropdownItem>
<DropdownItem>Settings</DropdownItem>
</DropdownGroup>
</DropdownPopup>
</Dropdown>
);
}
export function CheckboxAndRadio() {
const [showGrid, setShowGrid] = useState(true);
const [view, setView] = useState("comfortable");
return (
<Dropdown>
<DropdownTrigger render={<Button variant="soft" color="neutral">View</Button>} />
<DropdownPopup>
<DropdownCheckboxItem checked={showGrid} onCheckedChange={setShowGrid}>
Show grid
</DropdownCheckboxItem>
<DropdownSeparator />
<DropdownRadioGroup value={view} onValueChange={setView}>
<DropdownRadioItem value="comfortable">Comfortable</DropdownRadioItem>
<DropdownRadioItem value="compact">Compact</DropdownRadioItem>
</DropdownRadioGroup>
</DropdownPopup>
</Dropdown>
);
}

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Menu } from "@base-ui/react/menu";
import type { ComponentProps } from "react";
// Root of the dropdown menu (Radix "Dropdown Menu"). Controlled the same way as
// Base UI's Menu: `open` / `onOpenChange`, or left uncontrolled. See
// contrib/claude/ui.md.
export type DropdownProps = ComponentProps<typeof Menu.Root>;
export function Dropdown(props: DropdownProps) {
return <Menu.Root {...props} />;
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Menu } from "@base-ui/react/menu";
import { CheckIcon } from "@phosphor-icons/react";
import type { ComponentProps, ReactNode } from "react";
import { useDropdownContext } from "./context";
import { dropdownItem, dropdownItemIndicator, dropdownItemLabel, dropdownItemShortcut } from "./variants";
export type DropdownCheckboxItemProps
= & Omit<ComponentProps<typeof Menu.CheckboxItem>, "className">
& {
className?: string;
shortcut?: ReactNode;
};
// A menu item with a checkmark indicator. Controlled with `checked` /
// `onCheckedChange`, like Base UI's CheckboxItem.
export function DropdownCheckboxItem(props: DropdownCheckboxItemProps) {
const { className, shortcut, children, ...rest } = props;
const { size, variant, highContrast } = useDropdownContext();
return (
<Menu.CheckboxItem
className={dropdownItem({ size, variant, color: "accent", highContrast, className })}
{...rest}
>
<span className={dropdownItemIndicator()}>
<Menu.CheckboxItemIndicator>
<CheckIcon weight="bold" />
</Menu.CheckboxItemIndicator>
</span>
<span className={dropdownItemLabel()}>{children}</span>
{shortcut != null && <span className={dropdownItemShortcut()}>{shortcut}</span>}
</Menu.CheckboxItem>
);
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Menu } from "@base-ui/react/menu";
import type { ComponentProps } from "react";
// Groups related items, optionally labelled with a DropdownGroupLabel.
export type DropdownGroupProps = ComponentProps<typeof Menu.Group>;
export function DropdownGroup(props: DropdownGroupProps) {
return <Menu.Group {...props} />;
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Menu } from "@base-ui/react/menu";
import type { ComponentProps } from "react";
import { dropdownGroupLabel } from "./variants";
export type DropdownGroupLabelProps = Omit<ComponentProps<typeof Menu.GroupLabel>, "className"> & {
className?: string;
};
// A non-interactive heading for a DropdownGroup.
export function DropdownGroupLabel(props: DropdownGroupLabelProps) {
const { className, ...rest } = props;
return <Menu.GroupLabel className={dropdownGroupLabel({ className })} {...rest} />;
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Menu } from "@base-ui/react/menu";
import type { ComponentProps, ReactNode } from "react";
import { useDropdownContext } from "./context";
import { dropdownItem, dropdownItemLabel, dropdownItemShortcut } from "./variants";
export type DropdownItemProps
= & Omit<ComponentProps<typeof Menu.Item>, "color" | "className">
& {
className?: string;
color?: "accent" | "error";
iconStart?: ReactNode;
// Trailing keyboard shortcut hint (e.g. "⌘ E").
shortcut?: ReactNode;
};
// A single actionable menu item. Inherits size/variant/highContrast from the
// popup; set `color="error"` for destructive actions.
export function DropdownItem(props: DropdownItemProps) {
const { className, color = "accent", iconStart, shortcut, children, ...rest } = props;
const { size, variant, highContrast } = useDropdownContext();
return (
<Menu.Item
className={dropdownItem({ size, variant, color, highContrast, className })}
{...rest}
>
{iconStart}
<span className={dropdownItemLabel()}>{children}</span>
{shortcut != null && <span className={dropdownItemShortcut()}>{shortcut}</span>}
</Menu.Item>
);
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Menu } from "@base-ui/react/menu";
import type { ComponentProps } from "react";
import { DropdownProvider } from "./context";
import { dropdownPopup } from "./variants";
export type DropdownPopupProps
= & Omit<ComponentProps<typeof Menu.Popup>, "className">
& {
className?: string;
// Menu-level look applied to every item.
size?: 1 | 2;
variant?: "solid" | "soft";
highContrast?: boolean;
// Positioner placement passthrough.
side?: ComponentProps<typeof Menu.Positioner>["side"];
align?: ComponentProps<typeof Menu.Positioner>["align"];
sideOffset?: ComponentProps<typeof Menu.Positioner>["sideOffset"];
};
// Portal + positioner + styled popup. Provides the menu-level look to its items.
export function DropdownPopup(props: DropdownPopupProps) {
const {
className, children,
size = 2, variant = "solid", highContrast = false,
side = "bottom", align = "start", sideOffset = 4,
...popupProps
} = props;
return (
<Menu.Portal>
<Menu.Positioner side={side} align={align} sideOffset={sideOffset}>
<Menu.Popup className={dropdownPopup({ className })} {...popupProps}>
<DropdownProvider value={{ size, variant, highContrast }}>
{children}
</DropdownProvider>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
);
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Menu } from "@base-ui/react/menu";
import type { ComponentProps } from "react";
// Groups radio items with a shared value (Base UI's RadioGroup), controlled
// with `value` / `onValueChange`.
export type DropdownRadioGroupProps = ComponentProps<typeof Menu.RadioGroup>;
export function DropdownRadioGroup(props: DropdownRadioGroupProps) {
return <Menu.RadioGroup {...props} />;
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Menu } from "@base-ui/react/menu";
import type { ComponentProps, ReactNode } from "react";
import { useDropdownContext } from "./context";
import { dropdownItem, dropdownItemIndicator, dropdownItemLabel, dropdownItemShortcut } from "./variants";
export type DropdownRadioItemProps
= & Omit<ComponentProps<typeof Menu.RadioItem>, "className">
& {
className?: string;
shortcut?: ReactNode;
};
// A menu item with a radio dot indicator. Used inside a DropdownRadioGroup.
export function DropdownRadioItem(props: DropdownRadioItemProps) {
const { className, shortcut, children, ...rest } = props;
const { size, variant, highContrast } = useDropdownContext();
return (
<Menu.RadioItem
className={dropdownItem({ size, variant, color: "accent", highContrast, className })}
{...rest}
>
<span className={dropdownItemIndicator()}>
<Menu.RadioItemIndicator className="size-1.5 rounded-full bg-current" />
</span>
<span className={dropdownItemLabel()}>{children}</span>
{shortcut != null && <span className={dropdownItemShortcut()}>{shortcut}</span>}
</Menu.RadioItem>
);
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Menu } from "@base-ui/react/menu";
import type { ComponentProps } from "react";
import { dropdownSeparator } from "./variants";
export type DropdownSeparatorProps = Omit<ComponentProps<typeof Menu.Separator>, "className"> & {
className?: string;
};
// A thin divider between groups of items.
export function DropdownSeparator(props: DropdownSeparatorProps) {
const { className, ...rest } = props;
return <Menu.Separator className={dropdownSeparator({ className })} {...rest} />;
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Menu } from "@base-ui/react/menu";
import type { ComponentProps } from "react";
// Wraps a nested menu. Place a DropdownSubmenuTrigger and a DropdownPopup
// inside. Mirrors Base UI's SubmenuRoot.
export type DropdownSubmenuProps = ComponentProps<typeof Menu.SubmenuRoot>;
export function DropdownSubmenu(props: DropdownSubmenuProps) {
return <Menu.SubmenuRoot {...props} />;
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Menu } from "@base-ui/react/menu";
import { CaretRightIcon } from "@phosphor-icons/react";
import type { ComponentProps, ReactNode } from "react";
import { useDropdownContext } from "./context";
import { dropdownItem, dropdownItemLabel, dropdownSubmenuCaret } from "./variants";
export type DropdownSubmenuTriggerProps
= & Omit<ComponentProps<typeof Menu.SubmenuTrigger>, "className">
& {
className?: string;
iconStart?: ReactNode;
};
// The item that opens a submenu; renders a trailing caret.
export function DropdownSubmenuTrigger(props: DropdownSubmenuTriggerProps) {
const { className, iconStart, children, ...rest } = props;
const { size, variant, highContrast } = useDropdownContext();
return (
<Menu.SubmenuTrigger
className={dropdownItem({ size, variant, color: "accent", highContrast, className })}
{...rest}
>
{iconStart}
<span className={dropdownItemLabel()}>{children}</span>
<span className={dropdownSubmenuCaret()}>
<CaretRightIcon />
</span>
</Menu.SubmenuTrigger>
);
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Menu } from "@base-ui/react/menu";
import type { ComponentProps } from "react";
// The element that opens the menu. Compose a Button via `render` to style it.
export type DropdownTriggerProps = ComponentProps<typeof Menu.Trigger>;
export function DropdownTrigger(props: DropdownTriggerProps) {
return <Menu.Trigger {...props} />;
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { createContext, useContext } from "react";
// Menu-level look (set on the popup) shared with every item, so item styling
// stays consistent without threading props through each one.
export type DropdownContextValue = {
size: 1 | 2;
variant: "solid" | "soft";
highContrast: boolean;
};
const DropdownContext = createContext<DropdownContextValue>({
size: 2,
variant: "solid",
highContrast: false,
});
export const DropdownProvider = DropdownContext.Provider;
export function useDropdownContext() {
return useContext(DropdownContext);
}

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { tv } from "tailwind-variants/lite";
// Dropdown menu (Radix "Dropdown Menu" over Base UI's Menu). Accent-colored
// menu surface with grouped, highlightable items.
export const dropdownPopup = tv({
base: [
"min-w-40 origin-[var(--transform-origin)] rounded-3 border border-sand-6 bg-sand-1 p-2 shadow-5 outline-none",
"transition-[transform,opacity] data-[starting-style]:scale-95 data-[starting-style]:opacity-0",
"data-[ending-style]:scale-95 data-[ending-style]:opacity-0",
],
});
export const dropdownItem = tv({
base: [
"group flex cursor-pointer items-center gap-2 rounded-2 outline-none select-none",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
],
variants: {
size: {
1: "h-7 px-2 text-1",
2: "h-8 px-3 text-2",
},
// Highlight treatment resolves in the compound variants below.
variant: {
solid: "",
soft: "",
},
color: {
accent: "text-sand-12",
error: "text-red-11",
},
highContrast: {
true: "",
false: "",
},
},
compoundVariants: [
// accent
{ variant: "solid", color: "accent", class: "data-[highlighted]:bg-gold-9 data-[highlighted]:text-white" },
{ variant: "soft", color: "accent", class: "data-[highlighted]:bg-gold-4 data-[highlighted]:text-gold-12" },
{ color: "accent", highContrast: true, class: "text-sand-12" },
// error
{ variant: "solid", color: "error", class: "data-[highlighted]:bg-red-9 data-[highlighted]:text-white" },
{ variant: "soft", color: "error", class: "data-[highlighted]:bg-red-4 data-[highlighted]:text-red-12" },
],
defaultVariants: {
size: 2,
variant: "solid",
color: "accent",
highContrast: false,
},
});
export const dropdownItemLabel = tv({
base: "min-w-0 flex-1 truncate",
});
// Keyboard shortcut hint: trailing and muted, but inherits the item's color
// once the item is highlighted (so it tracks the contrast text).
export const dropdownItemShortcut = tv({
base: "shrink-0 text-sand-11 group-data-[highlighted]:text-inherit",
});
export const dropdownItemIndicator = tv({
base: "flex size-4 shrink-0 items-center justify-center [&_svg]:size-4",
});
export const dropdownSubmenuCaret = tv({
base: "size-4 shrink-0 [&_svg]:size-4",
});
export const dropdownSeparator = tv({
base: "-mx-2 my-1 h-px bg-sand-6",
});
export const dropdownGroupLabel = tv({
base: "px-3 py-1 text-1 font-medium text-sand-11",
});