Add shadcn/ui dropdown menu component

This commit is contained in:
cte 2025-02-03 11:48:20 -08:00
parent 6da05598bf
commit a7101a9b94
8 changed files with 1210 additions and 1290 deletions

View file

@ -2,12 +2,7 @@ import type { StorybookConfig } from "@storybook/react-vite"
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-onboarding",
"@storybook/addon-essentials",
"@chromatic-com/storybook",
"@storybook/addon-interactions",
],
addons: ["@storybook/addon-essentials", "@storybook/addon-interactions"],
framework: {
name: "@storybook/react-vite",
options: {},

File diff suppressed because it is too large Load diff

View file

@ -13,6 +13,8 @@
"build-storybook": "storybook build"
},
"dependencies": {
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-slot": "^1.1.1",
"@tailwindcss/vite": "^4.0.0",
"@vscode/webview-ui-toolkit": "^1.4.0",
@ -36,14 +38,12 @@
"vscrui": "^0.2.0"
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.4",
"@storybook/addon-essentials": "^8.5.2",
"@storybook/addon-interactions": "^8.5.2",
"@storybook/addon-onboarding": "^8.5.2",
"@storybook/addon-essentials": "^8.5.3",
"@storybook/addon-interactions": "^8.5.3",
"@storybook/blocks": "^8.5.2",
"@storybook/react": "^8.5.2",
"@storybook/react-vite": "^8.5.2",
"@storybook/test": "^8.5.2",
"@storybook/react": "^8.5.3",
"@storybook/react-vite": "^8.5.3",
"@storybook/test": "^8.5.3",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
@ -65,7 +65,7 @@
"jest": "^27.5.1",
"jest-environment-jsdom": "^27.5.1",
"jest-simple-dot-reporter": "^1.0.5",
"storybook": "^8.5.2",
"storybook": "^8.5.3",
"ts-jest": "^27.1.5",
"typescript": "^4.9.5",
"vite": "6.0.11"

View file

@ -0,0 +1,177 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, DotFilledIcon } from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View file

@ -0,0 +1,2 @@
export * from "./button"
export * from "./dropdown-menu"

View file

@ -1,15 +1,47 @@
import type { Meta, StoryObj } from "@storybook/react"
import { fn } from "@storybook/test"
import { Button } from "@/components/ui/button"
import { Button } from "@/components/ui"
const meta = {
title: "Example/Button",
title: "@shadcn/Button",
component: Button,
parameters: { layout: "centered" },
tags: ["autodocs"],
argTypes: {},
args: { onClick: fn(), children: "Button" },
argTypes: {
variant: {
control: "select",
options: ["default", "secondary", "outline", "ghost", "link", "destructive"],
type: "string",
table: {
defaultValue: {
summary: "default",
},
},
},
size: {
control: "select",
options: ["default", "sm", "lg", "icon"],
type: "string",
table: {
defaultValue: {
summary: "default",
},
},
},
children: {
table: {
disable: true,
},
},
asChild: {
table: {
disable: true,
},
},
},
args: {
children: "Button",
},
} satisfies Meta<typeof Button>
export default meta
@ -17,37 +49,5 @@ export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
variant: "default",
},
}
export const Secondary: Story = {
args: {
variant: "secondary",
},
}
export const Outline: Story = {
args: {
variant: "outline",
},
}
export const Ghost: Story = {
args: {
variant: "ghost",
},
}
export const Link: Story = {
args: {
variant: "link",
},
}
export const Destructive: Story = {
args: {
variant: "destructive",
},
name: "Button",
}

View file

@ -0,0 +1,134 @@
import type { Meta, StoryObj } from "@storybook/react"
import {
HamburgerMenuIcon,
BorderLeftIcon,
BorderRightIcon,
BorderBottomIcon,
BorderTopIcon,
TextAlignLeftIcon,
TextAlignCenterIcon,
TextAlignRightIcon,
} from "@radix-ui/react-icons"
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui"
const meta = {
title: "@shadcn/DropdownMenu",
component: DropdownMenu,
parameters: { layout: "centered" },
tags: ["autodocs"],
} satisfies Meta<typeof DropdownMenu>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
name: "DropdownMenu",
render: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<HamburgerMenuIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Label</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>Item 1</DropdownMenuItem>
<DropdownMenuItem>
Item 2<DropdownMenuShortcut>2</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Submenu</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem>Foo</DropdownMenuItem>
<DropdownMenuItem>
Bar
<DropdownMenuShortcut>B</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Baz</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
),
}
type DropdownMenuVariantProps = {
side?: "top" | "bottom" | "left" | "right"
align?: "start" | "center" | "end"
children?: React.ReactNode
}
const DropdownMenuVariant = ({ side = "bottom", align = "center", children }: DropdownMenuVariantProps) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
{children}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side={side} align={align}>
<DropdownMenuItem>Foo</DropdownMenuItem>
<DropdownMenuItem>Bar</DropdownMenuItem>
<DropdownMenuItem>Baz</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
export const Placements: Story = {
render: () => (
<div className="flex gap-2">
<DropdownMenuVariant side="top">
<BorderTopIcon />
</DropdownMenuVariant>
<DropdownMenuVariant side="bottom">
<BorderBottomIcon />
</DropdownMenuVariant>
<DropdownMenuVariant side="left">
<BorderLeftIcon />
</DropdownMenuVariant>
<DropdownMenuVariant side="right">
<BorderRightIcon />
</DropdownMenuVariant>
</div>
),
}
export const Alignments: Story = {
render: () => (
<div className="flex gap-2">
<DropdownMenuVariant align="center">
<TextAlignCenterIcon />
</DropdownMenuVariant>
<DropdownMenuVariant align="end">
<TextAlignRightIcon />
</DropdownMenuVariant>
<DropdownMenuVariant align="start">
<TextAlignLeftIcon />
</DropdownMenuVariant>
</div>
),
}

View file

@ -0,0 +1,47 @@
import type { Meta, StoryObj } from "@storybook/react"
import { Dropdown } from "vscrui"
const meta = {
title: "@vscrui/Dropdown",
component: () => (
<Dropdown
value="foo"
role="combobox"
options={[
{ value: "foo", label: "Foo" },
{ value: "bar", label: "Bar" },
{ value: "baz", label: "Baz" },
]}
/>
),
parameters: { layout: "centered" },
tags: ["autodocs"],
argTypes: {},
args: {},
} satisfies Meta<typeof Dropdown>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {},
parameters: {
docs: {
source: {
code: `
<Dropdown
value="foo"
role="combobox"
options={[
{ value: "foo", label: "Foo" },
{ value: "bar", label: "Bar" },
{ value: "baz", label: "Baz" }
]}
/>`,
language: "tsx",
},
},
},
}