From 8822f6dadc8ac66513726861fffa9d1518764dcd Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Mon, 25 Aug 2025 16:53:49 +0200 Subject: [PATCH] ui: add multiple search for machines and tags --- .../src/components/Search/MultipleSearch.tsx | 201 ++++++++++++++++++ .../src/components/Search/Search.module.css | 20 +- .../src/components/Search/Search.stories.tsx | 168 ++++++++++++++- .../ui/src/components/Search/Search.tsx | 69 ++---- 4 files changed, 390 insertions(+), 68 deletions(-) create mode 100644 pkgs/clan-app/ui/src/components/Search/MultipleSearch.tsx diff --git a/pkgs/clan-app/ui/src/components/Search/MultipleSearch.tsx b/pkgs/clan-app/ui/src/components/Search/MultipleSearch.tsx new file mode 100644 index 000000000..b7945962f --- /dev/null +++ b/pkgs/clan-app/ui/src/components/Search/MultipleSearch.tsx @@ -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 { + onChange: (values: T[]) => void; + options: T[]; + renderItem: (item: T, opts: ItemRenderOptions) => JSX.Element; + initialValues?: T[]; + placeholder?: string; + virtualizerOptions?: Partial>; + height: string; // e.g. '14.5rem' +} +export function SearchMultiple( + props: SearchMultipleProps, +) { + // Controlled input value, to allow resetting the input itself + const [values, setValues] = createSignal(props.initialValues || []); + const [inputValue, setInputValue] = createSignal(""); + + let inputEl: HTMLInputElement; + + let listboxRef: HTMLUListElement; + + // const [currentItems, setItems] = createSignal(ALL_MODULES_OPTIONS_FLAT); + const [comboboxItems, setComboboxItems] = createSignal[]>( + 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, + ...props.virtualizerOptions, + }); + + return newVirtualizer; + }); + + return ( + + 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} + > + class={styles.searchHeader}> + {(state) => ( +
+ + { + inputEl = el; + }} + class={styles.searchInput} + placeholder={props.placeholder} + 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; + } + const isSelected = () => + values().some((v) => v.value === item.rawValue.value); + return ( + + {props.renderItem(item.rawValue, { + selected: isSelected(), + })} + + ); + }} + +
+ ); + }} + +
+
+ + ); +} diff --git a/pkgs/clan-app/ui/src/components/Search/Search.module.css b/pkgs/clan-app/ui/src/components/Search/Search.module.css index 4b5c4dbee..11692c9e2 100644 --- a/pkgs/clan-app/ui/src/components/Search/Search.module.css +++ b/pkgs/clan-app/ui/src/components/Search/Search.module.css @@ -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 { diff --git a/pkgs/clan-app/ui/src/components/Search/Search.stories.tsx b/pkgs/clan-app/ui/src/components/Search/Search.stories.tsx index aff30cb28..c466280ea 100644 --- a/pkgs/clan-app/ui/src/components/Search/Search.stories.tsx +++ b/pkgs/clan-app/ui/src/components/Search/Search.stories.tsx @@ -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; +} satisfies Meta>; export default meta; -type Story = StoryObj; +type Story = StoryObj>; // 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 ( +
+
+ +
+
+ + + {item.label} + + + + {item.description} + by {item.input} + +
+
+ ); + }, }, - render: (args: SearchProps) => { + render: (args: SearchProps) => { return (
- {...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 ( +
+ + }> + + + + + + {item.label} + + + {(tag) => ( + + {tag().members.length} + + )} + + + +
+ ); + }, + }, + render: (args: SearchMultipleProps) => { + return ( +
+ + {...args} + height="20rem" + virtualizerOptions={{ + estimateSize: () => 38, + }} + onChange={(selection) => { + // Go to the module configuration + console.log("Currently Selected:", selection); + }} + /> +
+ ); + }, +}; diff --git a/pkgs/clan-app/ui/src/components/Search/Search.tsx b/pkgs/clan-app/ui/src/components/Search/Search.tsx index 16d289cb7..3b09c6604 100644 --- a/pkgs/clan-app/ui/src/components/Search/Search.tsx +++ b/pkgs/clan-app/ui/src/components/Search/Search.tsx @@ -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 { + onChange: (value: T | null) => void; + options: T[]; + renderItem: (item: T) => JSX.Element; } -export function Search(props: SearchProps) { +export function Search(props: SearchProps) { // Controlled input value, to allow resetting the input itself - const [value, setValue] = createSignal(null); + const [value, setValue] = createSignal(null); const [inputValue, setInputValue] = createSignal(""); 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[] - >( + const [comboboxItems, setComboboxItems] = createSignal[]>( props.options.map((item) => ({ rawValue: item, - })) as CollectionNode[], + })) as CollectionNode[], ); // Create a reactive virtualizer that updates when items change @@ -56,19 +52,19 @@ export function Search(props: SearchProps) { }); return ( - + 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} > - class={styles.searchHeader}> + class={styles.searchHeader}> {(state) => (
@@ -117,7 +113,7 @@ export function Search(props: SearchProps) { - + ref={(el) => { listboxRef = el; }} @@ -149,7 +145,7 @@ export function Search(props: SearchProps) { > {(virtualRow) => { - const item: CollectionNode | undefined = + const item: CollectionNode | undefined = items().getItem(virtualRow.key as string); if (!item) { @@ -169,32 +165,7 @@ export function Search(props: SearchProps) { transform: `translateY(${virtualRow.start}px)`, }} > -
- -
-
- - - {item.rawValue.name} - - - - {item.rawValue.description} - by {item.rawValue.input} - -
+ {props.renderItem(item.rawValue)} ); }}