mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-04-14 13:11:54 -04:00
Implement combobox
This commit is contained in:
parent
b55a61b944
commit
bdbd3933cd
@ -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=="],
|
||||
|
||||
@ -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",
|
||||
|
||||
193
front/src/primitives/combobox.tsx
Normal file
193
front/src/primitives/combobox.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
185
front/src/primitives/combobox.web.tsx
Normal file
185
front/src/primitives/combobox.web.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user