mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-25 13:26:27 +02:00
576 lines
19 KiB
TypeScript
576 lines
19 KiB
TypeScript
/** biome-ignore-all lint/a11y/noStaticElementInteractions: temporary suppress until in active use */
|
|
/** biome-ignore-all lint/a11y/useKeyWithClickEvents: temporary suppress until in active use */
|
|
"use client";
|
|
|
|
import { Command as CommandPrimitive, useCommandState } from "cmdk";
|
|
import * as React from "react";
|
|
import { forwardRef, useEffect } from "react";
|
|
import { LuX } from "react-icons/lu";
|
|
import { cn } from "../lib/utils";
|
|
import { Badge } from "./ui/badge";
|
|
import { Command, CommandGroup, CommandItem, CommandList } from "./ui/command";
|
|
|
|
export interface Option {
|
|
value: string;
|
|
label?: string;
|
|
disable?: boolean;
|
|
/** fixed option that can't be removed. */
|
|
fixed?: boolean;
|
|
/** Group the options by providing key. */
|
|
[key: string]: string | boolean | undefined;
|
|
}
|
|
interface GroupOption {
|
|
[key: string]: Option[];
|
|
}
|
|
|
|
interface MultipleSelectorProps {
|
|
value?: Option[];
|
|
defaultOptions?: Option[];
|
|
/** manually controlled options */
|
|
options?: Option[];
|
|
placeholder?: string;
|
|
/** Loading component. */
|
|
loadingIndicator?: React.ReactNode;
|
|
/** Empty component. */
|
|
emptyIndicator?: React.ReactNode;
|
|
/** Debounce time for async search. Only work with `onSearch`. */
|
|
delay?: number;
|
|
/**
|
|
* Only work with `onSearch` prop. Trigger search when `onFocus`.
|
|
* For example, when user click on the input, it will trigger the search to get initial options.
|
|
**/
|
|
triggerSearchOnFocus?: boolean;
|
|
/** async search */
|
|
onSearch?: (value: string) => Promise<Option[]>;
|
|
onChange?: (options: Option[]) => void;
|
|
/** Limit the maximum number of selected options. */
|
|
maxSelected?: number;
|
|
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
|
|
onMaxSelected?: (maxLimit: number) => void;
|
|
/** Hide the placeholder when there are options selected. */
|
|
hidePlaceholderWhenSelected?: boolean;
|
|
disabled?: boolean;
|
|
/** Group the options base on provided key. */
|
|
groupBy?: string;
|
|
className?: string;
|
|
badgeClassName?: string;
|
|
/**
|
|
* First item selected is a default behavior by cmdk. That is why the default is true.
|
|
* This is a workaround solution by add a dummy item.
|
|
*
|
|
* @reference: https://github.com/pacocoursey/cmdk/issues/171
|
|
*/
|
|
selectFirstItem?: boolean;
|
|
/** Allow user to create option when there is no option matched. */
|
|
creatable?: boolean;
|
|
/** Props of `Command` */
|
|
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
|
|
/** Props of `CommandInput` */
|
|
inputProps?: Omit<
|
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
|
|
"value" | "placeholder" | "disabled"
|
|
>;
|
|
}
|
|
|
|
export interface MultipleSelectorRef {
|
|
selectedValue: Option[];
|
|
input: HTMLInputElement;
|
|
}
|
|
|
|
// eslint-disable-next-line react-refresh/only-export-components
|
|
export function useDebounce<T>(value: T, delay?: number): T {
|
|
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
|
|
|
|
return () => {
|
|
clearTimeout(timer);
|
|
};
|
|
}, [value, delay]);
|
|
|
|
return debouncedValue;
|
|
}
|
|
|
|
function transToGroupOption(options: Option[], groupBy?: string) {
|
|
if (options.length === 0) {
|
|
return {};
|
|
}
|
|
if (!groupBy) {
|
|
return {
|
|
"": options,
|
|
};
|
|
}
|
|
|
|
const groupOption: GroupOption = {};
|
|
options.forEach((option) => {
|
|
const key = (option[groupBy] as string) || "";
|
|
if (!groupOption[key]) {
|
|
groupOption[key] = [option];
|
|
} else {
|
|
groupOption[key]?.push(option);
|
|
}
|
|
});
|
|
return groupOption;
|
|
}
|
|
|
|
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
|
|
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
|
|
|
|
for (const [key, value] of Object.entries(cloneOption)) {
|
|
cloneOption[key] = value.filter(
|
|
(val) => !picked.find((p) => p.value === val.value),
|
|
);
|
|
}
|
|
return cloneOption;
|
|
}
|
|
|
|
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
|
|
for (const [, value] of Object.entries(groupOption)) {
|
|
if (
|
|
value.some((option) => targetOption.find((p) => p.value === option.value))
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
|
|
* So we create one and copy the `Empty` implementation from `cmdk`.
|
|
*
|
|
* @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607
|
|
**/
|
|
const CommandEmpty = forwardRef<
|
|
HTMLDivElement,
|
|
React.ComponentProps<typeof CommandPrimitive.Empty>
|
|
>(({ className, ...props }, forwardedRef) => {
|
|
const render = useCommandState((state) => state.filtered.count === 0);
|
|
|
|
if (!render) return null;
|
|
|
|
return (
|
|
<div
|
|
ref={forwardedRef}
|
|
className={cn("py-6 text-sm text-center", className)}
|
|
cmdk-empty=""
|
|
role="presentation"
|
|
{...props}
|
|
/>
|
|
);
|
|
});
|
|
|
|
CommandEmpty.displayName = "CommandEmpty";
|
|
|
|
const MultipleSelector = React.forwardRef<
|
|
MultipleSelectorRef,
|
|
MultipleSelectorProps
|
|
>(
|
|
(
|
|
{
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
defaultOptions: arrayDefaultOptions = [],
|
|
options: arrayOptions,
|
|
delay,
|
|
onSearch,
|
|
loadingIndicator,
|
|
emptyIndicator,
|
|
maxSelected = Number.MAX_SAFE_INTEGER,
|
|
onMaxSelected,
|
|
hidePlaceholderWhenSelected,
|
|
disabled,
|
|
groupBy,
|
|
className,
|
|
badgeClassName,
|
|
selectFirstItem = true,
|
|
creatable = false,
|
|
triggerSearchOnFocus = false,
|
|
commandProps,
|
|
inputProps,
|
|
}: MultipleSelectorProps,
|
|
ref: React.Ref<MultipleSelectorRef>,
|
|
) => {
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
const [open, setOpen] = React.useState(false);
|
|
const [isLoading, setIsLoading] = React.useState(false);
|
|
|
|
const [selected, setSelected] = React.useState<Option[]>(value || []);
|
|
const [options, setOptions] = React.useState<GroupOption>(
|
|
transToGroupOption(arrayDefaultOptions, groupBy),
|
|
);
|
|
const [inputValue, setInputValue] = React.useState("");
|
|
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
|
|
|
|
React.useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
selectedValue: [...selected],
|
|
input: inputRef.current as HTMLInputElement,
|
|
focus: () => inputRef.current?.focus(),
|
|
}),
|
|
[selected],
|
|
);
|
|
|
|
const handleUnselect = React.useCallback(
|
|
(option: Option) => {
|
|
const newOptions = selected.filter((s) => s.value !== option.value);
|
|
setSelected(newOptions);
|
|
onChange?.(newOptions);
|
|
},
|
|
[onChange, selected],
|
|
);
|
|
|
|
const handleKeyDown = React.useCallback(
|
|
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
const input = inputRef.current;
|
|
if (input) {
|
|
if (e.key === "Delete" || e.key === "Backspace") {
|
|
if (input.value === "" && selected.length > 0) {
|
|
const lastSelectOption = selected[selected.length - 1];
|
|
// If last item is fixed, we should not remove it.
|
|
if (!lastSelectOption?.fixed) {
|
|
// biome-ignore lint/style/noNonNullAssertion: false positive
|
|
handleUnselect(selected.at(-1)!);
|
|
}
|
|
}
|
|
}
|
|
// This is not a default behavior of the <input /> field
|
|
if (e.key === "Escape") {
|
|
input.blur();
|
|
}
|
|
}
|
|
},
|
|
[handleUnselect, selected],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (value) {
|
|
setSelected(value);
|
|
}
|
|
}, [value]);
|
|
|
|
useEffect(() => {
|
|
/** If `onSearch` is provided, do not trigger options updated. */
|
|
if (!arrayOptions || onSearch) {
|
|
return;
|
|
}
|
|
const newOption = transToGroupOption(arrayOptions || [], groupBy);
|
|
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
|
|
setOptions(newOption);
|
|
}
|
|
}, [arrayOptions, groupBy, onSearch, options]);
|
|
|
|
useEffect(() => {
|
|
const doSearch = async () => {
|
|
setIsLoading(true);
|
|
const res = await onSearch?.(debouncedSearchTerm);
|
|
setOptions(transToGroupOption(res || [], groupBy));
|
|
setIsLoading(false);
|
|
};
|
|
|
|
const exec = async () => {
|
|
if (!onSearch || !open) return;
|
|
|
|
if (triggerSearchOnFocus) {
|
|
await doSearch();
|
|
}
|
|
|
|
if (debouncedSearchTerm) {
|
|
await doSearch();
|
|
}
|
|
};
|
|
|
|
void exec();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus, onSearch]);
|
|
|
|
const CreatableItem = () => {
|
|
if (!creatable) return undefined;
|
|
if (
|
|
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
|
|
selected.find((s) => s.value === inputValue)
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const Item = (
|
|
<CommandItem
|
|
value={inputValue}
|
|
className="cursor-pointer"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
onSelect={(value: string) => {
|
|
if (selected.length >= maxSelected) {
|
|
onMaxSelected?.(selected.length);
|
|
return;
|
|
}
|
|
setInputValue("");
|
|
const newOptions = [...selected, { value, label: value }];
|
|
setSelected(newOptions);
|
|
onChange?.(newOptions);
|
|
}}
|
|
>
|
|
{`Create "${inputValue}"`}
|
|
</CommandItem>
|
|
);
|
|
|
|
// For normal creatable
|
|
if (!onSearch && inputValue.length > 0) {
|
|
return Item;
|
|
}
|
|
|
|
// For async search creatable. avoid showing creatable item before loading at first.
|
|
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
|
|
return Item;
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
const EmptyItem = React.useCallback(() => {
|
|
if (!emptyIndicator) return undefined;
|
|
|
|
// For async search that showing emptyIndicator
|
|
if (onSearch && !creatable && Object.keys(options).length === 0) {
|
|
return (
|
|
<CommandItem value="-" disabled>
|
|
{emptyIndicator}
|
|
</CommandItem>
|
|
);
|
|
}
|
|
|
|
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
|
|
}, [creatable, emptyIndicator, onSearch, options]);
|
|
|
|
const selectables = React.useMemo<GroupOption>(
|
|
() => removePickedOption(options, selected),
|
|
[options, selected],
|
|
);
|
|
|
|
const hasAvailableOptions = React.useMemo(() => {
|
|
return Object.values(selectables).some((group) => group.length > 0);
|
|
}, [selectables]);
|
|
|
|
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
|
|
const commandFilter = React.useCallback(() => {
|
|
if (commandProps?.filter) {
|
|
return commandProps.filter;
|
|
}
|
|
|
|
if (creatable) {
|
|
return (value: string, search: string) => {
|
|
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
|
|
};
|
|
}
|
|
// Using default filter in `cmdk`. We don't have to provide it.
|
|
return undefined;
|
|
}, [creatable, commandProps?.filter]);
|
|
|
|
return (
|
|
<Command
|
|
{...commandProps}
|
|
onKeyDown={(e) => {
|
|
handleKeyDown(e);
|
|
commandProps?.onKeyDown?.(e);
|
|
}}
|
|
className={cn(
|
|
"h-auto overflow-visible bg-transparent",
|
|
commandProps?.className,
|
|
)}
|
|
shouldFilter={
|
|
commandProps?.shouldFilter !== undefined
|
|
? commandProps.shouldFilter
|
|
: !onSearch
|
|
} // When onSearch is provided, we don't want to filter the options. You can still override it.
|
|
filter={commandFilter()}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"min-h-10 rounded-md border border-input text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
|
|
{
|
|
"px-3 py-2": selected.length !== 0,
|
|
"cursor-text": !disabled && selected.length !== 0,
|
|
},
|
|
className,
|
|
)}
|
|
onClick={() => {
|
|
if (disabled) return;
|
|
inputRef.current?.focus();
|
|
}}
|
|
>
|
|
<div className="flex flex-wrap gap-1">
|
|
{selected.map((option) => {
|
|
return (
|
|
<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",
|
|
badgeClassName,
|
|
)}
|
|
data-fixed={option.fixed}
|
|
data-disabled={disabled || undefined}
|
|
>
|
|
{option.label ?? option.value}
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"cursor-pointer ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
(disabled || option.fixed) && "hidden",
|
|
)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
handleUnselect(option);
|
|
}
|
|
}}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
onClick={() => handleUnselect(option)}
|
|
>
|
|
<LuX className="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
|
</button>
|
|
</Badge>
|
|
);
|
|
})}
|
|
{/* Avoid having the "Search" Icon */}
|
|
<CommandPrimitive.Input
|
|
{...inputProps}
|
|
ref={inputRef}
|
|
value={inputValue}
|
|
disabled={disabled}
|
|
onValueChange={(value) => {
|
|
setInputValue(value);
|
|
inputProps?.onValueChange?.(value);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
// Allow consumer to handle first
|
|
inputProps?.onKeyDown?.(
|
|
e as unknown as React.KeyboardEvent<HTMLInputElement>,
|
|
);
|
|
if (e.defaultPrevented) return;
|
|
if (e.key === "Enter") {
|
|
const value = inputValue.trim();
|
|
if (value.length === 0) return;
|
|
// If option already exists among available options, pick that; otherwise create
|
|
const entries = Object.values(options).flat();
|
|
const existing = entries.find(
|
|
(o) => o.value === value && !o.disable,
|
|
);
|
|
// Prevent duplicates in the current selection
|
|
if (
|
|
selected.some((s) => s.value === (existing?.value ?? value))
|
|
) {
|
|
e.preventDefault();
|
|
setInputValue("");
|
|
return;
|
|
}
|
|
if (selected.length >= maxSelected) {
|
|
onMaxSelected?.(selected.length);
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
setInputValue("");
|
|
const picked = existing ?? { value, label: value };
|
|
const newOptions = [...selected, picked];
|
|
setSelected(newOptions);
|
|
onChange?.(newOptions);
|
|
}
|
|
}}
|
|
onBlur={(event) => {
|
|
setOpen(false);
|
|
inputProps?.onBlur?.(event);
|
|
}}
|
|
onFocus={(event) => {
|
|
setOpen(true);
|
|
if (triggerSearchOnFocus && onSearch) {
|
|
onSearch(debouncedSearchTerm);
|
|
}
|
|
inputProps?.onFocus?.(event);
|
|
}}
|
|
placeholder={
|
|
hidePlaceholderWhenSelected && selected.length !== 0
|
|
? ""
|
|
: placeholder
|
|
}
|
|
className={cn(
|
|
"flex-1 bg-transparent outline-none placeholder:text-muted-foreground",
|
|
{
|
|
"w-full": hidePlaceholderWhenSelected,
|
|
"px-3 mt-1": selected.length === 0,
|
|
"ml-1": selected.length !== 0,
|
|
},
|
|
inputProps?.className,
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="relative">
|
|
{open && hasAvailableOptions && (
|
|
<CommandList className="absolute top-1 z-10 w-full rounded-md border shadow-md outline-none bg-popover text-popover-foreground animate-in">
|
|
{isLoading ? (
|
|
loadingIndicator
|
|
) : (
|
|
<>
|
|
{EmptyItem()}
|
|
{CreatableItem()}
|
|
{!selectFirstItem && (
|
|
<CommandItem value="-" className="hidden" />
|
|
)}
|
|
{Object.entries(selectables).map(([key, dropdowns]) => (
|
|
<CommandGroup
|
|
key={key}
|
|
heading={key}
|
|
className="overflow-auto h-24"
|
|
>
|
|
{dropdowns.map((option) => {
|
|
return (
|
|
<CommandItem
|
|
key={option.value}
|
|
value={option.value}
|
|
disabled={option.disable}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
onSelect={() => {
|
|
if (selected.length >= maxSelected) {
|
|
onMaxSelected?.(selected.length);
|
|
return;
|
|
}
|
|
setInputValue("");
|
|
const newOptions = [...selected, option];
|
|
setSelected(newOptions);
|
|
onChange?.(newOptions);
|
|
}}
|
|
className={cn(
|
|
"cursor-pointer",
|
|
option.disable &&
|
|
"cursor-default text-muted-foreground",
|
|
)}
|
|
>
|
|
{option.label ?? option.value}
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</CommandGroup>
|
|
))}
|
|
</>
|
|
)}
|
|
</CommandList>
|
|
)}
|
|
</div>
|
|
</Command>
|
|
);
|
|
},
|
|
);
|
|
|
|
MultipleSelector.displayName = "MultipleSelector";
|
|
export default MultipleSelector;
|