custom dropdown component

This commit is contained in:
Nate Kelley 2025-02-25 16:24:53 -07:00
parent e068c27b8c
commit bbbbcaa1b8
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
8 changed files with 482 additions and 67 deletions

View File

@ -1,6 +1,7 @@
---
description: Rules for the components ui directory
globs: src/components/ui/**/*
alwaysApply: false
---
# Component Directory Structure and Organization
@ -65,6 +66,10 @@ globs: src/components/ui/**/*
import { Title } from '@/components/ui';
```
##
I have a helper called `cn`. This is how I do a tailwind merge and classnames concatination. This is found in import { cn } from '@/lib/classMerge';
## File Naming
- Use PascalCase for component files
- File name should match the component name

32
web/package-lock.json generated
View File

@ -17,6 +17,7 @@
"@manufac/echarts-simple-transform": "^2.0.11",
"@million/lint": "^1.0.14",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-switch": "^1.1.3",
@ -2420,7 +2421,6 @@
},
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0",
"extraneous": true,
"inBundle": true,
"license": "MIT",
"engines": {
@ -5622,6 +5622,36 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.4.tgz",
"integrity": "sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-previous": "1.1.0",
"@radix-ui/react-use-size": "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-collection": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz",

View File

@ -26,6 +26,7 @@
"@manufac/echarts-simple-transform": "^2.0.11",
"@million/lint": "^1.0.14",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-switch": "^1.1.3",

View File

@ -0,0 +1,126 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Checkbox } from './Checkbox';
const meta: Meta<typeof Checkbox> = {
title: 'Base/Checkbox',
component: Checkbox,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['default'],
description: 'The visual style of the checkbox'
},
size: {
control: 'select',
options: ['default', 'sm', 'lg'],
description: 'The size of the checkbox'
},
disabled: {
control: 'boolean',
description: 'Whether the checkbox is disabled'
},
checked: {
control: 'select',
options: [true, false, 'indeterminate'],
description: 'Whether the checkbox is checked (controlled)'
},
defaultChecked: {
control: 'boolean',
description: 'The default checked state (uncontrolled)'
},
onCheckedChange: {
description: 'Callback when the checked state changes'
}
}
};
export default meta;
type Story = StoryObj<typeof Checkbox>;
// Default variant with all sizes
export const Default: Story = {
args: {
variant: 'default',
size: 'default'
}
};
export const Small: Story = {
args: {
variant: 'default',
size: 'sm'
}
};
export const Large: Story = {
args: {
variant: 'default',
size: 'lg'
}
};
// States
export const Checked: Story = {
args: {
checked: true,
size: 'default'
}
};
export const Disabled: Story = {
args: {
disabled: true,
size: 'default'
}
};
export const DisabledChecked: Story = {
args: {
disabled: true,
checked: true,
size: 'default'
}
};
// Example with event handler
export const WithOnChange: Story = {
args: {
onCheckedChange: (checked: boolean) => console.log('Checked:', checked)
}
};
// Example showing all sizes in a group
export const AllSizes: Story = {
render: () => (
<div className="flex items-center gap-4">
<Checkbox size="sm" />
<Checkbox size="default" />
<Checkbox size="lg" />
</div>
)
};
// Example showing different states
export const AllStates: Story = {
render: () => (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<Checkbox />
<span className="text-sm">Default</span>
</div>
<div className="flex items-center gap-4">
<Checkbox checked />
<span className="text-sm">Checked</span>
</div>
<div className="flex items-center gap-4">
<Checkbox disabled />
<span className="text-sm">Disabled</span>
</div>
<div className="flex items-center gap-4">
<Checkbox disabled checked />
<span className="text-sm">Disabled Checked</span>
</div>
</div>
)
};

View File

@ -0,0 +1,89 @@
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { cn } from '@/lib/classMerge';
import { Minus, Check } from '../icons';
import { cva, type VariantProps } from 'class-variance-authority';
const checkboxVariants = cva(
'peer relative h-4 w-4 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed',
{
variants: {
variant: {
default:
' ring-offset-background focus-visible:ring-ring data-[state=unchecked]:border-gray-light data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:disabled:bg-primary data-[state=checked]:text-background'
},
size: {
default: 'h-4 w-4 text-[10px]',
sm: 'h-3 w-3 text-[8px]',
lg: 'h-5 w-5 text-base'
},
disabled: {
true: 'opacity-60 cursor-not-allowed ',
false: 'cursor-pointer'
},
checked: {
true: '',
false: '',
indeterminate: ''
}
},
defaultVariants: {
variant: 'default',
size: 'default',
disabled: false
},
compoundVariants: [
{
variant: 'default',
checked: 'indeterminate',
className:
'bg-primary-light text-background border-primary-light hover:bg-primary! hover:border-primary! hover:disabled:bg-inherit'
}
]
}
);
type CheckboxVariants = VariantProps<typeof checkboxVariants>;
interface CheckboxProps
extends Omit<
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
keyof CheckboxVariants
>,
CheckboxVariants {
indeterminate?: boolean;
}
const Checkbox = React.forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root>, CheckboxProps>(
(
{
className,
variant = 'default',
size = 'default',
checked,
disabled = false,
indeterminate,
...props
},
ref
) => {
return (
<CheckboxPrimitive.Root
ref={ref}
disabled={disabled || false}
className={cn(checkboxVariants({ variant, size, disabled, checked }), className)}
checked={checked || undefined}
{...props}>
<CheckboxPrimitive.Indicator
className={cn('absolute inset-0 flex items-center justify-center')}>
<div className="text-background flex">
{checked === 'indeterminate' ? <Minus /> : <Check />}
</div>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
);
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox, type CheckboxProps };

View File

@ -1,8 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Dropdown } from './Dropdown';
import { Dropdown, DropdownItems } from './Dropdown';
import { Button } from '../buttons/Button';
import { PaintRoller, Star, Storage } from '../icons';
import { faker } from '@faker-js/faker';
import React from 'react';
const meta: Meta<typeof Dropdown> = {
title: 'Base/Dropdown',
@ -203,6 +204,17 @@ export const WithLoadingItems: Story = {
id: '3',
label: 'Another Normal',
onClick: () => console.log('Another clicked')
},
{ type: 'divider' },
{
id: '4',
label: 'Option 4',
onClick: () => console.log('Option 4 clicked')
},
{
id: '5',
label: 'Option 5',
onClick: () => console.log('Option 5 clicked')
}
],
children: <Button>Menu with Loading</Button>
@ -210,9 +222,9 @@ export const WithLoadingItems: Story = {
};
// Example with selection
export const WithSelection: Story = {
export const WithSelectionSingle: Story = {
args: {
selectType: true,
selectType: 'single',
items: [
{
id: '1',
@ -236,6 +248,68 @@ export const WithSelection: Story = {
}
};
export const WithSelectionMultiple: Story = {
render: () => {
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set(['3']));
const items: DropdownItems = [
{
id: '1',
label: 'Option 1',
selected: selectedIds.has('1'),
onClick: () => console.log('Option 1 clicked')
},
{
id: '2',
label: 'Option 2',
selected: selectedIds.has('2'),
onClick: () => console.log('Option 2 clicked')
},
{
id: '3',
label: 'Option 3',
selected: selectedIds.has('3'),
onClick: () => console.log('Option 3 clicked')
},
{ type: 'divider' as const },
{
id: '4',
label: 'Option 4',
selected: selectedIds.has('4'),
onClick: () => console.log('Option 4 clicked')
},
{
id: '5',
label: 'Option 5',
selected: selectedIds.has('5'),
onClick: () => console.log('Option 5 clicked')
}
];
const handleSelect = (itemId: string) => {
setSelectedIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(itemId)) {
newSet.delete(itemId);
} else {
newSet.add(itemId);
}
return newSet;
});
};
return (
<Dropdown
selectType="multiple"
items={items}
menuHeader={{ placeholder: 'Search items...' }}
onSelect={handleSelect}
children={<Button>Selection Menu</Button>}
/>
);
}
};
// Example with secondary labels
export const WithSecondaryLabel: Story = {
args: {

View File

@ -12,7 +12,8 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
DropdownMenuCheckboxItem
DropdownMenuCheckboxItemSingle,
DropdownMenuCheckboxItemMultiple
} from './DropdownBase';
import { CircleSpinnerLoader } from '../loaders/CircleSpinnerLoader';
import { useMemoizedFn } from 'ahooks';
@ -44,7 +45,7 @@ export type DropdownItems = (DropdownItem | DropdownDivider | React.ReactNode)[]
export interface DropdownProps extends DropdownMenuProps {
items?: DropdownItems;
selectType?: boolean;
selectType?: 'single' | 'multiple' | 'none';
menuHeader?: string | React.ReactNode | { placeholder: string };
minWidth?: number;
maxWidth?: number;
@ -65,7 +66,7 @@ const dropdownItemKey = (item: DropdownItems[number], index: number) => {
export const Dropdown: React.FC<DropdownProps> = React.memo(
({
items = [],
selectType = false,
selectType = 'none',
menuHeader,
minWidth = 240,
maxWidth,
@ -99,6 +100,29 @@ export const Dropdown: React.FC<DropdownProps> = React.memo(
return filteredItems.length > 0 && filteredItems.some((item) => (item as DropdownItem).id);
}, [filteredItems]);
const { selectedItems, unselectedItems } = useMemo(() => {
if (selectType === 'multiple') {
const [selectedItems, unselectedItems] = filteredItems.reduce(
(acc, item) => {
if ((item as DropdownItem).selected) {
acc[0].push(item);
} else {
acc[1].push(item);
}
return acc;
},
[[], []] as [typeof filteredItems, typeof filteredItems]
);
return { selectedItems, unselectedItems };
}
return {
selectedItems: [],
unselectedItems: []
};
}, [selectType, filteredItems]);
const dropdownItems = selectType === 'multiple' ? unselectedItems : filteredItems;
return (
<DropdownMenu open={open} defaultOpen={open} onOpenChange={onOpenChange} {...props}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
@ -117,18 +141,33 @@ export const Dropdown: React.FC<DropdownProps> = React.memo(
</>
)}
<div className="max-h-[300px] overflow-y-auto">
<div className="max-h-[350px] overflow-y-auto">
{hasShownItem ? (
filteredItems.map((item, index) => (
<DropdownItemSelector
item={item}
index={index}
selectType={selectType}
onSelect={onSelect}
closeOnSelect={closeOnSelect}
key={dropdownItemKey(item, index)}
/>
))
<>
{selectedItems.map((item, index) => (
<DropdownItemSelector
item={item}
index={index}
selectType={selectType}
onSelect={onSelect}
closeOnSelect={closeOnSelect}
key={dropdownItemKey(item, index)}
/>
))}
{selectedItems.length > 0 && <DropdownMenuSeparator />}
{dropdownItems.map((item, index) => (
<DropdownItemSelector
item={item}
index={index}
selectType={selectType}
onSelect={onSelect}
closeOnSelect={closeOnSelect}
key={dropdownItemKey(item, index)}
/>
))}
</>
) : (
<DropdownMenuItem disabled className="text-gray-light text-center">
{emptyStateText}
@ -192,7 +231,7 @@ const DropdownItem: React.FC<
items,
closeOnSelect,
onSelect,
selectType = false,
selectType,
secondaryLabel,
truncate
}) => {
@ -228,15 +267,27 @@ const DropdownItem: React.FC<
);
}
if (selectType) {
if (selectType === 'single') {
return (
<DropdownMenuCheckboxItem
<DropdownMenuCheckboxItemSingle
checked={selected}
disabled={disabled}
onClick={onClickItem}
closeOnSelect={closeOnSelect}>
{content}
</DropdownMenuCheckboxItem>
</DropdownMenuCheckboxItemSingle>
);
}
if (selectType === 'multiple') {
return (
<DropdownMenuCheckboxItemMultiple
checked={selected}
disabled={disabled}
onClick={onClickItem}
closeOnSelect={closeOnSelect}>
{content}
</DropdownMenuCheckboxItemMultiple>
);
}
@ -316,28 +367,25 @@ interface DropdownMenuHeaderSearchProps {
placeholder?: string;
}
const DropdownMenuHeaderSearch: React.FC<DropdownMenuHeaderSearchProps> = ({
text,
onChange,
placeholder
}) => {
return (
<div className="flex items-center gap-x-2">
<Input
autoFocus
variant={'ghost'}
placeholder={placeholder}
value={text}
onChange={(e) => {
e.stopPropagation();
e.preventDefault();
onChange(e.target.value);
}}
onKeyDown={(e) => {
e.stopPropagation();
}}
/>
{/*
const DropdownMenuHeaderSearch: React.FC<DropdownMenuHeaderSearchProps> = React.memo(
({ text, onChange, placeholder }) => {
return (
<div className="flex items-center gap-x-2">
<Input
autoFocus
variant={'ghost'}
placeholder={placeholder}
value={text}
onChange={(e) => {
e.stopPropagation();
e.preventDefault();
onChange(e.target.value);
}}
onKeyDown={(e) => {
e.stopPropagation();
}}
/>
{/*
<div className="flex pr-1 opacity-20 hover:opacity-100">
<Button
className="cursor-pointer"
@ -346,6 +394,9 @@ const DropdownMenuHeaderSearch: React.FC<DropdownMenuHeaderSearchProps> = ({
variant={'link'}
size={'small'}></Button>
</div> */}
</div>
);
};
</div>
);
}
);
DropdownMenuHeaderSearch.displayName = 'DropdownMenuHeaderSearch';

View File

@ -2,9 +2,10 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight } from '../icons/NucleoIconOutlined';
import { Check3 as Check, ChevronRight } from '../icons/NucleoIconOutlined';
import { cn } from '@/lib/classMerge';
import { Checkbox } from '../checkbox/Checkbox';
const DropdownMenu = DropdownMenuPrimitive.Root;
@ -38,16 +39,15 @@ const DropdownMenuSubTrigger = React.forwardRef<
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const baseContentClass = `bg-background text-foreground 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 rounded-md border p-1`;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'bg-background text-foreground 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 rounded-md border p-1 shadow-lg',
className
)}
className={cn(baseContentClass, 'shadow-lg', className)}
{...props}
/>
));
@ -61,10 +61,7 @@ const DropdownMenuContent = React.forwardRef<
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'bg-background text-foreground 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 rounded-md border p-1 shadow-md',
className
)}
className={cn(baseContentClass, 'shadow-md', className)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
@ -100,7 +97,13 @@ const DropdownMenuItem = React.forwardRef<
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
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-50'
);
const DropdownMenuCheckboxItemSingle = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
closeOnSelect?: boolean;
@ -109,10 +112,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
>(({ className, children, onClick, checked, closeOnSelect = true, selectType, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'focus:bg-item-hover focus:text-foreground relative flex cursor-pointer items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
className={cn(itemClass, 'data-[state=checked]:bg-item-hover', 'pr-6 pl-2', className)}
checked={checked}
onClick={(e) => {
if (closeOnSelect) {
@ -122,17 +122,55 @@ const DropdownMenuCheckboxItem = React.forwardRef<
onClick?.(e);
}}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
{children}
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<div className="flex h-4 w-4 items-center justify-center">
<Check />
</div>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
DropdownMenuCheckboxItemSingle.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuCheckboxItemMultiple = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
closeOnSelect?: boolean;
selectType?: boolean;
}
>(
(
{ className, children, onClick, checked = false, closeOnSelect = true, selectType, ...props },
ref
) => {
return (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(itemClass, 'group pr-2 pl-7', className)}
checked={checked}
onClick={(e) => {
if (closeOnSelect) {
e.stopPropagation();
e.preventDefault();
}
onClick?.(e);
}}
{...props}>
<span
className={cn(
'absolute left-2 flex h-3.5 w-3.5 items-center justify-center opacity-0 group-hover:opacity-100',
checked && 'opacity-100'
)}>
<Checkbox size="sm" checked={checked} />
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
);
DropdownMenuCheckboxItemMultiple.displayName = 'DropdownMenuCheckboxItemMultiple';
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
@ -156,7 +194,7 @@ const DropdownMenuSeparator = React.forwardRef<
ref={ref}
className={cn(
'bg-border dropdown-separator -mx-1 my-1 h-[0.5px]',
'[&.dropdown-separator:has(+.dropdown-separator)]:hidden [&.dropdown-separator:last-child]:hidden',
'[&.dropdown-separator:first-child]:hidden [&.dropdown-separator:has(+.dropdown-separator)]:hidden [&.dropdown-separator:last-child]:hidden',
className
)}
{...props}
@ -176,7 +214,7 @@ export {
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuCheckboxItemSingle,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
@ -184,5 +222,6 @@ export {
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
DropdownMenuSubTrigger,
DropdownMenuCheckboxItemMultiple
};