Merge pull request 'ui: add multiple search for machines and tags' (#4942) from search into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4942
This commit is contained in:
hsjobeki
2025-08-25 14:57:06 +00:00
4 changed files with 390 additions and 68 deletions

View File

@@ -0,0 +1,201 @@
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, JSX } from "solid-js";
import { createVirtualizer, VirtualizerOptions } from "@tanstack/solid-virtual";
import { CollectionNode } from "@kobalte/core/*";
export interface Option {
value: string;
label: string;
}
export interface ItemRenderOptions {
selected: boolean;
}
export interface SearchMultipleProps<T> {
onChange: (values: T[]) => void;
options: T[];
renderItem: (item: T, opts: ItemRenderOptions) => JSX.Element;
initialValues?: T[];
placeholder?: string;
virtualizerOptions?: Partial<VirtualizerOptions<Element, Element>>;
height: string; // e.g. '14.5rem'
}
export function SearchMultiple<T extends Option>(
props: SearchMultipleProps<T>,
) {
// Controlled input value, to allow resetting the input itself
const [values, setValues] = createSignal<T[]>(props.initialValues || []);
const [inputValue, setInputValue] = createSignal<string>("");
let inputEl: HTMLInputElement;
let listboxRef: HTMLUListElement;
// const [currentItems, setItems] = createSignal(ALL_MODULES_OPTIONS_FLAT);
const [comboboxItems, setComboboxItems] = createSignal<CollectionNode<T>[]>(
props.options.map((item) => ({
rawValue: item,
})) as CollectionNode<T>[],
);
// 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,
...props.virtualizerOptions,
});
return newVirtualizer;
});
return (
<Combobox<T>
multiple
value={values()}
onChange={(values) => {
setValues(() => values);
// setInputValue(value ? value.label : "");
props.onChange(values);
}}
class={styles.searchContainer}
style={{ "--container-height": props.height }}
placement="bottom-start"
options={props.options}
optionValue="value"
optionTextValue="label"
optionLabel="label"
sameWidth={true}
open={true}
gutter={7}
modal={false}
flip={false}
virtualized={true}
allowsEmptyCollection={true}
closeOnSelection={false}
triggerMode="manual"
noResetInputOnBlur={true}
>
<Combobox.Control<T> class={styles.searchHeader}>
{(state) => (
<div class={styles.inputContainer}>
<Icon icon="Search" color="quaternary" />
<Combobox.Input
ref={(el) => {
inputEl = el;
}}
class={styles.searchInput}
placeholder={props.placeholder}
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}
style={{ "--container-height": props.height }}
>
<Combobox.Listbox<T>
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<T> | undefined =
items().getItem(virtualRow.key as string);
if (!item) {
console.warn("Item not found for key:", virtualRow.key);
return null;
}
const isSelected = () =>
values().some((v) => v.value === item.rawValue.value);
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)`,
}}
>
{props.renderItem(item.rawValue, {
selected: isSelected(),
})}
</Combobox.Item>
);
}}
</For>
</div>
);
}}
</Combobox.Listbox>
</Combobox.Content>
</Combobox.Portal>
</Combobox>
);
}

View File

@@ -42,17 +42,6 @@
}
.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,
@@ -63,12 +52,16 @@
&:active {
@apply bg-inv-acc-3;
}
@apply flex flex-col justify-center;
}
.searchContainer {
@apply bg-gradient-to-b from-bg-inv-3 to-bg-inv-4;
@apply h-[14.5rem] rounded-lg;
@apply rounded-lg;
height: var(--container-height, 14.5rem);
border: 1px solid #2b4647;
@@ -87,7 +80,8 @@
.searchContent {
@apply px-3;
height: calc(14.5rem - 4rem);
height: var(--container-height, 14.5rem);
padding-bottom: 4rem;
}
@keyframes contentHide {

View File

@@ -1,15 +1,24 @@
import { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Search, SearchProps, Module } from "./Search";
import { Search, SearchProps } from "./Search";
import Icon from "../Icon/Icon";
import { Combobox } from "@kobalte/core/combobox";
import { Typography } from "../Typography/Typography";
import {
ItemRenderOptions,
SearchMultiple,
SearchMultipleProps,
} from "./MultipleSearch";
import { JSX, Show } from "solid-js";
const meta = {
title: "Components/Search",
component: Search,
} satisfies Meta<SearchProps>;
} satisfies Meta<SearchProps<unknown>>;
export default meta;
type Story = StoryObj<SearchProps>;
type Story = StoryObj<SearchProps<unknown>>;
// To test the virtualizer, we can generate a list of modules
function generateModules(count: number): Module[] {
@@ -45,7 +54,7 @@ function generateModules(count: number): Module[] {
for (let i = 0; i < count; i++) {
modules.push({
value: `lolcat/module-${i + 1}`,
name: `Module ${i + 1}`,
label: `Module ${i + 1}`,
description: `${greek[i % greek.length]}#${i + 1}`,
input: "lolcat",
});
@@ -54,15 +63,49 @@ function generateModules(count: number): Module[] {
return modules;
}
export interface Module {
value: string;
label: string;
input: string;
description: string;
}
export const Default: Story = {
args: {
// Test with lots of modules
options: generateModules(1000),
renderItem: (item: Module) => {
return (
<div class="flex items-center justify-between gap-2 rounded-md px-2 py-1 pr-4">
<div class="flex size-8 items-center justify-center rounded-md bg-white">
<Icon icon="Code" />
</div>
<div class="flex w-full flex-col">
<Combobox.ItemLabel class="flex">
<Typography hierarchy="body" size="s" weight="medium" inverted>
{item.label}
</Typography>
</Combobox.ItemLabel>
<Typography
hierarchy="body"
size="xxs"
weight="normal"
color="quaternary"
inverted
class="flex justify-between"
>
<span>{item.description}</span>
<span>by {item.input}</span>
</Typography>
</div>
</div>
);
},
},
render: (args: SearchProps) => {
render: (args: SearchProps<Module>) => {
return (
<div class="absolute bottom-1/3 w-3/4 px-3">
<Search
<Search<Module>
{...args}
onChange={(module) => {
// Go to the module configuration
@@ -73,3 +116,116 @@ export const Default: Story = {
);
},
};
type MachineOrTag =
| {
value: string;
label: string;
type: "machine";
}
| {
members: string[];
value: string;
label: string;
type: "tag";
};
interface WrapIfProps {
condition: boolean;
wrapper: (children: JSX.Element) => JSX.Element;
children: JSX.Element;
}
const WrapIf = (props: WrapIfProps) => {
if (props.condition) {
return props.wrapper(props.children);
} else {
return props.children;
}
};
const machinesAndTags: MachineOrTag[] = [
{ value: "machine-1", label: "Machine 1", type: "machine" },
{ value: "machine-2", label: "Machine 2", type: "machine" },
{
value: "all",
label: "All",
type: "tag",
members: ["machine-1", "machine-2", "machine-3", "machine-4", "machine-5"],
},
{
value: "tag-1",
label: "Tag 1",
type: "tag",
members: ["machine-1", "machine-2"],
},
{ value: "machine-3", label: "Machine 3", type: "machine" },
{ value: "machine-4", label: "Machine 4", type: "machine" },
{ value: "machine-5", label: "Machine 5", type: "machine" },
{
value: "tag-2",
label: "Tag 2",
type: "tag",
members: ["machine-3", "machine-4", "machine-5"],
},
];
export const Multiple: Story = {
args: {
// Test with lots of modules
options: machinesAndTags,
placeholder: "Search for Machine or Tags",
renderItem: (item: MachineOrTag, opts: ItemRenderOptions) => {
console.log("Rendering item:", item, "opts", opts);
return (
<div class="flex w-full items-center gap-2 px-3 py-2">
<Combobox.ItemIndicator>
<Show when={opts.selected} fallback={<Icon icon="Code" />}>
<Icon icon="Checkmark" color="primary" inverted />
</Show>
</Combobox.ItemIndicator>
<Combobox.ItemLabel class="flex items-center gap-2">
<Typography hierarchy="body" size="s" weight="medium" inverted>
{item.label}
</Typography>
<Show when={item.type === "tag" && item}>
{(tag) => (
<Typography
hierarchy="body"
size="xs"
weight="medium"
inverted
color="secondary"
tag="div"
>
{tag().members.length}
</Typography>
)}
</Show>
</Combobox.ItemLabel>
<Icon
class="ml-auto"
icon={item.type === "machine" ? "Machine" : "Tag"}
color="quaternary"
inverted
/>
</div>
);
},
},
render: (args: SearchMultipleProps<MachineOrTag>) => {
return (
<div class="absolute bottom-1/3 w-3/4 px-3">
<SearchMultiple<MachineOrTag>
{...args}
height="20rem"
virtualizerOptions={{
estimateSize: () => 38,
}}
onChange={(selection) => {
// Go to the module configuration
console.log("Currently Selected:", selection);
}}
/>
</div>
);
},
};

View File

@@ -2,25 +2,23 @@ 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 { createMemo, createSignal, For, JSX } from "solid-js";
import { createVirtualizer } from "@tanstack/solid-virtual";
import { CollectionNode } from "@kobalte/core/*";
export interface Module {
export interface Option {
value: string;
name: string;
input: string;
description: string;
label: string;
}
export interface SearchProps {
onChange: (value: Module | null) => void;
options: Module[];
export interface SearchProps<T> {
onChange: (value: T | null) => void;
options: T[];
renderItem: (item: T) => JSX.Element;
}
export function Search(props: SearchProps) {
export function Search<T extends Option>(props: SearchProps<T>) {
// Controlled input value, to allow resetting the input itself
const [value, setValue] = createSignal<Module | null>(null);
const [value, setValue] = createSignal<T | null>(null);
const [inputValue, setInputValue] = createSignal<string>("");
let inputEl: HTMLInputElement;
@@ -28,12 +26,10 @@ export function Search(props: SearchProps) {
let listboxRef: HTMLUListElement;
// const [currentItems, setItems] = createSignal(ALL_MODULES_OPTIONS_FLAT);
const [comboboxItems, setComboboxItems] = createSignal<
CollectionNode<Module>[]
>(
const [comboboxItems, setComboboxItems] = createSignal<CollectionNode<T>[]>(
props.options.map((item) => ({
rawValue: item,
})) as CollectionNode<Module>[],
})) as CollectionNode<T>[],
);
// Create a reactive virtualizer that updates when items change
@@ -56,19 +52,19 @@ export function Search(props: SearchProps) {
});
return (
<Combobox<Module>
<Combobox<T>
value={value()}
onChange={(value) => {
setValue(value);
setInputValue(value ? value.name : "");
setValue(() => value);
setInputValue(value ? value.label : "");
props.onChange(value);
}}
class={styles.searchContainer}
placement="bottom-start"
options={props.options}
optionValue="value"
optionTextValue="name"
optionLabel="name"
optionTextValue="label"
optionLabel="label"
placeholder="Search a service"
sameWidth={true}
open={true}
@@ -81,7 +77,7 @@ export function Search(props: SearchProps) {
triggerMode="manual"
noResetInputOnBlur={true}
>
<Combobox.Control<Module> class={styles.searchHeader}>
<Combobox.Control<T> class={styles.searchHeader}>
{(state) => (
<div class={styles.inputContainer}>
<Icon icon="Search" color="quaternary" />
@@ -117,7 +113,7 @@ export function Search(props: SearchProps) {
</Combobox.Control>
<Combobox.Portal>
<Combobox.Content class={styles.searchContent} tabIndex={-1}>
<Combobox.Listbox<Module>
<Combobox.Listbox<T>
ref={(el) => {
listboxRef = el;
}}
@@ -149,7 +145,7 @@ export function Search(props: SearchProps) {
>
<For each={virtualizer().getVirtualItems()}>
{(virtualRow) => {
const item: CollectionNode<Module> | undefined =
const item: CollectionNode<T> | undefined =
items().getItem(virtualRow.key as string);
if (!item) {
@@ -169,32 +165,7 @@ export function Search(props: SearchProps) {
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>
{props.renderItem(item.rawValue)}
</Combobox.Item>
);
}}