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:
Thomas Camlong
2025-10-02 10:52:18 +02:00
parent e1ae75d27f
commit 4e58f705d3

View 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>
);
};