Implement combobox

This commit is contained in:
Zoe Roux 2026-03-08 19:42:23 +01:00
parent b55a61b944
commit bdbd3933cd
No known key found for this signature in database
6 changed files with 398 additions and 8 deletions

View File

@ -13,6 +13,7 @@
"@legendapp/list": "zoriya/legend-list#build",
"@material-symbols/svg-400": "^0.40.2",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/native": "^7.1.8",
@ -474,6 +475,8 @@
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],

View File

@ -23,6 +23,7 @@
"@legendapp/list": "zoriya/legend-list#build",
"@material-symbols/svg-400": "^0.40.2",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/native": "^7.1.8",

View File

@ -0,0 +1,193 @@
import { Portal } from "@gorhom/portal";
import { LegendList } from "@legendapp/list";
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 { type QueryIdentifier, useInfiniteFetch } from "~/query/query";
import { cn } from "~/utils";
import { Icon, IconButton } from "./icons";
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;
};
export const ComboBox = <Data,>({
label,
value,
onValueChange,
query,
getLabel,
getKey,
placeholder,
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;
}) => {
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);
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 handleSelect = (item: Data) => {
onValueChange(item);
setOpen(false);
setSearch("");
};
const handleClose = () => {
setOpen(false);
setSearch("");
};
return (
<>
<PressableFeedback
onPressIn={() => setOpen(true)}
accessibilityLabel={label}
className={cn(
"flex-row items-center justify-center overflow-hidden",
"rounded-4xl border-3 border-accent p-1 outline-0",
"group focus-within:bg-accent hover:bg-accent",
)}
>
<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)}
</P>
<Icon
icon={ExpandMore}
className="group-focus-within:fill-slate-200 group-hover:fill-slate-200"
/>
</View>
</PressableFeedback>
{isOpen && (
<Portal>
<Pressable
onPress={handleClose}
tabIndex={-1}
className="absolute inset-0 flex-1 bg-transparent"
/>
<View
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",
"xl:top-0 xl:right-0 xl:mr-0 xl:rounded-l-4xl xl:rounded-tr-0",
)}
>
<IconButton
icon={Close}
onPress={handleClose}
className="hidden self-end xl:flex"
/>
<View
className={cn(
"mx-4 mb-2 flex-row items-center rounded-xl border border-accent p-1",
"focus-within:border-2",
)}
>
<Icon icon={SearchIcon} className="mx-2" />
<TextInput
ref={inputRef}
value={search}
onChangeText={setSearch}
placeholder={placeholder ?? label}
autoFocus
textAlignVertical="center"
className="h-full flex-1 font-sans text-base text-slate-600 outline-0 dark:text-slate-400"
/>
</View>
<LegendList
data={data}
estimatedItemSize={48}
keyExtractor={(item: Data | null, index: number) =>
item ? getKey(item) : `placeholder-${index}`
}
renderItem={({ item }: { item: Data | null }) =>
item ? (
<ComboBoxItem
label={getLabel(item)}
selected={value !== null && getKey(item) === getKey(value)}
onSelect={() => handleSelect(item)}
/>
) : (
<ComboBoxItemLoader />
)
}
onEndReached={
hasNextPage && !isFetching ? () => fetchNextPage() : undefined
}
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
/>
</View>
</Portal>
)}
</>
);
};
const ComboBoxItem = ({
label,
selected,
onSelect,
}: {
label: string;
selected: boolean;
onSelect: () => void;
}) => {
return (
<PressableFeedback
onPress={onSelect}
className="h-12 w-full flex-row items-center px-4"
>
{selected && <Icon icon={Check} className="mx-6" />}
<P
style={{
paddingLeft: selected ? 0 : 8 * 2 + 24,
}}
className="flex-1"
>
{label}
</P>
</PressableFeedback>
);
};
const ComboBoxItemLoader = () => {
return (
<View className="h-12 w-full flex-row items-center px-4">
<Skeleton className="ml-14 h-4 w-3/5" />
</View>
);
};

View File

