whisper-money/resources/js/components/shared/category-combobox.tsx

234 lines
8.0 KiB
TypeScript

import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { __ } from '@/utils/i18n';
import * as Icons from 'lucide-react';
import {
Check,
ChevronsUpDown,
HelpCircle,
type LucideIcon,
} from 'lucide-react';
import { memo, useEffect, useRef, useState } from 'react';
const iconCache = new Map<string, LucideIcon>();
function getIconComponent(iconName?: string): LucideIcon | null {
if (!iconName) return null;
if (iconCache.has(iconName)) {
return iconCache.get(iconName)!;
}
const icon = Icons[iconName as keyof typeof Icons] as
| LucideIcon
| undefined;
if (icon) {
iconCache.set(iconName, icon);
}
return icon ?? null;
}
interface CategoryComboboxProps {
value: string | null;
onValueChange: (value: string) => void;
categories: Category[];
disabled?: boolean;
placeholder?: string;
triggerClassName?: string;
showUncategorized?: boolean;
withoutChevronIcon?: boolean;
'data-testid'?: string;
}
export function CategoryCombobox({
value,
onValueChange,
categories,
disabled = false,
placeholder = 'Select category',
triggerClassName,
showUncategorized = true,
withoutChevronIcon = false,
'data-testid': dataTestId,
}: CategoryComboboxProps) {
const [open, setOpen] = useState(false);
const [filterValue, setFilterValue] = useState('');
const listRef = useRef<HTMLDivElement>(null);
const selectedCategory =
value && value !== 'null'
? categories.find((c) => c.id === value)
: null;
const sortedCategories = [...categories].sort((a, b) =>
a.name.localeCompare(b.name),
);
useEffect(() => {
if (filterValue && listRef.current) {
listRef.current.scrollTop = 0;
}
}, [filterValue]);
useEffect(() => {
if (open && listRef.current) {
listRef.current.scrollTop = 0;
}
if (!open) {
setFilterValue('');
}
}, [open]);
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
'w-full max-w-full justify-between !pr-2 !pl-1',
triggerClassName,
)}
disabled={disabled}
onClick={(e) => e.stopPropagation()}
data-testid={dataTestId}
>
{selectedCategory ? (
<div className="flex items-center gap-2 overflow-x-hidden">
<CategoryIcon category={selectedCategory} />
<span className="truncate">
{selectedCategory.name}
</span>
</div>
) : value === 'null' ? (
<div className="flex items-center gap-2 truncate">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
<HelpCircle className="h-3 w-3 text-zinc-500" />
</div>
<span className="truncate text-zinc-500">
{__('Uncategorized')}
</span>
</div>
) : (
<span className="truncate text-muted-foreground">
{placeholder}
</span>
)}
{withoutChevronIcon === false && (
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
)}
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder={__('Search categories...')}
value={filterValue}
onValueChange={setFilterValue}
/>
<CommandList ref={listRef}>
<CommandEmpty>{__('No category found.')}</CommandEmpty>
{showUncategorized && (
<CommandItem
value="uncategorized"
onSelect={() => {
onValueChange('null');
setOpen(false);
}}
>
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
<HelpCircle className="h-3 w-3 text-zinc-500" />
</div>
<span>{__('Uncategorized')}</span>
</div>
<Check
className={cn(
'ml-auto h-4 w-4',
value === 'null'
? 'opacity-100'
: 'opacity-0',
)}
/>
</CommandItem>
)}
{sortedCategories.map((category) => (
<CommandItem
key={category.id}
value={category.name}
onSelect={() => {
onValueChange(String(category.id));
setOpen(false);
}}
>
<div className="flex items-center gap-2">
<CategoryIcon category={category} />
<span className="truncate">
{category.name}
</span>
</div>
<Check
className={cn(
'ml-auto h-4 w-4',
value === String(category.id)
? 'opacity-100'
: 'opacity-0',
)}
/>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export const CategoryIcon = memo(function CategoryIcon({
category,
}: {
category: Category;
}) {
const colorClasses = getCategoryColorClasses(category.color);
const iconName = category.icon;
return (
<div
className={cn(
'flex aspect-square items-center justify-center rounded-full p-1',
colorClasses.bg,
)}
>
<DynamicIcon
name={iconName}
className={cn(`size-4 sm:size-3.5`, colorClasses.text)}
/>
</div>
);
});
const DynamicIcon = memo(function DynamicIcon({
name,
className,
}: {
name?: string;
className?: string;
}) {
const Icon = getIconComponent(name);
if (!Icon) return null;
return <Icon className={className} />;
});