From 58ad50b749511e44a2f728e5ebb578ade41f3c29 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Fri, 22 Aug 2025 17:50:13 +0200 Subject: [PATCH] ui/search: add search with virtualized scrolling --- pkgs/clan-app/ui/package-lock.json | 27 +++ pkgs/clan-app/ui/package.json | 1 + .../src/components/Search/Search.module.css | 102 +++++++++ .../src/components/Search/Search.stories.tsx | 75 +++++++ .../ui/src/components/Search/Search.tsx | 210 ++++++++++++++++++ 5 files changed, 415 insertions(+) create mode 100644 pkgs/clan-app/ui/src/components/Search/Search.module.css create mode 100644 pkgs/clan-app/ui/src/components/Search/Search.stories.tsx create mode 100644 pkgs/clan-app/ui/src/components/Search/Search.tsx diff --git a/pkgs/clan-app/ui/package-lock.json b/pkgs/clan-app/ui/package-lock.json index e3616b477..7bdc03484 100644 --- a/pkgs/clan-app/ui/package-lock.json +++ b/pkgs/clan-app/ui/package-lock.json @@ -19,6 +19,7 @@ "@tanstack/solid-query": "^5.85.5", "@tanstack/solid-query-devtools": "^5.85.5", "@tanstack/solid-query-persist-client": "^5.85.5", + "@tanstack/solid-virtual": "^3.13.12", "solid-js": "^1.9.7", "solid-toast": "^0.5.0", "three": "^0.176.0", @@ -2745,6 +2746,32 @@ "solid-js": "^1.6.0" } }, + "node_modules/@tanstack/solid-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/solid-virtual/-/solid-virtual-3.13.12.tgz", + "integrity": "sha512-0dS8GkBTmbuM9cUR6Jni0a45eJbd32CAEbZj8HrZMWIj3lu974NpGz5ywcomOGJ9GdeHuDaRzlwtonBbKV1ihQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "solid-js": "^1.3.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", diff --git a/pkgs/clan-app/ui/package.json b/pkgs/clan-app/ui/package.json index b60a38c0c..30ce1f1d3 100644 --- a/pkgs/clan-app/ui/package.json +++ b/pkgs/clan-app/ui/package.json @@ -76,6 +76,7 @@ "@tanstack/solid-query": "^5.85.5", "@tanstack/solid-query-devtools": "^5.85.5", "@tanstack/solid-query-persist-client": "^5.85.5", + "@tanstack/solid-virtual": "^3.13.12", "solid-js": "^1.9.7", "solid-toast": "^0.5.0", "three": "^0.176.0", diff --git a/pkgs/clan-app/ui/src/components/Search/Search.module.css b/pkgs/clan-app/ui/src/components/Search/Search.module.css new file mode 100644 index 000000000..4b5c4dbee --- /dev/null +++ b/pkgs/clan-app/ui/src/components/Search/Search.module.css @@ -0,0 +1,102 @@ +.searchInput { + @apply w-full bg-inv-4 fg-inv-1; + + font-size: 0.875rem; + font-weight: 500; + font-family: "Archivo", sans-serif; + line-height: 132%; + + &::placeholder { + @apply fg-def-4; + } + + &:focus, + &:focus-visible { + @apply outline-none; + } + + &[data-invalid] { + @apply outline-semantic-error-4; + } + + &[data-disabled] { + @apply outline-def-2 fg-def-4 cursor-not-allowed; + } + + &[data-readonly] { + @apply outline-none border-none bg-inherit p-0 cursor-auto resize-none; + } +} + +.searchHeader { + @apply bg-inv-3 flex gap-2 items-center p-2 rounded-md z-50; + @apply px-3 pt-3 pb-2; +} + +.inputContainer { + @apply flex items-center gap-2 bg-inv-4 rounded-md px-1 w-full; + + :has :focus-visible { + @apply bg-def-1; + } +} + +.searchItem { + @apply flex py-1 px-2 pr-4 gap-2 justify-between items-center rounded-md; + + & [role="option"] { + @apply flex flex-col w-full; + } + + /* Icon */ + & [role="complementary"] { + @apply size-8 flex items-center justify-center bg-white rounded-md; + } + + &[data-highlighted], + &:focus, + &:focus-visible, + &:hover { + @apply bg-inv-acc-2; + } + + &:active { + @apply bg-inv-acc-3; + } +} + +.searchContainer { + @apply bg-gradient-to-b from-bg-inv-3 to-bg-inv-4; + + @apply h-[14.5rem] rounded-lg; + + border: 1px solid #2b4647; + + background: + linear-gradient(0deg, rgba(0, 0, 0, 0.18) 0%, rgba(0, 0, 0, 0.18) 100%), + linear-gradient( + 180deg, + var(--clr-bg-inv-3, rgba(43, 70, 71, 0.79)) 0%, + var(--clr-bg-inv-4, rgba(32, 54, 55, 0.79)) 100% + ); + + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.searchContent { + @apply px-3; + height: calc(14.5rem - 4rem); +} + +@keyframes contentHide { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-8px); + } +} diff --git a/pkgs/clan-app/ui/src/components/Search/Search.stories.tsx b/pkgs/clan-app/ui/src/components/Search/Search.stories.tsx new file mode 100644 index 000000000..aff30cb28 --- /dev/null +++ b/pkgs/clan-app/ui/src/components/Search/Search.stories.tsx @@ -0,0 +1,75 @@ +import { Meta, StoryObj } from "@kachurun/storybook-solid"; + +import { Search, SearchProps, Module } from "./Search"; + +const meta = { + title: "Components/Search", + component: Search, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +// To test the virtualizer, we can generate a list of modules +function generateModules(count: number): Module[] { + const greek = [ + "alpha", + "beta", + "gamma", + "delta", + "epsilon", + "zeta", + "eta", + "theta", + "iota", + "kappa", + "lambda", + "mu", + "nu", + "xi", + "omicron", + "pi", + "rho", + "sigma", + "tau", + "upsilon", + "phi", + "chi", + "psi", + "omega", + ]; + + const modules: Module[] = []; + + for (let i = 0; i < count; i++) { + modules.push({ + value: `lolcat/module-${i + 1}`, + name: `Module ${i + 1}`, + description: `${greek[i % greek.length]}#${i + 1}`, + input: "lolcat", + }); + } + + return modules; +} + +export const Default: Story = { + args: { + // Test with lots of modules + options: generateModules(1000), + }, + render: (args: SearchProps) => { + return ( +
+ { + // Go to the module configuration + console.log("Selected module:", module); + }} + /> +
+ ); + }, +}; diff --git a/pkgs/clan-app/ui/src/components/Search/Search.tsx b/pkgs/clan-app/ui/src/components/Search/Search.tsx new file mode 100644 index 000000000..16d289cb7 --- /dev/null +++ b/pkgs/clan-app/ui/src/components/Search/Search.tsx @@ -0,0 +1,210 @@ +import Icon from "../Icon/Icon"; +import { Button } from "../Button/Button"; +import styles from "./Search.module.css"; +import { Combobox } from "@kobalte/core/combobox"; +import { createMemo, createSignal, For } from "solid-js"; +import { Typography } from "../Typography/Typography"; +import { createVirtualizer } from "@tanstack/solid-virtual"; +import { CollectionNode } from "@kobalte/core/*"; + +export interface Module { + value: string; + name: string; + input: string; + description: string; +} + +export interface SearchProps { + onChange: (value: Module | null) => void; + options: Module[]; +} +export function Search(props: SearchProps) { + // Controlled input value, to allow resetting the input itself + const [value, setValue] = createSignal(null); + const [inputValue, setInputValue] = createSignal(""); + + let inputEl: HTMLInputElement; + + let listboxRef: HTMLUListElement; + + // const [currentItems, setItems] = createSignal(ALL_MODULES_OPTIONS_FLAT); + const [comboboxItems, setComboboxItems] = createSignal< + CollectionNode[] + >( + props.options.map((item) => ({ + rawValue: item, + })) as CollectionNode[], + ); + + // Create a reactive virtualizer that updates when items change + const virtualizer = createMemo(() => { + const items = comboboxItems(); + + const newVirtualizer = createVirtualizer({ + count: items.length, + getScrollElement: () => listboxRef, + getItemKey: (index) => { + const item = items[index]; + return item?.rawValue?.value || `item-${index}`; + }, + estimateSize: () => 42, + gap: 6, + overscan: 5, + }); + + return newVirtualizer; + }); + + return ( + + value={value()} + onChange={(value) => { + setValue(value); + setInputValue(value ? value.name : ""); + props.onChange(value); + }} + class={styles.searchContainer} + placement="bottom-start" + options={props.options} + optionValue="value" + optionTextValue="name" + optionLabel="name" + placeholder="Search a service" + sameWidth={true} + open={true} + gutter={7} + modal={false} + flip={false} + virtualized={true} + allowsEmptyCollection={true} + closeOnSelection={false} + triggerMode="manual" + noResetInputOnBlur={true} + > + class={styles.searchHeader}> + {(state) => ( +
+ + { + inputEl = el; + }} + class={styles.searchInput} + placeholder={"Search a service"} + value={inputValue()} + onChange={(e) => { + setInputValue(e.currentTarget.value); + }} + /> +
+ )} + + + + + ref={(el) => { + listboxRef = el; + }} + style={{ + height: "100%", + width: "100%", + overflow: "auto", + "overflow-y": "auto", + }} + scrollToItem={(key) => { + const idx = comboboxItems().findIndex( + (option) => option.rawValue.value === key, + ); + virtualizer().scrollToIndex(idx); + }} + > + {(items) => { + // Update the virtualizer with the filtered items + const arr = Array.from(items()); + setComboboxItems(arr); + + return ( +
+ + {(virtualRow) => { + const item: CollectionNode | undefined = + items().getItem(virtualRow.key as string); + + if (!item) { + console.warn("Item not found for key:", virtualRow.key); + return null; + } + return ( + +
+ +
+
+ + + {item.rawValue.name} + + + + {item.rawValue.description} + by {item.rawValue.input} + +
+
+ ); + }} +
+
+ ); + }} + +
+
+ + ); +}