ui/search: remove portal, fix styling

This commit is contained in:
Johannes Kirschbauer
2025-08-28 10:09:41 +02:00
parent 789d326273
commit 640f15d55e
4 changed files with 170 additions and 167 deletions

View File

@@ -2,9 +2,11 @@ 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 { createMemo, createSignal, For, JSX, Match, Switch } from "solid-js";
import { createVirtualizer, VirtualizerOptions } from "@tanstack/solid-virtual";
import { CollectionNode } from "@kobalte/core/*";
import cx from "classnames";
import { Loader } from "../Loader/Loader";
export interface Option {
value: string;
@@ -23,6 +25,10 @@ export interface SearchMultipleProps<T> {
placeholder?: string;
virtualizerOptions?: Partial<VirtualizerOptions<Element, Element>>;
height: string; // e.g. '14.5rem'
headerClass?: string;
headerChildren?: JSX.Element;
loading?: boolean;
loadingComponent?: JSX.Element;
}
export function SearchMultiple<T extends Option>(
props: SearchMultipleProps<T>,
@@ -72,7 +78,6 @@ export function SearchMultiple<T extends Option>(
props.onChange(values);
}}
class={styles.searchContainer}
style={{ "--container-height": props.height }}
placement="bottom-start"
options={props.options}
optionValue="value"
@@ -89,69 +94,78 @@ export function SearchMultiple<T extends Option>(
triggerMode="manual"
noResetInputOnBlur={true}
>
<Combobox.Control<T> class={styles.searchHeader}>
<Combobox.Control<T>
class={cx(styles.searchHeader, props.headerClass || "bg-inv-3")}
>
{(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("");
<>
{props.headerChildren}
<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>
// 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);
<Combobox.Listbox<T>
ref={(el) => {
listboxRef = el;
}}
style={{
height: props.height,
width: "100%",
overflow: "auto",
"overflow-y": "auto",
}}
scrollToItem={(key) => {
const idx = comboboxItems().findIndex(
(option) => option.rawValue.value === key,
);
virtualizer().scrollToIndex(idx);
}}
class={styles.listbox}
>
{(items) => {
// Update the virtualizer with the filtered items
const arr = Array.from(items());
setComboboxItems(arr);
return (
return (
<Switch>
<Match when={props.loading}>
{props.loadingComponent ?? (
<div class="flex w-full justify-center py-2">
<Loader />
</div>
)}
</Match>
<Match when={!props.loading}>
<div
style={{
height: `${virtualizer().getTotalSize()}px`,
@@ -191,11 +205,12 @@ export function SearchMultiple<T extends Option>(
}}
</For>
</div>
);
}}
</Combobox.Listbox>
</Combobox.Content>
</Combobox.Portal>
</Match>
</Switch>
);
}}
</Combobox.Listbox>
{/* </Combobox.Content> */}
</Combobox>
);
}

View File

@@ -29,7 +29,7 @@
}
.searchHeader {
@apply bg-inv-3 flex gap-2 items-center p-2 rounded-md z-50;
@apply flex gap-2 items-center p-2 rounded-t-md z-50;
@apply px-3 pt-3 pb-2;
}
@@ -42,18 +42,21 @@
}
.searchItem {
@apply flex flex-col justify-center overflow-hidden;
box-shadow: 0 1px 0 0 theme(colors.border.inv.2);
&[data-highlighted],
&:focus,
&:focus-visible,
&:hover {
@apply bg-inv-acc-2;
@apply bg-inv-acc-2 rounded-md;
box-shadow: unset;
}
&:active {
@apply bg-inv-acc-3;
@apply bg-inv-acc-3 rounded-md;
box-shadow: unset;
}
@apply flex flex-col justify-center;
}
.searchContainer {
@@ -61,8 +64,6 @@
@apply rounded-lg;
height: var(--container-height, 14.5rem);
border: 1px solid #2b4647;
background:
@@ -78,9 +79,8 @@
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.searchContent {
@apply px-3;
height: calc(var(--container-height, 14.5rem) - 3.5rem);
.listbox {
@apply px-3 pt-3.5;
}
@keyframes contentHide {

View File

@@ -9,7 +9,7 @@ import {
SearchMultiple,
SearchMultipleProps,
} from "./MultipleSearch";
import { JSX, Show } from "solid-js";
import { Show } from "solid-js";
const meta = {
title: "Components/Search",
@@ -72,6 +72,7 @@ export interface Module {
export const Default: Story = {
args: {
height: "14.5rem",
// Test with lots of modules
options: generateModules(1000),
renderItem: (item: Module) => {
@@ -119,6 +120,7 @@ export const Default: Story = {
export const Loading: Story = {
args: {
height: "14.5rem",
// Test with lots of modules
loading: true,
options: [],
@@ -151,19 +153,6 @@ type MachineOrTag =
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" },

View File

@@ -6,6 +6,7 @@ import { createMemo, createSignal, For, JSX, Match, Switch } from "solid-js";
import { createVirtualizer } from "@tanstack/solid-virtual";
import { CollectionNode } from "@kobalte/core/*";
import { Loader } from "../Loader/Loader";
import cx from "classnames";
export interface Option {
value: string;
@@ -18,6 +19,8 @@ export interface SearchProps<T> {
renderItem: (item: T) => JSX.Element;
loading?: boolean;
loadingComponent?: JSX.Element;
headerClass?: string;
height: string; // e.g. '14.5rem'
}
export function Search<T extends Option>(props: SearchProps<T>) {
// Controlled input value, to allow resetting the input itself
@@ -80,7 +83,9 @@ export function Search<T extends Option>(props: SearchProps<T>) {
triggerMode="manual"
noResetInputOnBlur={true}
>
<Combobox.Control<T> class={styles.searchHeader}>
<Combobox.Control<T>
class={cx(styles.searchHeader, props.headerClass || "bg-inv-3")}
>
{(state) => (
<div class={styles.inputContainer}>
<Icon icon="Search" color="quaternary" />
@@ -114,85 +119,79 @@ export function Search<T extends Option>(props: SearchProps<T>) {
</div>
)}
</Combobox.Control>
<Combobox.Portal>
<Combobox.Content class={styles.searchContent} tabIndex={-1}>
<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);
<Combobox.Listbox<T>
ref={(el) => {
listboxRef = el;
}}
style={{
height: props.height,
width: "100%",
overflow: "auto",
"overflow-y": "auto",
}}
class={styles.listbox}
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 (
<Switch>
<Match when={props.loading}>
{props.loadingComponent ?? (
<div class="flex w-full justify-center py-2">
<Loader />
</div>
)}
</Match>
<Match when={!props.loading}>
<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);
return (
<Switch>
<Match when={props.loading}>
{props.loadingComponent ?? (
<div class="flex w-full justify-center py-2">
<Loader />
</div>
)}
</Match>
<Match when={!props.loading}>
<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;
}
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)}
</Combobox.Item>
);
}}
</For>
</div>
</Match>
</Switch>
);
}}
</Combobox.Listbox>
</Combobox.Content>
</Combobox.Portal>
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)`,
}}
>
{props.renderItem(item.rawValue)}
</Combobox.Item>
);
}}
</For>
</div>
</Match>
</Switch>
);
}}
</Combobox.Listbox>
</Combobox>
);
}