mirror of
https://github.com/walkxcode/dashboard-icons.git
synced 2025-11-18 17:47:30 +01:00
feat: add advanced combobox component
- Create comprehensive combobox component with context management - Support controllable state with @radix-ui/react-use-controllable-state - Add search functionality with Command component integration - Implement create new item functionality - Include responsive width detection with ResizeObserver - Support grouped items, empty states, and custom triggers - Provide flexible data structure for various use cases
This commit is contained in:
311
web/src/components/ui/shadcn-io/combobox/index.tsx
Normal file
311
web/src/components/ui/shadcn-io/combobox/index.tsx
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useControllableState } from '@radix-ui/react-use-controllable-state';
|
||||||
|
import { ChevronsUpDownIcon, PlusIcon } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
type ComponentProps,
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from '@/components/ui/command';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type ComboboxData = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ComboboxContextType = {
|
||||||
|
data: ComboboxData[];
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
width: number;
|
||||||
|
setWidth: (width: number) => void;
|
||||||
|
inputValue: string;
|
||||||
|
setInputValue: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ComboboxContext = createContext<ComboboxContextType>({
|
||||||
|
data: [],
|
||||||
|
type: 'item',
|
||||||
|
value: '',
|
||||||
|
onValueChange: () => {},
|
||||||
|
open: false,
|
||||||
|
onOpenChange: () => {},
|
||||||
|
width: 200,
|
||||||
|
setWidth: () => {},
|
||||||
|
inputValue: '',
|
||||||
|
setInputValue: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ComboboxProps = ComponentProps<typeof Popover> & {
|
||||||
|
data: ComboboxData[];
|
||||||
|
type: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Combobox = ({
|
||||||
|
data,
|
||||||
|
type,
|
||||||
|
defaultValue,
|
||||||
|
value: controlledValue,
|
||||||
|
onValueChange: controlledOnValueChange,
|
||||||
|
defaultOpen = false,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange: controlledOnOpenChange,
|
||||||
|
...props
|
||||||
|
}: ComboboxProps) => {
|
||||||
|
const [value, onValueChange] = useControllableState({
|
||||||
|
defaultProp: defaultValue ?? '',
|
||||||
|
prop: controlledValue,
|
||||||
|
onChange: controlledOnValueChange,
|
||||||
|
});
|
||||||
|
const [open, onOpenChange] = useControllableState({
|
||||||
|
defaultProp: defaultOpen,
|
||||||
|
prop: controlledOpen,
|
||||||
|
onChange: controlledOnOpenChange,
|
||||||
|
});
|
||||||
|
const [width, setWidth] = useState(200);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComboboxContext.Provider
|
||||||
|
value={{
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
data,
|
||||||
|
width,
|
||||||
|
setWidth,
|
||||||
|
inputValue,
|
||||||
|
setInputValue,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popover {...props} onOpenChange={onOpenChange} open={open} />
|
||||||
|
</ComboboxContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComboboxTriggerProps = ComponentProps<typeof Button>;
|
||||||
|
|
||||||
|
export const ComboboxTrigger = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ComboboxTriggerProps) => {
|
||||||
|
const { value, data, type, setWidth } = useContext(ComboboxContext);
|
||||||
|
const ref = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Create a ResizeObserver to detect width changes
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const newWidth = (entry.target as HTMLElement).offsetWidth;
|
||||||
|
if (newWidth) {
|
||||||
|
setWidth?.(newWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ref.current) {
|
||||||
|
resizeObserver.observe(ref.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the observer when component unmounts
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [setWidth]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" {...props} ref={ref}>
|
||||||
|
{children ?? (
|
||||||
|
<span className="flex w-full items-center justify-between gap-2">
|
||||||
|
{value
|
||||||
|
? data.find((item) => item.value === value)?.label
|
||||||
|
: `Select ${type}...`}
|
||||||
|
<ChevronsUpDownIcon
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComboboxContentProps = ComponentProps<typeof Command> & {
|
||||||
|
popoverOptions?: ComponentProps<typeof PopoverContent>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComboboxContent = ({
|
||||||
|
className,
|
||||||
|
popoverOptions,
|
||||||
|
...props
|
||||||
|
}: ComboboxContentProps) => {
|
||||||
|
const { width } = useContext(ComboboxContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverContent
|
||||||
|
className={cn('p-0', className)}
|
||||||
|
style={{ width }}
|
||||||
|
{...popoverOptions}
|
||||||
|
>
|
||||||
|
<Command {...props} />
|
||||||
|
</PopoverContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComboboxInputProps = ComponentProps<typeof CommandInput> & {
|
||||||
|
value?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComboboxInput = ({
|
||||||
|
value: controlledValue,
|
||||||
|
defaultValue,
|
||||||
|
onValueChange: controlledOnValueChange,
|
||||||
|
...props
|
||||||
|
}: ComboboxInputProps) => {
|
||||||
|
const { type, inputValue, setInputValue } = useContext(ComboboxContext);
|
||||||
|
|
||||||
|
const [value, onValueChange] = useControllableState({
|
||||||
|
defaultProp: defaultValue ?? inputValue,
|
||||||
|
prop: controlledValue,
|
||||||
|
onChange: (newValue) => {
|
||||||
|
// Sync with context state
|
||||||
|
setInputValue(newValue);
|
||||||
|
// Call external onChange if provided
|
||||||
|
controlledOnValueChange?.(newValue);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandInput
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
placeholder={`Search ${type}...`}
|
||||||
|
value={value}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComboboxListProps = ComponentProps<typeof CommandList>;
|
||||||
|
|
||||||
|
export const ComboboxList = (props: ComboboxListProps) => (
|
||||||
|
<CommandList {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ComboboxEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||||
|
|
||||||
|
export const ComboboxEmpty = ({ children, ...props }: ComboboxEmptyProps) => {
|
||||||
|
const { type } = useContext(ComboboxContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandEmpty {...props}>{children ?? `No ${type} found.`}</CommandEmpty>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComboboxGroupProps = ComponentProps<typeof CommandGroup>;
|
||||||
|
|
||||||
|
export const ComboboxGroup = (props: ComboboxGroupProps) => (
|
||||||
|
<CommandGroup {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ComboboxItemProps = ComponentProps<typeof CommandItem>;
|
||||||
|
|
||||||
|
export const ComboboxItem = (props: ComboboxItemProps) => {
|
||||||
|
const { onValueChange, onOpenChange } = useContext(ComboboxContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
onValueChange(currentValue);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComboboxSeparatorProps = ComponentProps<typeof CommandSeparator>;
|
||||||
|
|
||||||
|
export const ComboboxSeparator = (props: ComboboxSeparatorProps) => (
|
||||||
|
<CommandSeparator {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ComboboxCreateNewProps = {
|
||||||
|
onCreateNew: (value: string) => void;
|
||||||
|
children?: (inputValue: string) => ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComboboxCreateNew = ({
|
||||||
|
onCreateNew,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: ComboboxCreateNewProps) => {
|
||||||
|
const { inputValue, type, onValueChange, onOpenChange } =
|
||||||
|
useContext(ComboboxContext);
|
||||||
|
|
||||||
|
if (!inputValue.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateNew = () => {
|
||||||
|
onCreateNew(inputValue.trim());
|
||||||
|
onValueChange(inputValue.trim());
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={handleCreateNew}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{children ? (
|
||||||
|
children(inputValue)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PlusIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>
|
||||||
|
Create new {type}: "{inputValue}"
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user