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:
119
packages/ui/src/v2/Dropdown/Dropdown.stories.tsx
Normal file
119
packages/ui/src/v2/Dropdown/Dropdown.stories.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
packages/ui/src/v2/Dropdown/Dropdown.tsx
Normal file
25
packages/ui/src/v2/Dropdown/Dropdown.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
49
packages/ui/src/v2/Dropdown/DropdownCheckboxItem.tsx
Normal file
49
packages/ui/src/v2/Dropdown/DropdownCheckboxItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
packages/ui/src/v2/Dropdown/DropdownGroup.tsx
Normal file
23
packages/ui/src/v2/Dropdown/DropdownGroup.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
29
packages/ui/src/v2/Dropdown/DropdownGroupLabel.tsx
Normal file
29
packages/ui/src/v2/Dropdown/DropdownGroupLabel.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
47
packages/ui/src/v2/Dropdown/DropdownItem.tsx
Normal file
47
packages/ui/src/v2/Dropdown/DropdownItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
packages/ui/src/v2/Dropdown/DropdownPopup.tsx
Normal file
55
packages/ui/src/v2/Dropdown/DropdownPopup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
packages/ui/src/v2/Dropdown/DropdownRadioGroup.tsx
Normal file
24
packages/ui/src/v2/Dropdown/DropdownRadioGroup.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
45
packages/ui/src/v2/Dropdown/DropdownRadioItem.tsx
Normal file
45
packages/ui/src/v2/Dropdown/DropdownRadioItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
packages/ui/src/v2/Dropdown/DropdownSeparator.tsx
Normal file
29
packages/ui/src/v2/Dropdown/DropdownSeparator.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
24
packages/ui/src/v2/Dropdown/DropdownSubmenu.tsx
Normal file
24
packages/ui/src/v2/Dropdown/DropdownSubmenu.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
46
packages/ui/src/v2/Dropdown/DropdownSubmenuTrigger.tsx
Normal file
46
packages/ui/src/v2/Dropdown/DropdownSubmenuTrigger.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
packages/ui/src/v2/Dropdown/DropdownTrigger.tsx
Normal file
23
packages/ui/src/v2/Dropdown/DropdownTrigger.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
35
packages/ui/src/v2/Dropdown/context.ts
Normal file
35
packages/ui/src/v2/Dropdown/context.ts
Normal 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);
|
||||||
|
}
|
||||||
93
packages/ui/src/v2/Dropdown/variants.ts
Normal file
93
packages/ui/src/v2/Dropdown/variants.ts
Normal 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",
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user