@ -0,0 +1,185 @@
import { LegendList } from "@legendapp/list";
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, View } from "react-native";
import { type QueryIdentifier, useInfiniteFetch } from "~/query/query";
import { cn } from "~/utils";
import { Icon } from "./icons";
import { PressableFeedback } from "./links";
import { InternalTriger } from "./menu.web";
import { Skeleton } from "./skeleton";
import { P } from "./text";
export const ComboBox = <Data,>({
label,
value,
onValueChange,
query,
getLabel,
getKey,
placeholder,
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;
}) => {
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]);
return (
<Popover.Root
open={isOpen}
onOpenChange={(open: boolean) => {
setOpen(open);
if (!open) setSearch("");
}}
>
<Popover.Trigger aria-label={label} asChild>
<InternalTriger
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">
{value ? getLabel(value) : (placeholder ?? label)}
</P>
<Icon
icon={ExpandMore}
className="group-focus-within:fill-slate-200 group-hover:fill-slate-200"
/>
</View>
</InternalTriger>
</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={placeholder ?? label}
// 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}
estimatedItemSize={40}
keyExtractor={(item: Data | null, index: number) =>
item ? getKey(item) : `placeholder-${index}`
}
renderItem={({ item }: { item: Data | null }) =>
item ? (
<ComboBoxItem
label={getLabel(item)}
selected={value !== null && getKey(item) === getKey(value)}
onSelect={() =>
((item: Data) => {
onValueChange(item);
setOpen(false);
setSearch("");
})(item)
}
/>
) : (
<ComboBoxItemLoader />
)
}
onEndReached={
hasNextPage && !isFetching ? () => fetchNextPage() : undefined
}
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
style={{ flex: 1, overflow: "auto" as any }}
/>
<Popover.Arrow className="fill-popover" />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
const ComboBoxItem = ({
label,
selected,
onSelect,
}: {
label: string;
selected: boolean;
onSelect: () => void;
}) => {
return (
<button
type="button"
onClick={onSelect}
className={cn(
"flex w-full select-none items-center rounded py-2 pr-6 pl-8 outline-0",
"font-sans text-slate-600 dark:text-slate-400",
"hover:bg-accent hover:text-slate-200",
"group",
)}
>
{selected && (
<Icon
icon={Check}
className={cn(
"absolute left-0 w-6 items-center justify-center",
"group-hover:fill-slate-200",
)}
/>
)}
<span className="text-left group-hover:text-slate-200">{label}</span>
</button>
);
};
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>
);
};

View File

@ -3,6 +3,7 @@ export * from "./alert";
export * from "./avatar";
export * from "./button";
export * from "./chip";
export * from "./combobox";
export * from "./container";
export * from "./divider";
export * from "./icons";

View File

@ -1,7 +1,7 @@
import { View } from "react-native";
import { entryDisplayNumber } from "~/components/entries";
import { FullVideo } from "~/models";
import { Modal, P, Select, Skeleton } from "~/primitives";
import { Entry, FullVideo } from "~/models";
import { ComboBox, Modal, P, Skeleton } from "~/primitives";
import { InfiniteFetch, type QueryIdentifier } from "~/query";
import { useQueryState } from "~/utils";
@ -16,14 +16,21 @@ export const VideosModal = () => {
Render={({ item }) => (
<View className="h-12 flex-row items-center justify-between hover:bg-card">
<P>{item.path}</P>
<Select
<ComboBox
label={"toto"}
value={1}
values={[1, 2, 3]}
getLabel={() =>
item.entries.map((x) => entryDisplayNumber(x)).join(", ")
}
value={null}
// value={item.entries.map((x) => entryDisplayNumber(x)).join(", ")}
query={(q) => ({
parser: Entry,
path: ["api", "series", slug, "entries"],
params: {
query: q,
},
infinite: true,
})}
getLabel={(x) => `${entryDisplayNumber(x)} - ${x.name}`}
onValueChange={(x) => {}}
getKey={(x) => x.id}
/>
</View>
)}