From bbbbcaa1b8016ceeb8c7b06f62ecdfd0b59c3121 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Tue, 25 Feb 2025 16:24:53 -0700 Subject: [PATCH] custom dropdown component --- web/.cursor/rules/components_ui_rules.mdc | 5 + web/package-lock.json | 32 +++- web/package.json | 1 + .../ui/checkbox/Checkbox.stories.tsx | 126 ++++++++++++++++ web/src/components/ui/checkbox/Checkbox.tsx | 89 ++++++++++++ .../ui/dropdown/Dropdown.stories.tsx | 80 +++++++++- web/src/components/ui/dropdown/Dropdown.tsx | 137 ++++++++++++------ .../components/ui/dropdown/DropdownBase.tsx | 79 +++++++--- 8 files changed, 482 insertions(+), 67 deletions(-) create mode 100644 web/src/components/ui/checkbox/Checkbox.stories.tsx create mode 100644 web/src/components/ui/checkbox/Checkbox.tsx diff --git a/web/.cursor/rules/components_ui_rules.mdc b/web/.cursor/rules/components_ui_rules.mdc index e1be52d78..88bcecd52 100644 --- a/web/.cursor/rules/components_ui_rules.mdc +++ b/web/.cursor/rules/components_ui_rules.mdc @@ -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 diff --git a/web/package-lock.json b/web/package-lock.json index 889ab329d..c4dbc0763 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 81cc3c16b..d50fee933 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/ui/checkbox/Checkbox.stories.tsx b/web/src/components/ui/checkbox/Checkbox.stories.tsx new file mode 100644 index 000000000..12efb235b --- /dev/null +++ b/web/src/components/ui/checkbox/Checkbox.stories.tsx @@ -0,0 +1,126 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Checkbox } from './Checkbox'; + +const meta: Meta = { + 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; + +// 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: () => ( +
+ + + +
+ ) +}; + +// Example showing different states +export const AllStates: Story = { + render: () => ( +
+
+ + Default +
+
+ + Checked +
+
+ + Disabled +
+
+ + Disabled Checked +
+
+ ) +}; diff --git a/web/src/components/ui/checkbox/Checkbox.tsx b/web/src/components/ui/checkbox/Checkbox.tsx new file mode 100644 index 000000000..9a4d904ce --- /dev/null +++ b/web/src/components/ui/checkbox/Checkbox.tsx @@ -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; + +interface CheckboxProps + extends Omit< + React.ComponentPropsWithoutRef, + keyof CheckboxVariants + >, + CheckboxVariants { + indeterminate?: boolean; +} + +const Checkbox = React.forwardRef, CheckboxProps>( + ( + { + className, + variant = 'default', + size = 'default', + checked, + disabled = false, + indeterminate, + ...props + }, + ref + ) => { + return ( + + +
+ {checked === 'indeterminate' ? : } +
+
+
+ ); + } +); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox, type CheckboxProps }; diff --git a/web/src/components/ui/dropdown/Dropdown.stories.tsx b/web/src/components/ui/dropdown/Dropdown.stories.tsx index 672c193b4..c4dbb507a 100644 --- a/web/src/components/ui/dropdown/Dropdown.stories.tsx +++ b/web/src/components/ui/dropdown/Dropdown.stories.tsx @@ -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 = { 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: @@ -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>(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 ( + Selection Menu} + /> + ); + } +}; + // Example with secondary labels export const WithSecondaryLabel: Story = { args: { diff --git a/web/src/components/ui/dropdown/Dropdown.tsx b/web/src/components/ui/dropdown/Dropdown.tsx index 9659a74f2..77084da2b 100644 --- a/web/src/components/ui/dropdown/Dropdown.tsx +++ b/web/src/components/ui/dropdown/Dropdown.tsx @@ -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 = React.memo( ({ items = [], - selectType = false, + selectType = 'none', menuHeader, minWidth = 240, maxWidth, @@ -99,6 +100,29 @@ export const Dropdown: React.FC = 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 ( {children} @@ -117,18 +141,33 @@ export const Dropdown: React.FC = React.memo( )} -
+
{hasShownItem ? ( - filteredItems.map((item, index) => ( - - )) + <> + {selectedItems.map((item, index) => ( + + ))} + + {selectedItems.length > 0 && } + + {dropdownItems.map((item, index) => ( + + ))} + ) : ( {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 ( - {content} - + + ); + } + + if (selectType === 'multiple') { + return ( + + {content} + ); } @@ -316,28 +367,25 @@ interface DropdownMenuHeaderSearchProps { placeholder?: string; } -const DropdownMenuHeaderSearch: React.FC = ({ - text, - onChange, - placeholder -}) => { - return ( -
- { - e.stopPropagation(); - e.preventDefault(); - onChange(e.target.value); - }} - onKeyDown={(e) => { - e.stopPropagation(); - }} - /> - {/* +const DropdownMenuHeaderSearch: React.FC = React.memo( + ({ text, onChange, placeholder }) => { + return ( +
+ { + e.stopPropagation(); + e.preventDefault(); + onChange(e.target.value); + }} + onKeyDown={(e) => { + e.stopPropagation(); + }} + /> + {/*
*/} -
- ); -}; +
+ ); + } +); + +DropdownMenuHeaderSearch.displayName = 'DropdownMenuHeaderSearch'; diff --git a/web/src/components/ui/dropdown/DropdownBase.tsx b/web/src/components/ui/dropdown/DropdownBase.tsx index 05724b230..1fee44d9b 100644 --- a/web/src/components/ui/dropdown/DropdownBase.tsx +++ b/web/src/components/ui/dropdown/DropdownBase.tsx @@ -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, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); @@ -61,10 +61,7 @@ const DropdownMenuContent = React.forwardRef< @@ -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, React.ComponentPropsWithoutRef & { closeOnSelect?: boolean; @@ -109,10 +112,7 @@ const DropdownMenuCheckboxItem = React.forwardRef< >(({ className, children, onClick, checked, closeOnSelect = true, selectType, ...props }, ref) => ( { if (closeOnSelect) { @@ -122,17 +122,55 @@ const DropdownMenuCheckboxItem = React.forwardRef< onClick?.(e); }} {...props}> - + {children} +
- {children}
)); -DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; +DropdownMenuCheckboxItemSingle.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuCheckboxItemMultiple = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + closeOnSelect?: boolean; + selectType?: boolean; + } +>( + ( + { className, children, onClick, checked = false, closeOnSelect = true, selectType, ...props }, + ref + ) => { + return ( + { + if (closeOnSelect) { + e.stopPropagation(); + e.preventDefault(); + } + onClick?.(e); + }} + {...props}> + + + + {children} + + ); + } +); +DropdownMenuCheckboxItemMultiple.displayName = 'DropdownMenuCheckboxItemMultiple'; const DropdownMenuLabel = React.forwardRef< React.ElementRef, @@ -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 };