make a context menu

This commit is contained in:
Nate Kelley 2025-03-01 13:07:03 -07:00
parent 43d4ded84e
commit 4dcbda8c13
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
10 changed files with 806 additions and 79 deletions

82
web/package-lock.json generated
View File

@ -12,7 +12,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@faker-js/faker": "^9.5.0",
"@faker-js/faker": "^9.5.1",
"@fluentui/react-context-selector": "^9.1.72",
"@manufac/echarts-simple-transform": "^2.0.11",
"@million/lint": "^1.0.14",
@ -20,6 +20,7 @@
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-context-menu": "^2.2.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.6",
@ -32,8 +33,8 @@
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.49.1",
"@tailwindcss/forms": "^0.5.10",
"@tanstack/react-query": "^5.66.9",
"@tanstack/react-query-devtools": "^5.66.9",
"@tanstack/react-query": "^5.66.11",
"@tanstack/react-query-devtools": "^5.66.11",
"@vercel/speed-insights": "^1.2.0",
"ahooks": "^3.8.4",
"antd": "5.23.3",
@ -93,7 +94,7 @@
"react-markdown": "^9.0.3",
"react-material-symbols": "^4.4.0",
"react-monaco-editor": "^0.58.0",
"react-scan": "^0.2.3",
"react-scan": "^0.2.8",
"react-syntax-highlighter": "^15.6.1",
"react-virtualized-auto-sizer": "^1.0.25",
"rehype-raw": "^7.0.0",
@ -133,7 +134,7 @@
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-window": "^1.8.8",
"@types/uuid": "^10.0.0",
"chromatic": "^11.25.2",
"chromatic": "^11.26.1",
"cypress": "^13.17.0",
"eslint": "^8",
"eslint-config-next": "14.2.3",
@ -145,7 +146,7 @@
"monaco-editor-webpack-plugin": "^7.1.0",
"postcss": "8.5.3",
"sass": "^1.85.1",
"storybook": "^8.6.0",
"storybook": "^8.6.2",
"tailwind-scrollbar": "^4.0.1",
"tailwindcss": "^4.0.9",
"tailwindcss-animate": "^1.0.7",
@ -2427,6 +2428,7 @@
},
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0",
"extraneous": true,
"inBundle": true,
"license": "MIT",
"engines": {
@ -3207,9 +3209,9 @@
}
},
"node_modules/@faker-js/faker": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.5.0.tgz",
"integrity": "sha512-3qbjLv+fzuuCg3umxc9/7YjrEXNaKwHgmig949nfyaTx8eL4FAsvFbu+1JcFUj1YAXofhaDn6JdEUBTYuk0Ssw==",
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.5.1.tgz",
"integrity": "sha512-0fzMEDxkExR2cn731kpDaCCnBGBUOIXEi2S1N5l8Hltp6aPf4soTMJ+g4k8r2sI5oB+rpwIW8Uy/6jkwGpnWPg==",
"funding": [
{
"type": "opencollective",
@ -5770,6 +5772,34 @@
}
}
},
"node_modules/@radix-ui/react-context-menu": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.6.tgz",
"integrity": "sha512-aUP99QZ3VU84NPsHeaFt4cQUNgJqFsLLOt/RbbWXszZ6MP0DpDyjkFZORr4RpAEx3sUBk+Kc8h13yGtC5Qw8dg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-menu": "2.1.6",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@ -7916,9 +7946,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "5.66.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.66.4.tgz",
"integrity": "sha512-skM/gzNX4shPkqmdTCSoHtJAPMTtmIJNS0hE+xwTTUVYwezArCT34NMermABmBVUg5Ls5aiUXEDXfqwR1oVkcA==",
"version": "5.66.11",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.66.11.tgz",
"integrity": "sha512-ZEYxgHUcohj3sHkbRaw0gYwFxjY5O6M3IXOYXEun7E1rqNhsP8fOtqjJTKPZpVHcdIdrmX4lzZctT4+pts0OgA==",
"license": "MIT",
"funding": {
"type": "github",
@ -7936,12 +7966,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.66.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.66.9.tgz",
"integrity": "sha512-NRI02PHJsP5y2gAuWKP+awamTIBFBSKMnO6UVzi03GTclmHHHInH5UzVgzi5tpu4+FmGfsdT7Umqegobtsp23A==",
"version": "5.66.11",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.66.11.tgz",
"integrity": "sha512-uPDiQbZScWkAeihmZ9gAm3wOBA1TmLB1KCB1fJ1hIiEKq3dTT+ja/aYM7wGUD+XiEsY4sDSE7p8VIz/21L2Dow==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.66.4"
"@tanstack/query-core": "5.66.11"
},
"funding": {
"type": "github",
@ -7952,9 +7982,9 @@
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.66.9",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.66.9.tgz",
"integrity": "sha512-70G6AR35he53SYUcUK6EdqNR18zejCv1rM6900gjZP408EAex56YLwVSeijzk9lWeU2J42G9Fjh0i1WngUTsgw==",
"version": "5.66.11",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.66.11.tgz",
"integrity": "sha512-a+zr2TN4dKpxVlJ9YBOC5YmpGWp2Ez2ZfIzsorVbrs/u2R+bVkLrU1u5e8WHzLdf6tXYueATqgeXWLHrvi4Dig==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.65.0"
@ -7964,7 +7994,7 @@
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.66.9",
"@tanstack/react-query": "^5.66.11",
"react": "^18 || ^19"
}
},
@ -11420,9 +11450,9 @@
}
},
"node_modules/chromatic": {
"version": "11.25.2",
"resolved": "https://registry.npmjs.org/chromatic/-/chromatic-11.25.2.tgz",
"integrity": "sha512-/9eQWn6BU1iFsop86t8Au21IksTRxwXAl7if8YHD05L2AbuMjClLWZo5cZojqrJHGKDhTqfrC2X2xE4uSm0iKw==",
"version": "11.26.1",
"resolved": "https://registry.npmjs.org/chromatic/-/chromatic-11.26.1.tgz",
"integrity": "sha512-kVMTigrKI7TOOV04i1lTTIVJsmQ+fj6ZFXyZ3LcdCioOrxO/zCVB1y74iX0iKS++cpi3bJcG+UszkmvptGDEuA==",
"dev": true,
"license": "MIT",
"bin": {
@ -22523,9 +22553,9 @@
}
},
"node_modules/react-scan": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/react-scan/-/react-scan-0.2.3.tgz",
"integrity": "sha512-hLUj/yIMCUI/v6Mcym5qABFM1wC4R9TEVzDc6qWWckmEQnMMMPcOApKaq95NpKY+qpP1HPZyb7R25uR/rCipEw==",
"version": "0.2.8",
"resolved": "https://registry.npmjs.org/react-scan/-/react-scan-0.2.8.tgz",
"integrity": "sha512-+6Gvu9b0UMmzV0JkigA7Y2YcjQABiNrweP9l9j8nrutN5OAYLRe4JgfwiUohPFngMD+Y6I5N0kW+okXhvVLGUw==",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.26.0",

