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

321 lines
12 KiB
TypeScript

import { store } from '@/actions/App/Http/Controllers/Settings/LabelController';
import { Badge } from '@/components/ui/badge';
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 { getLabelColorClasses, LABEL_COLORS, type Label } from '@/types/label';
import { Check, ChevronsUpDown, Plus, Tag, X } from 'lucide-react';
import { useState } from 'react';
interface LabelComboboxProps {
value: string[];
onValueChange: (value: string[]) => void;
labels: Label[];
disabled?: boolean;
placeholder?: string;
triggerClassName?: string;
allowCreate?: boolean;
allowRemoveAll?: boolean;
onLabelCreated?: (label: Label) => void;
}
export function LabelCombobox({
value,
onValueChange,
labels = [],
disabled = false,
placeholder = 'Add labels...',
triggerClassName,
allowCreate = true,
allowRemoveAll = false,
onLabelCreated,
}: LabelComboboxProps) {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [isCreating, setIsCreating] = useState(false);
const selectedLabels = labels.filter((l) => value.includes(l.id));
const sortedLabels = [...labels].sort((a, b) =>
a.name.localeCompare(b.name),
);
const handleSelect = (labelId: string) => {
if (value.includes(labelId)) {
onValueChange(value.filter((id) => id !== labelId));
} else {
onValueChange([...value, labelId]);
}
};
const handleRemove = (labelId: string, e: React.MouseEvent) => {
e.stopPropagation();
onValueChange(value.filter((id) => id !== labelId));
};
const handleCreate = async () => {
if (!inputValue.trim() || isCreating) return;
const existingLabel = labels.find(
(l) => l.name.toLowerCase() === inputValue.toLowerCase(),
);
if (existingLabel) {
handleSelect(existingLabel.id);
setInputValue('');
return;
}
setIsCreating(true);
try {
const randomColor =
LABEL_COLORS[Math.floor(Math.random() * LABEL_COLORS.length)];
const response = await fetch(store.url(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
),
Accept: 'application/json',
},
body: JSON.stringify({
name: inputValue.trim(),
color: randomColor,
}),
});
if (!response.ok) {
throw new Error('Failed to create label');
}
const data = await response.json();
const newLabel = data.data || data;
if (newLabel) {
onValueChange([...value, newLabel.id]);
setInputValue('');
onLabelCreated?.(newLabel);
}
} catch (error) {
console.error('Failed to create label:', error);
} finally {
setIsCreating(false);
}
};
const showCreateOption =
allowCreate &&
inputValue.trim() &&
!labels.some((l) => l.name.toLowerCase() === inputValue.toLowerCase());
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
'min-h-10 w-full justify-between',
triggerClassName,
)}
disabled={disabled}
onClick={(e) => e.stopPropagation()}
>
{selectedLabels.length > 0 ? (
<div className="flex flex-wrap gap-1">
{selectedLabels.map((label) => {
const colorClasses = getLabelColorClasses(
label.color,
);
return (
<Badge
key={label.id}
className={cn(
'gap-1 px-2 py-0.5',
colorClasses.bg,
colorClasses.text,
)}
>
<Tag className="h-3 w-3" />
{label.name}
<span
role="button"
tabIndex={0}
onClick={(e) =>
handleRemove(label.id, e)
}
onKeyDown={(e) => {
if (
e.key === 'Enter' ||
e.key === ' '
) {
handleRemove(
label.id,
e as unknown as React.MouseEvent,
);
}
}}
className="ml-0.5 cursor-pointer rounded-full hover:bg-black/10"
>
<X className="h-3 w-3" />
</span>
</Badge>
);
})}
</div>
) : (
<span className="text-muted-foreground">
{placeholder}
</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search or create labels..."
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList>
{sortedLabels.length === 0 && !showCreateOption && (
<CommandEmpty>No labels found.</CommandEmpty>
)}
{allowRemoveAll && (
<CommandItem
onSelect={() => {
onValueChange([]);
setOpen(false);
}}
className="gap-2"
>
<X className="h-4 w-4" />
Remove all labels
</CommandItem>
)}
{showCreateOption && (
<CommandItem
onSelect={handleCreate}
disabled={isCreating}
className="gap-2"
>
<Plus className="h-4 w-4" />
{isCreating
? 'Creating...'
: `Create "${inputValue.trim()}"`}
</CommandItem>
)}
{sortedLabels
.filter(
(label) =>
!inputValue ||
label.name
.toLowerCase()
.includes(inputValue.toLowerCase()),
)
.map((label) => {
const colorClasses = getLabelColorClasses(
label.color,
);
const isSelected = value.includes(label.id);
return (
<CommandItem
key={label.id}
value={label.name}
onSelect={() => handleSelect(label.id)}
>
<div className="flex items-center gap-2">
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full',
colorClasses.bg,
)}
>
<Tag
className={cn(
'h-3 w-3',
colorClasses.text,
)}
/>
</div>
<span className="truncate">
{label.name}
</span>
</div>
<Check
className={cn(
'ml-auto h-4 w-4',
isSelected
? 'opacity-100'
: 'opacity-0',
)}
/>
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function LabelBadge({ label }: { label: Label }) {
const colorClasses = getLabelColorClasses(label.color);
return (
<Badge
className={cn(
'gap-1 px-2 py-0.5',
colorClasses.bg,
colorClasses.text,
)}
>
<Tag className="h-3 w-3" />
{label.name}
</Badge>
);
}
export function LabelBadges({
labels,
max = 3,
}: {
labels: Label[];
max?: number;
}) {
if (!labels || labels.length === 0) return null;
const displayLabels = labels.slice(0, max);
const remainingCount = labels.length - max;
return (
<div className="flex flex-wrap gap-1">
{displayLabels.map((label) => (
<LabelBadge key={label.id} label={label} />
))}
{remainingCount > 0 && (
<Badge variant="outline" className="px-2 py-0.5">
+{remainingCount}
</Badge>
)}
</div>
);
}