From bdbd3933cd0201d7e081169a8329981b3ccf2223 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 8 Mar 2026 19:42:23 +0100 Subject: [PATCH] Implement combobox --- front/bun.lock | 3 + front/package.json | 1 + front/src/primitives/combobox.tsx | 193 ++++++++++++++++++++++++++ front/src/primitives/combobox.web.tsx | 185 ++++++++++++++++++++++++ front/src/primitives/index.ts | 1 + front/src/ui/admin/videos-modal.tsx | 23 +-- 6 files changed, 398 insertions(+), 8 deletions(-) create mode 100644 front/src/primitives/combobox.tsx create mode 100644 front/src/primitives/combobox.web.tsx diff --git a/front/bun.lock b/front/bun.lock index 03e4f9fa..8a4d8112 100644 --- a/front/bun.lock +++ b/front/bun.lock @@ -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=="], diff --git a/front/package.json b/front/package.json index 376d06d9..534794d5 100644 --- a/front/package.json +++ b/front/package.json @@ -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", diff --git a/front/src/primitives/combobox.tsx b/front/src/primitives/combobox.tsx new file mode 100644 index 00000000..63197783 --- /dev/null +++ b/front/src/primitives/combobox.tsx @@ -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 = (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 = ({ + label, + value, + onValueChange, + query, + getLabel, + getKey, + placeholder, + placeholderCount = 4, +}: { + label: string; + value: Data | null; + onValueChange: (item: Data | null) => void; + query: (search: string) => QueryIdentifier; + 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(null); + + const currentQuery = query(debouncedSearch); + const oldItems = useRef(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 ( + <> + 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", + )} + > + +

+ {value ? getLabel(value) : (placeholder ?? label)} +

+ +
+
+ {isOpen && ( + + + + + + + + + + item ? getKey(item) : `placeholder-${index}` + } + renderItem={({ item }: { item: Data | null }) => + item ? ( + handleSelect(item)} + /> + ) : ( + + ) + } + onEndReached={ + hasNextPage && !isFetching ? () => fetchNextPage() : undefined + } + onEndReachedThreshold={0.5} + showsVerticalScrollIndicator={false} + /> + + + )} + + ); +}; + +const ComboBoxItem = ({ + label, + selected, + onSelect, +}: { + label: string; + selected: boolean; + onSelect: () => void; +}) => { + return ( + + {selected && } +

+ {label} +

+
+ ); +}; + +const ComboBoxItemLoader = () => { + return ( + + + + ); +}; diff --git a/front/src/primitives/combobox.web.tsx b/front/src/primitives/combobox.web.tsx new file mode 100644 index 00000000..419ce6ee --- /dev/null +++ b/front/src/primitives/combobox.web.tsx @@ -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 = ({ + label, + value, + onValueChange, + query, + getLabel, + getKey, + placeholder, + placeholderCount = 4, +}: { + label: string; + value: Data | null; + onValueChange: (item: Data | null) => void; + query: (search: string) => QueryIdentifier; + getLabel: (item: Data) => string; + getKey: (item: Data) => string; + placeholder?: string; + placeholderCount?: number; +}) => { + const [isOpen, setOpen] = useState(false); + const [search, setSearch] = useState(""); + + const oldItems = useRef(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 ( + { + setOpen(open); + if (!open) setSearch(""); + }} + > + + + +

+ {value ? getLabel(value) : (placeholder ?? label)} +

+ +
+
+
+ + 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)", + }} + > +
+ + 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", + )} + /> +
+ + item ? getKey(item) : `placeholder-${index}` + } + renderItem={({ item }: { item: Data | null }) => + item ? ( + + ((item: Data) => { + onValueChange(item); + setOpen(false); + setSearch(""); + })(item) + } + /> + ) : ( + + ) + } + onEndReached={ + hasNextPage && !isFetching ? () => fetchNextPage() : undefined + } + onEndReachedThreshold={0.5} + showsVerticalScrollIndicator={false} + style={{ flex: 1, overflow: "auto" as any }} + /> + +
+
+
+ ); +}; + +const ComboBoxItem = ({ + label, + selected, + onSelect, +}: { + label: string; + selected: boolean; + onSelect: () => void; +}) => { + return ( + + ); +}; + +const ComboBoxItemLoader = () => { + return ( + + + + ); +}; diff --git a/front/src/primitives/index.ts b/front/src/primitives/index.ts index da067309..134eb4e4 100644 --- a/front/src/primitives/index.ts +++ b/front/src/primitives/index.ts @@ -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"; diff --git a/front/src/ui/admin/videos-modal.tsx b/front/src/ui/admin/videos-modal.tsx index 7d1e5b3c..4debee4f 100644 --- a/front/src/ui/admin/videos-modal.tsx +++ b/front/src/ui/admin/videos-modal.tsx @@ -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 }) => (

{item.path}

-