Files
Aurora-SpotiRip/components/AutoComplete.tsx
T
R3ap3r 3178b8f4f7 Aurora SpotiRip desktop and web app
- Spotify playlist, library, album and artist browsing with in-app playback
- Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks
- Electron desktop app (castLabs Widevine) with a permanent sidebar layout
- Linux and Windows packaging scripts
2026-09-25 23:20:57 -05:00

171 lines
4.7 KiB
TypeScript

import { CommandGroup, CommandItem, CommandList } from "./Command";
import { Command as CommandPrimitive } from "cmdk";
import {
useState,
useRef,
useCallback,
type KeyboardEvent,
type ReactNode,
} from "react";
import { Popover, PopoverContent, PopoverTrigger } from "./Popover";
import { Skeleton } from "./Skeleton";
import styles from "./AutoComplete.module.css";
export type Option<T = unknown> = {
value: string;
label: ReactNode;
displayText?: string;
metadata?: T;
};
const getDisplayText = <T,>(option: Option<T>): string => {
if (option.displayText) {
return option.displayText;
}
if (typeof option.label === "string") {
return option.label;
}
return option.value;
};
type AutoCompleteProps<T = unknown> = {
options: Option<T>[];
emptyMessage: string;
value?: Option<T>;
onValueChange?: (value: Option<T>) => void;
inputValue: string;
onInputValueChange: (value: string) => void;
isLoading?: boolean;
disabled?: boolean;
placeholder?: string;
allowFreeForm?: boolean;
};
export const AutoComplete = <T = unknown,>({
options,
placeholder,
emptyMessage,
value,
onValueChange,
inputValue,
onInputValueChange,
disabled,
isLoading = false,
allowFreeForm = false,
}: AutoCompleteProps<T>) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isOpen, setOpen] = useState(false);
const [selected, setSelected] = useState<Option<T> | undefined>(value);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
const input = inputRef.current;
if (!input) {
return;
}
if (event.key === "Enter" && input.value !== "") {
const optionToSelect = options.find(
(option) => getDisplayText(option) === input.value,
);
if (optionToSelect) {
setSelected(optionToSelect);
onValueChange?.(optionToSelect);
}
}
if (event.key === "Escape") {
input.blur();
setOpen(false);
}
},
[options, onValueChange],
);
const handleBlur = useCallback(() => {
if (!isOpen && !allowFreeForm) {
onInputValueChange(selected ? getDisplayText(selected) : "");
}
}, [selected, isOpen, onInputValueChange, allowFreeForm]);
const handleSelectOption = useCallback(
(selectedOption: Option<T>) => {
onInputValueChange(getDisplayText(selectedOption));
setSelected(selectedOption);
onValueChange?.(selectedOption);
setOpen(false);
setTimeout(() => {
inputRef?.current?.blur();
}, 0);
},
[onValueChange, onInputValueChange],
);
return (
<Popover open={isOpen} onOpenChange={setOpen}>
<CommandPrimitive
onKeyDown={handleKeyDown}
className={styles.autoComplete}
>
<div className={styles.inputWrapper}>
<PopoverTrigger asChild>
<CommandPrimitive.Input
ref={inputRef}
value={inputValue}
onValueChange={isLoading ? undefined : onInputValueChange}
onBlur={handleBlur}
placeholder={placeholder}
disabled={disabled}
className={styles.customInput}
/>
</PopoverTrigger>
</div>
{!!options.length && (
<PopoverContent
removeBackgroundAndPadding
className={styles.popoverContent}
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<CommandList className={styles.commandList}>
{options.length > 0 && (
<CommandGroup>
{options.map((option) => {
return (
<CommandItem
key={option.value}
value={getDisplayText(option)}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onSelect={() => handleSelectOption(option)}
>
{option.label}
</CommandItem>
);
{
isLoading ? (
<CommandPrimitive.Loading>
<div className={styles.loadingContainer}>
<Skeleton style={{ height: "2rem" }} />
</div>
</CommandPrimitive.Loading>
) : null;
}
})}
</CommandGroup>
)}
</CommandList>
</PopoverContent>
)}
</CommandPrimitive>
</Popover>
);
};