View File

@ -29,6 +29,7 @@
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-context-menu": "^2.2.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-select": "^2.1.6",

View File

@ -0,0 +1,219 @@
'use client';
import * as React from 'react';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { Check, ShapeCircle as Circle } from '@/components/ui/icons';
import { CaretRight } from '@/components/ui/icons/NucleoIconFilled';
import { cn } from '@/lib/utils';
import { DropdownMenuLink } from '../dropdown/DropdownBase';
/*
The styling of this and the dropdown is the same. TODO: Refactor to share styles
*/
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'focus:bg-item-hover data-[state=open]:bg-item-hover flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
{...props}>
{children}
<div className="text-icon-color text-3xs ml-auto">
<CaretRight />
</div>
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const baseContentClass = cn(
`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 z-50 min-w-[8rem] overflow-hidden `,
'bg-background text-foreground ',
'rounded-md border p-1'
);
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(baseContentClass, 'shadow-lg', className)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(baseContentClass, 'shadow', className)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
truncate?: boolean;
}
>(({ className, inset, truncate, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
'focus:bg-item-hover focus:text-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-60 [&_svg]:pointer-events-none [&_svg]:shrink-0',
inset && 'pl-8',
truncate && 'overflow-hidden',
'group',
className
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const itemClass = cn(
'focus:bg-item-hover focus:text-foreground',
'relative flex cursor-pointer items-center rounded-sm py-1.5 text-sm transition-colors outline-none select-none',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-60',
'gap-1.5'
);
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
truncate?: boolean;
}
>(({ className, children, checked, truncate, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
itemClass,
'data-[state=checked]:bg-item-hover',
'pr-6 pl-2',
truncate && 'overflow-hidden',
className
)}
checked={checked}
{...props}>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<div className="flex h-4 w-4 items-center justify-center">
<Check />
</div>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none 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">
<ContextMenuPrimitive.ItemIndicator>
<div className="fill-content h-2 w-2">
<Circle />
</div>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn('text-gray-dark px-2 py-1.5 text-sm', inset && 'pl-8', className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn('bg-border -mx-1 my-1 h-[0.5px]', className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
);
};
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
const ContextMenuLink: React.FC<{
className?: string;
link: string;
linkIcon?: 'arrow-right' | 'arrow-external' | 'caret-right';
}> = ({ className, link, linkIcon = 'arrow-external' }) => {
return <DropdownMenuLink className={className} link={link} linkIcon={linkIcon} />;
};
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
ContextMenuLink
};

View File

@ -0,0 +1,341 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ContextMenu, ContextProps } from './ContextMenu';
import {
Window,
WindowSettings,
WindowUser,
WindowEdit,
File,
WindowDownload,
CircleCopy
} from '../icons/NucleoIconOutlined';
import React from 'react';
const meta: Meta<typeof ContextMenu> = {
title: 'UI/Context/ContextMenu',
component: ContextMenu,
parameters: {
layout: 'centered'
},
argTypes: {
disabled: {
control: 'boolean',
defaultValue: false
}
},
tags: ['autodocs']
};
export default meta;
type Story = StoryObj<typeof ContextMenu>;
// Basic example with simple items
export const Basic: Story = {
args: {
items: [
{
label: 'Edit',
onClick: () => alert('Edit clicked'),
icon: <WindowEdit />
},
{
label: 'Settings',
onClick: () => alert('Settings clicked'),
icon: <WindowSettings />
},
{
label: 'Logout',
onClick: () => alert('Logout clicked'),
icon: <Window />
}
]
},
render: (args) => (
<div className="flex h-[200px] w-[200px] items-center justify-center rounded-md border border-dashed">
<ContextMenu items={args.items} disabled={args.disabled}>
<div className="h-full w-full bg-gray-200 p-4 text-center">
Right-click here to open context menu
</div>
</ContextMenu>
</div>
)
};
// Example with dividers and shortcuts
export const WithDividersAndShortcuts: Story = {
args: {
items: [
{
label: 'Profile',
onClick: () => alert('Profile clicked'),
icon: <WindowUser />,
shortcut: '⌘P'
},
{
label: 'Settings',
onClick: () => alert('Settings clicked'),
icon: <WindowSettings />,
shortcut: '⌘S'
},
{ type: 'divider' },
{
label: 'Logout',
onClick: () => alert('Logout clicked'),
icon: <Window />,
shortcut: '⌘L'
}
]
},
render: (args) => (
<div className="flex h-[200px] w-[200px] items-center justify-center rounded-md border border-dashed">
<ContextMenu items={args.items} disabled={args.disabled}>
<div className="h-full w-full bg-gray-200 p-4 text-center">
Right-click here to open context menu
</div>
</ContextMenu>
</div>
)
};
// Example with nested items
export const WithNestedItems: Story = {
args: {
items: [
{
label: 'File',
icon: <File />,
items: [
{
label: 'New',
onClick: () => alert('New file clicked')
},
{
label: 'Open',
onClick: () => alert('Open file clicked')
},
{
label: 'Save',
onClick: () => alert('Save file clicked'),
shortcut: '⌘S'
}
]
},
{
label: 'Edit',
icon: <WindowEdit />,
items: [
{
label: 'Copy',
onClick: () => alert('Copy clicked'),
icon: <CircleCopy />,
shortcut: '⌘C'
},
{
label: 'Delete',
onClick: () => alert('Delete clicked'),
icon: <Window />,
shortcut: '⌫'
}
]
}
]
},
render: (args) => (
<div className="flex h-[200px] w-[200px] items-center justify-center rounded-md border border-dashed">
<ContextMenu items={args.items} disabled={args.disabled}>
<div className="h-full w-full bg-gray-200 p-4 text-center">
Right-click here to open context menu
</div>
</ContextMenu>
</div>
)
};
// Example with disabled items
export const WithDisabledItems: Story = {
args: {
items: [
{
label: 'Edit',
onClick: () => alert('Edit clicked'),
icon: <WindowEdit />
},
{
label: 'Delete',
onClick: () => alert('Delete clicked'),
icon: <Window />,
disabled: true
},
{
label: 'Download',
onClick: () => alert('Download clicked'),
icon: <WindowDownload />
}
]
},
render: (args) => (
<div className="flex h-[200px] w-[200px] items-center justify-center rounded-md border border-dashed">
<ContextMenu items={args.items} disabled={args.disabled}>
<div className="h-full w-full bg-gray-200 p-4 text-center">
Right-click here to open context menu
</div>
</ContextMenu>
</div>
)
};
// Example with loading state
export const WithLoadingItems: Story = {
args: {
items: [
{
label: 'Normal Item',
onClick: () => alert('Normal clicked')
},
{
label: 'Loading Item',
loading: true,
onClick: () => alert('Loading clicked')
},
{ type: 'divider' },
{
label: 'Another Item',
onClick: () => alert('Another clicked')
}
]
},
render: (args) => (
<div className="flex h-[200px] w-[200px] items-center justify-center rounded-md border border-dashed">
<ContextMenu items={args.items} disabled={args.disabled}>
<div className="h-full w-full bg-gray-200 p-4 text-center">
Right-click here to open context menu
</div>
</ContextMenu>
</div>
)
};
// Example with selection
export const WithSelection: Story = {
args: {
items: [
{
label: 'Option 1',
onClick: () => alert('Option 1 clicked'),
selected: false
},
{
label: 'Option 2',
onClick: () => alert('Option 2 clicked'),
selected: true
},
{
label: 'Option 3',
onClick: () => alert('Option 3 clicked'),
selected: false
}
]
},
render: (args) => (
<div className="flex h-[200px] w-[200px] items-center justify-center rounded-md border border-dashed">
<ContextMenu items={args.items} disabled={args.disabled}>
<div className="h-full w-full bg-gray-200 p-4 text-center">
Right-click here to open context menu
</div>
</ContextMenu>
</div>
)
};
// Example with secondary labels and truncation
export const WithSecondaryLabels: Story = {
args: {
items: [
{
label: 'Document 1',
secondaryLabel: 'Last edited 2 days ago',
onClick: () => alert('Document 1 clicked'),
icon: <File />
},
{
label: 'Document with a very long name that should be truncated',
secondaryLabel: 'Last edited yesterday',
truncate: true,
onClick: () => alert('Document 2 clicked'),
icon: <File />
},
{
label: 'Document 3',
secondaryLabel: 'Last edited just now',
onClick: () => alert('Document 3 clicked'),
icon: <File />
}
]
},
render: (args) => (
<div className="flex h-[200px] w-[200px] items-center justify-center rounded-md border border-dashed">
<ContextMenu items={args.items} disabled={args.disabled}>
<div className="h-full w-full bg-gray-200 p-4 text-center">
Right-click here to open context menu
</div>
</ContextMenu>
</div>
)
};
// Example with links
export const WithLinks: Story = {
args: {
items: [
{
label: 'Documentation',
link: 'https://example.com/docs',
linkIcon: 'arrow-external'
},
{
label: 'Settings',
link: '/settings',
linkIcon: 'arrow-right'
},
{
label: 'Profile',
link: '/profile',
linkIcon: 'caret-right'
}
]
},
render: (args) => (
<div className="flex h-[200px] w-[200px] items-center justify-center rounded-md border border-dashed">
<ContextMenu items={args.items} disabled={args.disabled} className="">
<div className="h-full w-full bg-gray-200 p-4 text-center">
Right-click here to open context menu
</div>
</ContextMenu>
</div>
)
};
// Example with custom width
export const CustomWidth: Story = {
args: {
items: [
{
label: 'This is a menu item with a very long label that might need to be constrained',
onClick: () => alert('Long item clicked')
},
{
label: 'Short item',
onClick: () => alert('Short item clicked')
}
]
},
render: (args) => (
<div className="flex h-[200px] w-[200px] items-center justify-center rounded-md border border-dashed">
<ContextMenu items={args.items} disabled={args.disabled} className="min-w-[400px]">
<div className="h-full w-full bg-gray-200 p-4 text-center">
Right-click here to open context menu
</div>
</ContextMenu>
</div>
)
};

View File

@ -0,0 +1,176 @@
import React from 'react';
import {
ContextMenu as ContextMenuPrimitive,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem as ContextMenuItemPrimitive,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
ContextMenuLink,
ContextMenuPortal
} from './ContextBase';
import { ContextMenuProps } from '@radix-ui/react-context-menu';
import CircleSpinnerLoader from '../loaders/CircleSpinnerLoader';
import { cn } from '@/lib/classMerge';
export interface ContextMenuItem {
label: React.ReactNode | string;
truncate?: boolean;
secondaryLabel?: string;
showIndex?: boolean;
shortcut?: string;
onClick?: () => void;
icon?: React.ReactNode;
disabled?: boolean;
loading?: boolean;
selected?: boolean; //if a boolean is provided, it will render a checkboxitem component
items?: ContextMenuItem[];
link?: string;
linkIcon?: 'arrow-right' | 'arrow-external' | 'caret-right';
}
export interface ContextMenuDivider {
type: 'divider';
}
export type ContextMenuItems = (ContextMenuItem | ContextMenuDivider | React.ReactNode)[];
export interface ContextProps extends ContextMenuProps {
items: ContextMenuItems;
className?: string;
disabled?: boolean;
}
const contextMenuItemKey = (item: ContextMenuItems[number], index: number) => {
if ((item as ContextMenuDivider).type === 'divider') return `divider-${index}`;
return `item-${index}`;
};
export const ContextMenu: React.FC<ContextProps> = React.memo(
({ items, className, disabled, children, dir, modal }) => {
return (
<ContextMenuPrimitive dir={dir} modal={modal}>
<ContextMenuTrigger disabled={disabled} asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className={cn('max-w-72 min-w-44', className)}>
{items.map((item, index) => (
<ContextMenuItemSelector
key={contextMenuItemKey(item, index)}
item={item}
index={index}
/>
))}
</ContextMenuContent>
</ContextMenuPrimitive>
);
}
);
const ContextMenuItemSelector: React.FC<{
item: ContextMenuItems[number];
index: number;
}> = React.memo(({ item, index }) => {
if ((item as ContextMenuDivider).type === 'divider') {
return <ContextMenuSeparator key={contextMenuItemKey(item, index)} />;
}
if (typeof item === 'object' && React.isValidElement(item)) {
return item;
}
return <ContextMenuItemComponent {...(item as ContextMenuItem)} index={index} />;
});
ContextMenuItemSelector.displayName = 'ContextMenuItemSelector';
const ContextMenuItemComponent: React.FC<ContextMenuItem & { index: number }> = ({
label,
showIndex,
shortcut,
onClick,
items,
icon,
disabled,
loading,
selected,
secondaryLabel,
truncate,
link,
linkIcon,
index
}) => {
const isSubItem = items && items.length > 0;
const content = (
<>
{showIndex && <span className="text-gray-light">{index}</span>}
{icon && !loading && <span className="text-icon-color">{icon}</span>}
{icon && loading && <CircleSpinnerLoader size={9} />}
<div className={cn('flex flex-col gap-y-1', truncate && 'overflow-hidden')}>
<span className={cn(truncate && 'truncate', 'flex items-center gap-x-1')}>
{label}
{!icon && loading && <CircleSpinnerLoader size={9} />}
</span>
{secondaryLabel && <span className="text-gray-light text2xs">{secondaryLabel}</span>}
</div>
{shortcut && <ContextMenuShortcut>{shortcut}</ContextMenuShortcut>}
{link && (
<ContextMenuLink className="-mr-1 ml-auto opacity-100" link={link} linkIcon={linkIcon} />
)}
</>
);
if (isSubItem) {
return <ContextSubMenuWrapper items={items}>{content}</ContextSubMenuWrapper>;
}
const isSelectable = typeof selected === 'boolean';
if (isSelectable) {
return (
<ContextMenuCheckboxItem
truncate={truncate}
disabled={disabled}
onClick={onClick}
checked={selected}>
{content}
</ContextMenuCheckboxItem>
);
}
return (
<ContextMenuItemPrimitive truncate={truncate} disabled={disabled} onClick={onClick}>
{content}
</ContextMenuItemPrimitive>
);
};
const ContextSubMenuWrapper = React.memo(
({ items, children }: { items: ContextMenuItems | undefined; children: React.ReactNode }) => {
return (
<ContextMenuSub>
<ContextMenuSubTrigger>{children}</ContextMenuSubTrigger>
<ContextMenuPortal>
<ContextMenuSubContent>
{items?.map((item, index) => (
<ContextMenuItemSelector
key={contextMenuItemKey(item, index)}
item={item}
index={index}
/>
))}
</ContextMenuSubContent>
</ContextMenuPortal>
</ContextMenuSub>
);
}
);
ContextSubMenuWrapper.displayName = 'ContextSubMenuWrapper';

View File

View File

@ -51,8 +51,6 @@ export interface DropdownProps extends DropdownMenuProps {
items: DropdownItems;
selectType?: 'single' | 'multiple' | 'none';
menuHeader?: string | React.ReactNode; //if string it will render a search box
minWidth?: number;
maxWidth?: number;
closeOnSelect?: boolean;
onSelect?: (itemId: string) => void;
align?: 'start' | 'center' | 'end';
@ -73,8 +71,6 @@ export const Dropdown: React.FC<DropdownProps> = React.memo(
items = [],
selectType = 'none',
menuHeader,
minWidth = 240,
maxWidth,
closeOnSelect = true,
onSelect,
children,

View File

@ -2,7 +2,7 @@ import { ConfigProvider, Menu, MenuProps } from 'antd';
import { createStyles } from 'antd-style';
import { AnimatePresence, motion } from 'framer-motion';
import React, { forwardRef, useMemo } from 'react';
import { BusterListContextMenu, BusterMenuItemType } from './interfaces';
import { BusterListContextMenu } from './interfaces';
import { MenuItemType } from 'antd/es/menu/interface';
export interface BusterListContentMenuProps {
@ -23,7 +23,7 @@ export const BusterListContentMenu = forwardRef<HTMLDivElement, BusterListConten
...(item as MenuItemType),
popupClassName: cx(styles.popUpClassMenu),
onClick: () => {
(item as BusterMenuItemType)?.onClick?.(id);
item?.onClick?.(id);
}
};
}) || [],
@ -67,7 +67,7 @@ export const BusterListContentMenu = forwardRef<HTMLDivElement, BusterListConten
);
BusterListContentMenu.displayName = 'BusterListContentMenu';
const useStyles = createStyles(({ token, prefixCls, css }) => ({
const useStyles = createStyles(({ token, css }) => ({
popUpClassMenu: css`
.busterv2-menu {
border: 0.5px solid ${token.colorBorder};

View File

@ -1,15 +1,14 @@
import { useMemoizedFn } from 'ahooks';
import React from 'react';
import { MemoizedCheckbox } from './MemoizedCheckbox';
import { createStyles } from 'antd-style';
import { WIDTH_OF_CHECKBOX_COLUMN } from './config';
import { cn } from '@/lib/classMerge';
export const CheckboxColumn: React.FC<{
checkStatus: 'checked' | 'unchecked' | 'indeterminate' | undefined;
onChange: (v: boolean) => void;
className?: string;
}> = React.memo(({ checkStatus, onChange, className = '' }) => {
const { styles, cx } = useStyles();
const showBox = checkStatus === 'checked'; //|| checkStatus === 'indeterminate';
const onClickStopPropagation = useMemoizedFn((e: React.MouseEvent<HTMLDivElement>) => {
@ -19,11 +18,13 @@ export const CheckboxColumn: React.FC<{
return (
<div
onClick={onClickStopPropagation}
className={cx(
style={{
width: `${WIDTH_OF_CHECKBOX_COLUMN}px`,
minWidth: `${WIDTH_OF_CHECKBOX_COLUMN}px`
}}
className={cn(
className,
styles.checkboxColumn,
'flex items-center justify-center opacity-0',
'group-hover:opacity-100',
'flex items-center justify-center pr-0 pl-1 opacity-0 group-hover:opacity-100',
showBox ? 'opacity-100' : ''
)}>
<MemoizedCheckbox
@ -35,15 +36,3 @@ export const CheckboxColumn: React.FC<{
);
});
CheckboxColumn.displayName = 'CheckboxColumn';
export const useStyles = createStyles(({ css, token }) => ({
checkboxColumn: css`
padding-left: 4px;
padding-right: 0px;
width: ${WIDTH_OF_CHECKBOX_COLUMN}px;
min-width: ${WIDTH_OF_CHECKBOX_COLUMN}px;
display: flex;
align-items: center;
justify-content: center;
`
}));

View File

@ -1,11 +1,5 @@
import { MenuProps } from 'antd';
import type { MenuDividerType } from 'antd/es/menu/interface';
import type {
MenuItemType as RcMenuItemType,
SubMenuType as RcSubMenuType
} from 'rc-menu/lib/interface';
import React from 'react';
import { Dropdown } from '../../dropdown';
export interface BusterListProps {
columns: BusterListColumn[];
hideLastRowBorder?: boolean;
@ -49,25 +43,6 @@ export interface BusterListSectionRow {
}
//CONTEXT MENU INTERFACES
export interface BusterListContextMenu extends Omit<MenuProps, 'items'> {
items: BusterListMenuItemType[];
export interface BusterListContextMenu {
items: any[];
}
export interface BusterMenuItemType extends Omit<RcMenuItemType, 'onSelect' | 'onClick'> {
danger?: boolean;
icon?: React.ReactNode;
title?: string;
onClick?: (id: string) => void;
key: string;
}
export interface SubMenuType<T extends BusterMenuItemType = BusterMenuItemType>
extends Omit<RcSubMenuType, 'children' | 'onClick'> {
icon?: React.ReactNode;
theme?: 'dark' | 'light';
children: BusterListMenuItemType<T>[];
}
export type BusterListMenuItemType<T extends BusterMenuItemType = BusterMenuItemType> =
| T
| SubMenuType<T>
| MenuDividerType
| null;