ui: add multiple search for machines and tags
This commit is contained in:
201
pkgs/clan-app/ui/src/components/Search/MultipleSearch.tsx
Normal file
201
pkgs/clan-app/ui/src/components/Search/MultipleSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user