Initial select 2 component

This commit is contained in:
Nate Kelley 2025-07-09 16:25:30 -06:00
parent 6f6e70c54c
commit 30f1315908
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
3 changed files with 620 additions and 3 deletions

View File

@ -23,9 +23,11 @@ Command.displayName = CommandPrimitive.displayName;
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-2.5" cmdk-input-wrapper="">
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & {
parentClassName?: string;
}
>(({ className, parentClassName, ...props }, ref) => (
<div className={cn('flex items-center border-b px-2.5', parentClassName)} cmdk-input-wrapper="">
<div className="text-icon-color mr-1.5 shrink-0">
<Magnifier />
</div>

View File

@ -0,0 +1,236 @@
import type { Meta, StoryObj } from '@storybook/react';
import React from 'react';
import { fn } from '@storybook/test';
import { Select, type SelectItem, type SelectProps } from './Select2';
import { User, Gear, PowerOff } from '@/components/ui/icons/NucleoIconOutlined';
const meta = {
title: 'UI/select/Select2',
component: Select,
parameters: {
layout: 'centered'
},
argTypes: {
search: {
control: { type: 'boolean' },
description: 'Enable/disable search functionality'
},
disabled: {
control: { type: 'boolean' },
description: 'Disable the select'
},
loading: {
control: { type: 'boolean' },
description: 'Show loading state'
},
showIndex: {
control: { type: 'boolean' },
description: 'Show index numbers for items'
},
placeholder: {
control: { type: 'text' },
description: 'Placeholder text when no item is selected'
},
onChange: {
action: 'onChange'
}
},
decorators: [
(Story) => (
<div style={{ width: '300px' }}>
<Story />
</div>
)
]
} satisfies Meta<typeof Select>;
export default meta;
type Story = StoryObj<typeof meta>;
// Basic select with simple string options
export const BasicSelect: Story = {
args: {
placeholder: 'Select a fruit',
items: [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'orange', label: 'Orange' },
{ value: 'grape', label: 'Grape' },
{ value: 'strawberry', label: 'Strawberry' },
{ value: 'watermelon', label: 'Watermelon' },
{ value: 'pineapple', label: 'Pineapple' },
{ value: 'mango', label: 'Mango' }
] as SelectItem<string>[],
onChange: fn()
},
render: function RenderBasicSelect(args) {
const [value, setValue] = React.useState<string | undefined>();
return (
<Select
{...args}
value={value}
onChange={(newValue) => {
setValue(newValue as string);
args.onChange(newValue);
}}
/>
);
}
};
// Advanced select with grouped items, icons, secondary labels, and custom search
export const AdvancedSelect: Story = {
args: {
placeholder: 'Select an action',
items: [
{
label: 'Account',
items: [
{
value: 'profile',
label: 'View Profile',
icon: <User />,
secondaryLabel: 'See your profile details'
},
{
value: 'settings',
label: 'Settings',
icon: <Gear />,
secondaryLabel: 'Manage your preferences'
}
]
},
{
label: 'Session',
items: [
{
value: 'logout',
label: 'Log Out',
icon: <PowerOff />,
secondaryLabel: 'End your session',
disabled: false
},
{
value: 'switch-account',
label: 'Switch Account',
secondaryLabel: 'Change to another account',
disabled: true
}
]
}
],
search: (item, searchTerm) => {
// Custom search that also searches in secondary labels
const term = searchTerm.toLowerCase();
const labelText = typeof item.label === 'string' ? item.label.toLowerCase() : '';
const secondaryText = item.secondaryLabel?.toLowerCase() || '';
return labelText.includes(term) || secondaryText.includes(term);
},
onChange: fn()
},
render: function RenderAdvancedSelect(args) {
const [value, setValue] = React.useState<string | undefined>();
return (
<Select
{...args}
value={value}
onChange={(newValue) => {
setValue(newValue as string);
args.onChange(newValue);
}}
/>
);
}
};
// Select with search disabled
export const NoSearchSelect: Story = {
args: {
placeholder: 'Select a color',
search: false,
items: [
{ value: 'red', label: 'Red' },
{ value: 'green', label: 'Green' },
{ value: 'blue', label: 'Blue' },
{ value: 'yellow', label: 'Yellow' },
{ value: 'purple', label: 'Purple' },
{ value: 'orange', label: 'Orange' }
] as SelectItem<string>[],
onChange: fn()
},
render: function RenderNoSearchSelect(args) {
const [value, setValue] = React.useState<string | undefined>();
return (
<Select
{...args}
value={value}
onChange={(newValue) => {
setValue(newValue as string);
args.onChange(newValue);
}}
/>
);
}
};
// Select with pre-selected value
export const PreSelectedValue: Story = {
args: {
placeholder: 'Select a size',
items: [
{ value: 'xs', label: 'Extra Small' },
{ value: 's', label: 'Small' },
{ value: 'm', label: 'Medium' },
{ value: 'l', label: 'Large' },
{ value: 'xl', label: 'Extra Large' }
] as SelectItem<string>[],
onChange: fn()
},
render: function RenderPreSelectedValue(args) {
const [value, setValue] = React.useState<string | undefined>('m');
return (
<Select
{...args}
value={value}
onChange={(newValue) => {
setValue(newValue as string);
args.onChange(newValue);
}}
/>
);
}
};
// Select with clearable option
export const ClearableSelect: Story = {
args: {
placeholder: 'Select an option (clearable)',
clearable: true,
items: [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' },
{ value: 'option4', label: 'Option 4' },
{ value: 'option5', label: 'Option 5' }
] as SelectItem<string>[],
onChange: fn()
},
render: function RenderClearableSelect(args) {
const [value, setValue] = React.useState<string | undefined>('option2');
return (
<Select
{...args}
value={value}
onChange={(newValue) => {
setValue(newValue as string | undefined);
args.onChange(newValue);
}}
/>
);
}
};

View File

@ -0,0 +1,379 @@
import React from 'react';
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
CommandInput
} from '@/components/ui/command';
import { PopoverRoot, PopoverContent, PopoverTrigger } from '@/components/ui/popover/PopoverBase';
import { cn } from '@/lib/classMerge';
import { Check, ChevronDown, Xmark } from '@/components/ui/icons';
interface SelectItemGroup<T = string> {
label: string;
items: SelectItem<T>[];
}
export interface SelectItem<T = string> {
value: T;
label: string | React.ReactNode; //this will be used in the select item text
secondaryLabel?: string;
icon?: React.ReactNode;
searchLabel?: string; // Used for filtering
disabled?: boolean;
}
type SearchFunction<T> = (item: SelectItem<T>, searchTerm: string) => boolean;
export interface SelectProps<T> {
items: SelectItem<T>[] | SelectItemGroup<T>[];
disabled?: boolean;
onChange: (value: T) => void;
placeholder?: string;
value?: string | undefined;
onOpenChange?: (open: boolean) => void;
open?: boolean;
showIndex?: boolean;
className?: string;
defaultValue?: string;
dataTestId?: string;
loading?: boolean;
search?: boolean | SearchFunction<T>;
clearable?: boolean;
emptyMessage?: string;
}
function isGroupedItems<T>(
items: SelectItem<T>[] | SelectItemGroup<T>[]
): items is SelectItemGroup<T>[] {
return items.length > 0 && 'items' in items[0];
}
function defaultSearchFunction<T>(item: SelectItem<T>, searchTerm: string): boolean {
const term = searchTerm.toLowerCase();
const labelText = typeof item.label === 'string' ? item.label : '';
const searchText = item.searchLabel || labelText;
const valueText = String(item.value);
return searchText.toLowerCase().includes(term) || valueText.toLowerCase().includes(term);
}
// Memoized SelectItem component to avoid re-renders
const SelectItemComponent = React.memo(
<T,>({
item,
index,
value,
showIndex,
onSelect
}: {
item: SelectItem<T>;
index: number;
value: string | undefined;
showIndex: boolean;
onSelect: (value: string) => void;
}) => {
const isSelected = String(item.value) === String(value);
return (
<CommandItem
value={String(item.value)}
onSelect={onSelect}
disabled={item.disabled}
className={cn(
'flex h-7 items-center gap-2 px-2',
item.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
isSelected && 'bg-item-select'
)}>
{item.icon}
<span className="flex-1">
{showIndex && `${index + 1}. `}
{item.label}
{item.secondaryLabel && (
<span className="text-text-secondary ml-2 text-sm">{item.secondaryLabel}</span>
)}
</span>
{isSelected && (
<div className="text-icon-color flex h-4 w-4 items-center">
<Check />
</div>
)}
</CommandItem>
);
}
);
SelectItemComponent.displayName = 'SelectItemComponent';
export function Select<T = string>({
items,
disabled = false,
onChange,
placeholder = 'Select an option',
emptyMessage = 'No options found.',
value,
onOpenChange,
open: controlledOpen,
showIndex = false,
className,
defaultValue,
dataTestId,
loading = false,
search = true,
clearable = false
}: SelectProps<T>) {
const [internalOpen, setInternalOpen] = React.useState(false);
const [searchValue, setSearchValue] = React.useState('');
const [isFocused, setIsFocused] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const commandRef = React.useRef<HTMLDivElement>(null);
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
const handleOpenChange = React.useCallback(
(newOpen: boolean) => {
if (!disabled) {
setInternalOpen(newOpen);
onOpenChange?.(newOpen);
if (!newOpen) {
setSearchValue('');
setIsFocused(false);
}
}
},
[disabled, onOpenChange]
);
// Get all items in a flat array for easier processing
const flatItems = React.useMemo(() => {
if (isGroupedItems(items)) {
return items.flatMap((group) => group.items);
}
return items;
}, [items]);
// Find the selected item
const selectedItem = React.useMemo(
() => flatItems.find((item) => String(item.value) === String(value)),
[flatItems, value]
);
// Filter items based on search
const filterItem = React.useCallback(
(item: SelectItem<T>): boolean => {
if (!search || !searchValue) return true;
if (typeof search === 'function') {
return search(item, searchValue);
}
return defaultSearchFunction(item, searchValue);
},
[search, searchValue]
);
const handleSelect = React.useCallback(
(itemValue: string) => {
const item = flatItems.find((i) => String(i.value) === itemValue);
if (item) {
onChange(item.value);
handleOpenChange(false);
setSearchValue('');
inputRef.current?.blur();
}
},
[flatItems, onChange, handleOpenChange]
);
const handleClear = React.useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
onChange(undefined as any);
setSearchValue('');
handleOpenChange(false);
},
[onChange, handleOpenChange]
);
const handleInputChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setSearchValue(newValue);
if (search !== false && newValue && !open) {
handleOpenChange(true);
}
},
[search, open, handleOpenChange]
);
const handleInputFocus = React.useCallback(() => {
setIsFocused(true);
if (!open) {
handleOpenChange(true);
}
}, [open, handleOpenChange]);
const handleInputKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End'].includes(e.key)) {
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
handleOpenChange(true);
return;
}
// Forward the event to the command component
if (open && commandRef.current) {
const commandInput = commandRef.current.querySelector('[cmdk-input]');
if (commandInput) {
const newEvent = new KeyboardEvent('keydown', {
key: e.key,
code: e.code,
keyCode: e.keyCode,
which: e.which,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
altKey: e.altKey,
metaKey: e.metaKey,
bubbles: true,
cancelable: true
});
commandInput.dispatchEvent(newEvent);
e.preventDefault();
}
}
} else if (e.key === 'Escape') {
handleOpenChange(false);
inputRef.current?.blur();
}
},
[open, handleOpenChange]
);
// Render items with memoization to prevent unnecessary re-renders
const renderedItems = React.useMemo(() => {
if (isGroupedItems(items)) {
return items.map((group, groupIndex) => {
const filteredItems = group.items.filter(filterItem);
if (filteredItems.length === 0 && searchValue) return null;
return (
<CommandGroup key={`${group.label}-${groupIndex}`} heading={group.label}>
{filteredItems.map((item, index) => (
<SelectItemComponent
key={String(item.value)}
item={item}
index={index}
value={value}
showIndex={showIndex}
onSelect={handleSelect}
/>
))}
</CommandGroup>
);
});
}
const filteredItems = flatItems.filter(filterItem);
return filteredItems.map((item, index) => (
<SelectItemComponent
key={String(item.value)}
item={item}
index={index}
value={value}
showIndex={showIndex}
onSelect={handleSelect}
/>
));
}, [items, flatItems, filterItem, searchValue, value, showIndex, handleSelect]);
// Display value in input when not focused/searching
const inputDisplayValue = React.useMemo(() => {
if (isFocused || searchValue) {
return searchValue;
}
if (selectedItem) {
return typeof selectedItem.label === 'string' ? selectedItem.label : '';
}
return '';
}, [isFocused, searchValue, selectedItem]);
// Compute placeholder once
const computedPlaceholder = React.useMemo(() => {
return typeof selectedItem?.label === 'string' ? selectedItem.label : placeholder;
}, [selectedItem, placeholder]);
return (
<PopoverRoot open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<div className={cn('relative w-full', className)}>
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded={open}
aria-label={placeholder}
disabled={disabled || loading}
value={inputDisplayValue}
onChange={handleInputChange}
onFocus={handleInputFocus}
onKeyDown={handleInputKeyDown}
placeholder={computedPlaceholder}
data-testid={dataTestId}
readOnly={search === false}
className={cn(
'flex h-7 w-full items-center justify-between rounded border px-2.5 text-base',
'bg-background cursor-pointer transition-all duration-300',
'focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
disabled ? 'bg-disabled text-gray-light' : '',
!selectedItem && !searchValue && 'text-text-secondary'
)}
/>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
{clearable && selectedItem && !isFocused && (
<button
type="button"
onClick={handleClear}
className="hover:text-foreground text-icon-color pointer-events-auto mr-1 flex h-4 w-4 cursor-pointer items-center justify-center rounded"
aria-label="Clear selection">
<Xmark />
</button>
)}
{!open && (
<div className="flex h-4 w-4 shrink-0 items-center opacity-50">
<ChevronDown />
</div>
)}
</div>
</div>
</PopoverTrigger>
<PopoverContent
className="min-w-[var(--radix-popover-trigger-width)] p-0"
align="start"
onOpenAutoFocus={(e) => {
e.preventDefault();
inputRef.current?.focus();
}}>
<Command ref={commandRef} shouldFilter={false}>
{/* Hidden input that Command uses for keyboard navigation */}
<CommandInput
value={searchValue}
onValueChange={setSearchValue}
parentClassName="swag sr-only hidden h-0 border-0 p-0"
aria-hidden="true"
/>
<div className="scrollbar-hide max-h-[300px] overflow-y-auto">
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
{renderedItems}
</CommandList>
</div>
</Command>
</PopoverContent>
</PopoverRoot>
);
}