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:
27
pkgs/clan-app/ui/package-lock.json
generated
27
pkgs/clan-app/ui/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
102
pkgs/clan-app/ui/src/components/Search/Search.module.css
Normal file
102
pkgs/clan-app/ui/src/components/Search/Search.module.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
pkgs/clan-app/ui/src/components/Search/Search.stories.tsx
Normal file
75
pkgs/clan-app/ui/src/components/Search/Search.stories.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
210
pkgs/clan-app/ui/src/components/Search/Search.tsx
Normal file
210
pkgs/clan-app/ui/src/components/Search/Search.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user