Add multiselect to the combo box

This commit is contained in:
Zoe Roux 2026-03-08 22:25:12 +01:00
parent 86687f8802
commit 8f53158cc3
No known key found for this signature in database
6 changed files with 138 additions and 82 deletions

View File

@ -44,7 +44,9 @@
"nextUp": "Next up",
"season": "Season {{number}}",
"multiVideos": "Multiples video files available",
"videosCount": "{{number}} videos"
"videosCount": "{{number}} videos",
"videos-map": "Edit video mappings",
"videos-map-none": "No mapping"
},
"browse": {
"mediatypekey": {

View File

@ -4,8 +4,8 @@ import Check from "@material-symbols/svg-400/rounded/check-fill.svg";
import Close from "@material-symbols/svg-400/rounded/close-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 { useEffect, useMemo, useRef, useState } from "react";
import { Pressable, TextInput, View } from "react-native";
import { useMemo, useRef, useState } from "react";
import { KeyboardAvoidingView, Pressable, TextInput, View } from "react-native";
import { type QueryIdentifier, useInfiniteFetch } from "~/query/query";
import { cn } from "~/utils";
import { Icon, IconButton } from "./icons";
@ -13,43 +13,54 @@ import { PressableFeedback } from "./links";
import { Skeleton } from "./skeleton";
import { P } from "./text";
const useDebounce = <T,>(value: T, delay: number): T => {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
type ComboBoxSingleProps<Data> = {
multiple?: false;
value: Data | null;
values?: never;
onValueChange: (item: Data | null) => void;
};
type ComboBoxMultiProps<Data> = {
multiple: true;
value?: never;
values: Data[];
onValueChange: (items: Data[]) => void;
};
type ComboBoxBaseProps<Data> = {
label: string;
searchPlaceholder?: string;
query: (search: string) => QueryIdentifier<Data>;
getKey: (item: Data) => string;
getLabel: (item: Data) => string;
getSmallLabel?: (item: Data) => string;
placeholderCount?: number;
};
export type ComboBoxProps<Data> = ComboBoxBaseProps<Data> &
(ComboBoxSingleProps<Data> | ComboBoxMultiProps<Data>);
export const ComboBox = <Data,>({
label,
value,
values,
onValueChange,
query,
getLabel,
getSmallLabel,
getKey,
placeholder,
searchPlaceholder,
placeholderCount = 4,
}: {
label: string;
value: Data | null;
onValueChange: (item: Data | null) => void;
query: (search: string) => QueryIdentifier<Data>;
getLabel: (item: Data) => string;
getKey: (item: Data) => string;
placeholder?: string;
placeholderCount?: number;
}) => {
multiple,
}: ComboBoxProps<Data>) => {
const [isOpen, setOpen] = useState(false);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const inputRef = useRef<TextInput>(null);
const currentQuery = query(debouncedSearch);
const oldItems = useRef<Data[] | undefined>(undefined);
let { items, fetchNextPage, hasNextPage, isFetching } =
useInfiniteFetch(currentQuery);
let { items, fetchNextPage, hasNextPage, isFetching } = useInfiniteFetch(
query(search),
);
if (items) oldItems.current = items;
items ??= oldItems.current;
@ -59,16 +70,10 @@ export const ComboBox = <Data,>({
return isFetching ? [...items, ...placeholders] : items;
}, [items, isFetching, placeholderCount]);
const handleSelect = (item: Data) => {
onValueChange(item);
setOpen(false);
setSearch("");
};
const handleClose = () => {
setOpen(false);
setSearch("");
};
const selectedKeys = useMemo(() => {
if (multiple) return new Set(values.map(getKey));
return new Set(value !== null ? [getKey(value)] : []);
}, [value, values, multiple, getKey]);
return (
<>
@ -83,7 +88,11 @@ export const ComboBox = <Data,>({
>
<View className="flex-row items-center px-6">
<P className="text-center group-focus-within:text-slate-200 group-hover:text-slate-200">
{value ? getLabel(value) : (placeholder ?? label)}
{(multiple ? !values : !value)
? label
: (multiple ? values : [value!])
.map(getSmallLabel ?? getLabel)
.join(", ")}
</P>
<Icon
icon={ExpandMore}
@ -94,11 +103,15 @@ export const ComboBox = <Data,>({
{isOpen && (
<Portal>
<Pressable
onPress={handleClose}
onPress={() => {
setOpen(false);
setSearch("");
}}
tabIndex={-1}
className="absolute inset-0 flex-1 bg-transparent"
/>
<View
<KeyboardAvoidingView
behavior="padding"
className={cn(
"absolute bottom-0 w-full self-center bg-popover pb-safe sm:mx-12 sm:max-w-2xl",
"mt-20 max-h-[80vh] rounded-t-4xl pt-8",
@ -107,7 +120,10 @@ export const ComboBox = <Data,>({
>
<IconButton
icon={Close}
onPress={handleClose}
onPress={() => {
setOpen(false);
setSearch("");
}}
className="hidden self-end xl:flex"
/>
<View
@ -121,7 +137,7 @@ export const ComboBox = <Data,>({
ref={inputRef}
value={search}
onChangeText={setSearch}
placeholder={placeholder ?? label}
placeholder={searchPlaceholder}
autoFocus
textAlignVertical="center"
className="h-full flex-1 font-sans text-base text-slate-600 outline-0 dark:text-slate-400"
@ -137,8 +153,22 @@ export const ComboBox = <Data,>({
item ? (
<ComboBoxItem
label={getLabel(item)}
selected={value !== null && getKey(item) === getKey(value)}
onSelect={() => handleSelect(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 />
@ -150,7 +180,7 @@ export const ComboBox = <Data,>({
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
/>
</View>
</KeyboardAvoidingView>
</Portal>
)}
</>

View File

@ -5,8 +5,9 @@ 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, View } from "react-native";
import { type QueryIdentifier, useInfiniteFetch } from "~/query/query";
import { useInfiniteFetch } from "~/query/query";
import { cn } from "~/utils";
import type { ComboBoxProps } from "./combobox";
import { Icon } from "./icons";
import { PressableFeedback } from "./links";
import { InternalTriger } from "./menu.web";
@ -15,23 +16,17 @@ import { P } from "./text";
export const ComboBox = <Data,>({
label,
searchPlaceholder,
value,
values,
onValueChange,
query,
getLabel,
getKey,
placeholder,
getLabel,
getSmallLabel,
placeholderCount = 4,
}: {
label: string;
value: Data | null;
onValueChange: (item: Data | null) => void;
query: (search: string) => QueryIdentifier<Data>;
getLabel: (item: Data) => string;
getKey: (item: Data) => string;
placeholder?: string;
placeholderCount?: number;
}) => {
multiple,
}: ComboBoxProps<Data>) => {
const [isOpen, setOpen] = useState(false);
const [search, setSearch] = useState("");
@ -48,6 +43,11 @@ export const ComboBox = <Data,>({
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}
@ -67,7 +67,11 @@ export const ComboBox = <Data,>({
>
<View className="flex-row items-center px-6">
<P className="text-center group-focus-within:text-slate-200 group-hover:text-slate-200">
{value ? getLabel(value) : (placeholder ?? label)}
{(multiple ? !values : !value)
? label
: (multiple ? values : [value!])
.map(getSmallLabel ?? getLabel)
.join(", ")}
</P>
<Icon
icon={ExpandMore}
@ -96,7 +100,7 @@ export const ComboBox = <Data,>({
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={placeholder ?? label}
placeholder={searchPlaceholder}
// biome-ignore lint/a11y/noAutofocus: combobox search should auto-focus on open
autoFocus
className={cn(
@ -115,14 +119,22 @@ export const ComboBox = <Data,>({
item ? (
<ComboBoxItem
label={getLabel(item)}
selected={value !== null && getKey(item) === getKey(value)}
onSelect={() =>
((item: Data) => {
selected={selectedKeys.has(getKey(item))}
onSelect={() => {
if (!multiple) {
onValueChange(item);
setOpen(false);
setSearch("");
})(item)
}
return;
}
if (!selectedKeys.has(getKey(item))) {
onValueChange([...values, item]);
return;
}
onValueChange(
values.filter((v) => getKey(v) !== getKey(item)),
);
}}
/>
) : (
<ComboBoxItemLoader />

View File

@ -37,7 +37,7 @@ export const Modal = ({
<Pressable
className={cn(
"w-full max-w-3xl rounded-md bg-background",
"max-h-[90vh] cursor-default overflow-hidden *:p-6",
"max-h-[90vh] cursor-default! overflow-hidden *:p-6",
)}
onPress={(e) => e.preventDefault()}
>

View File

@ -1,15 +1,19 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { entryDisplayNumber } from "~/components/entries";
import { Entry, FullVideo } from "~/models";
import { ComboBox, Modal, P, Skeleton } from "~/primitives";
import { InfiniteFetch, type QueryIdentifier } from "~/query";
import { InfiniteFetch, type QueryIdentifier, useFetch } from "~/query";
import { useQueryState } from "~/utils";
import { Header } from "../details/header";
export const VideosModal = () => {
const [slug] = useQueryState<string>("slug", undefined!);
const { data } = useFetch(Header.query("serie", slug));
const { t } = useTranslation();
return (
<Modal title="toto" scroll={false}>
<Modal title={data?.name ?? t("misc.loading")} scroll={false}>
<InfiniteFetch
query={VideosModal.query(slug)}
layout={{ layout: "vertical", gap: 8, numColumns: 1, size: 48 }}
@ -17,9 +21,10 @@ export const VideosModal = () => {
<View className="h-12 flex-row items-center justify-between hover:bg-card">
<P>{item.path}</P>
<ComboBox
label={"toto"}
value={null}
// value={item.entries.map((x) => entryDisplayNumber(x)).join(", ")}
multiple
label={t("show.videos-map-none")}
searchPlaceholder={t("navbar.search")}
values={item.entries}
query={(q) => ({
parser: Entry,
path: ["api", "series", slug, "entries"],
@ -28,9 +33,10 @@ export const VideosModal = () => {
},
infinite: true,
})}
getLabel={(x) => `${entryDisplayNumber(x)} - ${x.name}`}
onValueChange={(x) => {}}
getKey={(x) => x.id}
getLabel={(x) => `${entryDisplayNumber(x)} - ${x.name}`}
getSmallLabel={entryDisplayNumber}
onValueChange={(x) => {}}
/>
</View>
)}

View File

@ -3,6 +3,7 @@ import MoreHoriz from "@material-symbols/svg-400/rounded/more_horiz.svg";
import MovieInfo from "@material-symbols/svg-400/rounded/movie_info.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import Theaters from "@material-symbols/svg-400/rounded/theaters-fill.svg";
import VideoLibrary from "@material-symbols/svg-400/rounded/video_library-fill.svg";
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
import { View, type ViewProps } from "react-native";
@ -116,16 +117,21 @@ const ButtonList = ({
/>
</>
)}
{/* {account?.isAdmin === true && ( */}
{/* <> */}
{/* {kind === "movie" && <HR />} */}
{/* <Menu.Item */}
{/* label={t("home.refreshMetadata")} */}
{/* icon={Refresh} */}
{/* onSelect={() => metadataRefreshMutation.mutate()} */}
{/* /> */}
{/* </> */}
{/* )} */}
{account?.isAdmin === true && (
<>
{kind === "movie" && <HR />}
<Menu.Item
label={t("show.videos-map")}
icon={VideoLibrary}
href={`/series/${slug}/videos`}
/>
{/* <Menu.Item */}
{/* label={t("home.refreshMetadata")} */}
{/* icon={Refresh} */}
{/* onSelect={() => metadataRefreshMutation.mutate()} */}
{/* /> */}
</>
)}
</Menu>
)}
</View>