Add tags and categories

This commit is contained in:
2025-12-20 17:37:52 +01:00
parent dfb6f7042a
commit e90578c98a
23 changed files with 913 additions and 45 deletions

View File

@ -77,6 +77,12 @@ interface MultipleSelectorProps {
>;
/** hide the clear all button. */
hideClearAllButton?: boolean;
/** Show selected items inside dropdown as disabled items (useful for "Selected" section). */
showSelectedInDropdown?: boolean;
/** Optional explicit group ordering (top to bottom). */
groupOrder?: string[];
}
export interface MultipleSelectorRef {
@ -192,6 +198,8 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
commandProps,
inputProps,
hideClearAllButton = false,
showSelectedInDropdown = false,
groupOrder,
}: MultipleSelectorProps,
ref: React.Ref<MultipleSelectorRef>,
) => {
@ -404,10 +412,13 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
}, [creatable, emptyIndicator, onSearch, options]);
const selectables = React.useMemo<GroupOption>(
() => removePickedOption(options, selected),
[options, selected],
);
const selectables = React.useMemo<GroupOption>(() => {
if (showSelectedInDropdown) {
// keep all options; selected will be rendered disabled (see below)
return options;
}
return removePickedOption(options, selected);
}, [options, selected, showSelectedInDropdown]);
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
const commandFilter = React.useCallback(() => {
@ -424,6 +435,30 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
return undefined;
}, [creatable, commandProps?.filter]);
const orderedGroupEntries = React.useMemo(() => {
const entries = Object.entries(selectables);
if (!groupOrder || groupOrder.length === 0) {
// default: existing behavior
return entries;
}
const map = new Map(entries);
const ordered: Array<[string, Option[]]> = [];
for (const key of groupOrder) {
const v = map.get(key);
if (v) {
ordered.push([key, v]);
map.delete(key);
}
}
// any remaining groups not specified in groupOrder (alphabetical)
const rest = Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
return [...ordered, ...rest];
}, [selectables, groupOrder]);
return (
<Command
ref={dropdownRef}
@ -458,8 +493,8 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
<Badge
key={option.value}
className={cn(
'data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground',
'data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground',
'data-disabled:bg-muted-foreground data-disabled:text-muted data-disabled:hover:bg-muted-foreground',
'data-fixed:bg-muted-foreground data-fixed:text-muted data-fixed:hover:bg-muted-foreground',
badgeClassName,
)}
data-fixed={option.fixed}
@ -559,32 +594,42 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
{EmptyItem()}
{CreatableItem()}
{!selectFirstItem && <CommandItem value="-" className="hidden" />}
{Object.entries(selectables).map(([key, dropdowns]) => (
{orderedGroupEntries.map(([key, dropdowns]) => (
<CommandGroup key={key} heading={key} className="h-full overflow-auto">
<>
{dropdowns.map((option) => {
const alreadySelected = selected.some((s) => s.value === option.value);
const disabledItem = option.disable || (showSelectedInDropdown && alreadySelected);
return (
<CommandItem
key={option.value}
value={option.label}
disabled={option.disable}
disabled={disabledItem}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={() => {
if (disabledItem) return;
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
return;
}
setInputValue('');
// Guard against duplicates (safety)
if (selected.some((s) => s.value === option.value)) return;
const newOptions = [...selected, option];
setSelected(newOptions);
onChange?.(newOptions);
}}
className={cn(
'cursor-pointer',
option.disable && 'cursor-default text-muted-foreground',
disabledItem && 'cursor-default text-muted-foreground',
)}
>
{option.label}