Merge pull request 'ui/search: add search with virtualized scrolling' (#4884) from ui-search into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4884
This commit is contained in:
hsjobeki
2025-08-22 16:07:54 +00:00
5 changed files with 415 additions and 0 deletions

View File

@@ -19,6 +19,7 @@
"@tanstack/solid-query": "^5.85.5", "@tanstack/solid-query": "^5.85.5",
"@tanstack/solid-query-devtools": "^5.85.5", "@tanstack/solid-query-devtools": "^5.85.5",
"@tanstack/solid-query-persist-client": "^5.85.5", "@tanstack/solid-query-persist-client": "^5.85.5",
"@tanstack/solid-virtual": "^3.13.12",
"solid-js": "^1.9.7", "solid-js": "^1.9.7",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"three": "^0.176.0", "three": "^0.176.0",
@@ -2745,6 +2746,32 @@
"solid-js": "^1.6.0" "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": { "node_modules/@testing-library/dom": {
"version": "10.4.0", "version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",

View File

@@ -76,6 +76,7 @@
"@tanstack/solid-query": "^5.85.5", "@tanstack/solid-query": "^5.85.5",
"@tanstack/solid-query-devtools": "^5.85.5", "@tanstack/solid-query-devtools": "^5.85.5",
"@tanstack/solid-query-persist-client": "^5.85.5", "@tanstack/solid-query-persist-client": "^5.85.5",
"@tanstack/solid-virtual": "^3.13.12",
"solid-js": "^1.9.7", "solid-js": "^1.9.7",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"three": "^0.176.0", "three": "^0.176.0",

View File

@@ -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);
}
}

View File

@@ -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<SearchProps>;
export default meta;
type Story = StoryObj<SearchProps>;
// 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 (
<div class="absolute bottom-1/3 w-3/4 px-3">
<Search
{...args}
onChange={(module) => {
// Go to the module configuration
console.log("Selected module:", module);
}}
/>
</div>
);
},
};

View File

@@ -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<Module | null>(null);
const [inputValue, setInputValue] = createSignal<string>("");
let inputEl: HTMLInputElement;
let listboxRef: HTMLUListElement;
// const [currentItems, setItems] = createSignal(ALL_MODULES_OPTIONS_FLAT);
const [comboboxItems, setComboboxItems] = createSignal<
CollectionNode<Module>[]
>(
props.options.map((item) => ({
rawValue: item,
})) as CollectionNode<Module>[],
);
// 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 (
<Combobox<Module>
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}
>
<Combobox.Control<Module> class={styles.searchHeader}>
{(state) => (
<div class={styles.inputContainer}>
<Icon icon="Search" color="quaternary" />
<Combobox.Input
ref={(el) => {
inputEl = el;
}}
class={styles.searchInput}
placeholder={"Search a service"}
value={inputValue()}
onChange={(e) => {
setInputValue(e.currentTarget.value);
}}
/>
<Button
type="reset"
hierarchy="primary"
size="s"
ghost
icon="CloseCircle"
onClick={() => {
state.clear();
setInputValue("");
// Dispatch an input event to notify combobox listeners
inputEl.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true }),
);
}}
/>
</div>
)}
</Combobox.Control>
<Combobox.Portal>
<Combobox.Content class={styles.searchContent} tabIndex={-1}>
<Combobox.Listbox<Module>
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 (
<div
style={{
height: `${virtualizer().getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
<For each={virtualizer().getVirtualItems()}>
{(virtualRow) => {
const item: CollectionNode<Module> | undefined =
items().getItem(virtualRow.key as string);
if (!item) {
console.warn("Item not found for key:", virtualRow.key);
return null;
}
return (
<Combobox.Item
item={item}
class={styles.searchItem}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div role="complementary">
<Icon icon="Code" />
</div>
<div role="option">
<Combobox.ItemLabel class="flex">
<Typography
hierarchy="body"
size="s"
weight="medium"
inverted
>
{item.rawValue.name}
</Typography>
</Combobox.ItemLabel>
<Typography
hierarchy="body"
size="xxs"
weight="normal"
color="quaternary"
inverted
class="flex justify-between"
>
<span>{item.rawValue.description}</span>
<span>by {item.rawValue.input}</span>
</Typography>
</div>
</Combobox.Item>
);
}}
</For>
</div>
);
}}
</Combobox.Listbox>
</Combobox.Content>
</Combobox.Portal>
</Combobox>
);
}