Files
Kyoo/front/src/primitives/combobox.web.tsx
T
2026-03-31 19:24:27 +02:00

202 lines
5.6 KiB
TypeScript

import { LegendList } from "@legendapp/list/react-native";
import Check from "@material-symbols/svg-400/rounded/check-fill.svg";
import ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg";
import SearchIcon from "@material-symbols/svg-400/rounded/search-fill.svg";
import * as Popover from "@radix-ui/react-popover";
import { useMemo, useRef, useState } from "react";
import { Platform, Pressable, View } from "react-native";
import { useInfiniteFetch } from "~/query/query";
import { cn } from "~/utils";
import type { ComboBoxProps } from "./combobox";
import { Icon } from "./icons";
import { PressableFeedback } from "./links";
import { InternalTrigger } from "./menu.web";
import { Skeleton } from "./skeleton";
import { P } from "./text";
export const ComboBox = <Data,>({
label,
searchPlaceholder,
value,
values,
onValueChange,
query,
getKey,
getLabel,
getSmallLabel,
placeholderCount = 4,
multiple,
Trigger,
}: ComboBoxProps<Data>) => {
const [isOpen, setOpen] = useState(false);
const [search, setSearch] = useState("");
const oldItems = useRef<Data[] | undefined>(undefined);
let { items, fetchNextPage, hasNextPage, isFetching } = useInfiniteFetch(
query(search),
);
if (items) oldItems.current = items;
items ??= oldItems.current;
const data = useMemo(() => {
const placeholders = [...Array(placeholderCount)].fill(null);
if (!items) return placeholders;
return isFetching ? [...items, ...placeholders] : items;
}, [items, isFetching, placeholderCount]);
const selectedKeys = useMemo(() => {
if (multiple) return new Set(values.map(getKey));
return new Set(value !== null ? [getKey(value as Data)] : []);
}, [value, values, multiple, getKey]);
return (
<Popover.Root
open={isOpen}
onOpenChange={(open: boolean) => {
setOpen(open);
if (!open) setSearch("");
}}
>
<Popover.Trigger aria-label={label} asChild>
{Trigger ? (
<InternalTrigger Component={Trigger} />
) : (
<InternalTrigger
Component={Platform.OS === "web" ? "div" : PressableFeedback}
className={cn(
"group flex-row items-center justify-center overflow-hidden rounded-4xl",
"border-2 border-accent p-1 outline-0 focus-within:bg-accent hover:bg-accent",
"cursor-pointer",
)}
>
<View className="flex-row items-center px-6">
<P className="text-center group-focus-within:text-slate-200 group-hover:text-slate-200">
{(multiple ? !values.length : !value)
? label
: (multiple ? values : [value!])
.sort((a, b) => getKey(a).localeCompare(getKey(b)))
.map(getSmallLabel ?? getLabel)
.join(", ")}
</P>
<Icon
icon={ExpandMore}
className="group-focus-within:fill-slate-200 group-hover:fill-slate-200"
/>
</View>
</InternalTrigger>
)}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
sideOffset={4}
onOpenAutoFocus={(e: Event) => e.preventDefault()}
className="z-10 flex min-w-3xs flex-col overflow-hidden rounded bg-popover shadow-xl"
style={{
maxHeight:
"calc(var(--radix-popover-content-available-height) * 0.8)",
}}
>
<div
className={cn(
"flex flex-row items-center border-accent border-b px-2",
)}
>
<Icon icon={SearchIcon} className="mx-1 shrink-0" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={searchPlaceholder}
// biome-ignore lint/a11y/noAutofocus: combobox search should auto-focus on open
autoFocus
className={cn(
"w-full bg-transparent py-2 font-sans text-base outline-0",
"text-slate-600 placeholder:text-slate-600/50 dark:text-slate-400 dark:placeholder:text-slate-400/50",
)}
/>
</div>
<LegendList
data={data}
extraData={selectedKeys}
estimatedItemSize={40}
keyExtractor={(item: Data | null, index: number) =>
item ? getKey(item) : `placeholder-${index}`
}
renderItem={({ item }: { item: Data | null }) =>
item ? (
<ComboBoxItem
label={getLabel(item)}
selected={selectedKeys.has(getKey(item))}
onSelect={() => {
if (!multiple) {
onValueChange(item);
setOpen(false);
return;
}
if (!selectedKeys.has(getKey(item))) {
onValueChange([...values, item]);
return;
}
onValueChange(
values.filter((v) => getKey(v) !== getKey(item)),
);
}}
/>
) : (
<ComboBoxItemLoader />
)
}
onEndReached={
hasNextPage && !isFetching ? () => fetchNextPage() : undefined
}
onEndReachedThreshold={0.5}
/>
<Popover.Arrow className="fill-popover" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
const ComboBoxItem = ({
label,
selected,
onSelect,
}: {
label: string;
selected: boolean;
onSelect: () => void;
}) => {
return (
<Pressable
onPress={onSelect}
className={cn(
"flex w-full select-none rounded py-2 pr-6 pl-8 outline-0",
"group focus-within:bg-accent hover:bg-accent",
)}
>
{selected && (
<Icon
icon={Check}
className={cn(
"absolute left-0 w-6 items-center justify-center",
"group-hover:fill-slate-200",
)}
/>
)}
<P className="group-focus-within:text-slate-200 group-hover:text-slate-200">
{label}
</P>
</Pressable>
);
};
const ComboBoxItemLoader = () => {
return (
<View className="flex h-10 w-full flex-row items-center py-2 pr-6 pl-8">
<Skeleton className="h-4 w-3/5" />
</View>
);
};