Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add virtualization to combobox #169

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/(app)/hierarchy/_components/direction-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,22 @@ export function DirectionSelect(props: DirectionSelectProps): ReactNode {

<Button className="inline-flex w-full items-center justify-between gap-x-2.5 rounded-md border border-brand-100 bg-white py-2.5 pl-4 pr-3 leading-none disabled:cursor-not-allowed sm:min-w-40">
<SelectValue className="placeholder-shown:text-neutral-500" />
<ChevronDownIcon aria-hidden={true} className="size-5 shrink-0 text-brand-500" />
<ChevronDownIcon aria-hidden={true} className="size-4 shrink-0 text-brand-500" />
</Button>

<Popover className="absolute z-10 w-[var(--trigger-width)] overflow-hidden rounded-md border border-neutral-200 bg-white text-brand-900 shadow-lg animate-in fade-in slide-in-from-top-2">
<ListBox className="py-2 outline-none" items={options}>
{(item) => {
return (
<ListBoxItem
className="relative flex w-full select-none items-center rounded-md px-8 py-2 hover:bg-brand-50 hover:outline-none selected:bg-brand-50 disabled:pointer-events-none disabled:text-neutral-500"
className="relative flex w-full select-none items-center rounded-md pl-9 pr-6 py-1.5 hover:bg-brand-50 hover:outline-none selected:bg-brand-50 disabled:pointer-events-none disabled:text-neutral-500"
textValue={item.label}
>
{({ isSelected }) => {
return (
<Fragment>
{isSelected ? (
<span className="absolute left-2 inline-flex items-center justify-center">
<span className="absolute left-3 inline-flex items-center justify-center">
<CheckIcon
aria-hidden={true}
className="size-4 shrink-0 text-brand-600"
Expand Down
125 changes: 46 additions & 79 deletions app/(app)/hierarchy/_components/entity-combobox.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { Fragment, type ReactNode, useMemo } from "react";
import {
Button,
ComboBox,
Expand All @@ -11,7 +11,8 @@ import {
ListBox,
ListBoxItem,
Popover,
useFilter,
UNSTABLE_ListLayout as ListLayout,
UNSTABLE_Virtualizer as Virtualizer,
} from "react-aria-components";

import type { AutocompleteItem } from "@/app/(app)/hierarchy/_lib/autocomplete";
Expand All @@ -30,105 +31,71 @@ export function EntityComboBox(props: EntityComboBoxProps): ReactNode {
const { selectedKey, label, legendLabels, name, onSelectionChange, options, triggerLabel } =
props;

const getLabel = useCallback(
function getLabel(id: number | null): string {
if (id == null) return "";
return options.get(id)?.label ?? "";
},
[options],
);

const [searchTerm, setSearchTerm] = useState(getLabel(selectedKey));
const [menuTrigger, setMenuTrigger] = useState<"focus" | "input" | "manual" | null>(null);
// eslint-disable-next-line @typescript-eslint/unbound-method
const { contains } = useFilter({ sensitivity: "base" });

// FIXME: autocomplete should filter server-side, i.e. in typesense
const filteredOptions = useMemo(() => {
const filteredOptions = [];

for (const option of options.values()) {
if (contains(option.label, searchTerm)) {
filteredOptions.push(option);
if (filteredOptions.length === 10) break;
}
}

return filteredOptions;
}, [options, searchTerm, contains]);
const allOptions = useMemo(() => {
return Array.from(options.values()).slice(0, 2500);
}, [options]);

/**
* When a user clicks a node in the visualisation, the selectedKey will change and we need to
* sync the input value of the controlled combobox.
*/
useEffect(() => {
setSearchTerm(getLabel(selectedKey));
}, [selectedKey, getLabel]);
const layout = useMemo(() => {
return new ListLayout({ rowHeight: 52 });
}, []);

return (
<ComboBox
allowsCustomValue={false}
className="grid gap-y-1"
inputValue={searchTerm}
items={menuTrigger === "manual" ? Array.from(options.values()).slice(0, 10) : filteredOptions}
defaultItems={allOptions}
name={name}
onInputChange={(searchTerm) => {
setSearchTerm(searchTerm);
setMenuTrigger("input");
}}
onOpenChange={(_isOpen, menuTrigger) => {
setMenuTrigger(menuTrigger ?? null);
}}
onSelectionChange={(id: Key | null) => {
onSelectionChange(id);
setSearchTerm(getLabel(id as number | null));
}}
onSelectionChange={onSelectionChange}
selectedKey={selectedKey}
>
<Label className="cursor-default text-xs font-bold uppercase tracking-wider text-brand-600">
{label}
</Label>

<div className="relative inline-flex w-full items-center justify-between overflow-hidden rounded-md border border-brand-100 bg-white has-[disabled]:cursor-not-allowed sm:min-w-96">
<Input className="flex-1 py-2.5 pl-4 pr-9 leading-none outline-none disabled:pointer-events-none" />
<Input className="flex-1 h-9 py-2.5 pl-4 pr-9 leading-none outline-none disabled:pointer-events-none" />
<Button
aria-label={triggerLabel}
className="absolute inset-y-0 right-2 disabled:pointer-events-none"
>
<ChevronsUpDownIcon aria-hidden={true} className="size-5 shrink-0 text-brand-500" />
<ChevronsUpDownIcon aria-hidden={true} className="size-4 shrink-0 text-brand-500" />
</Button>
</div>

<Popover className="absolute z-10 w-[var(--trigger-width)] overflow-hidden rounded-md border border-neutral-200 bg-white text-brand-900 shadow-lg animate-in fade-in slide-in-from-top-2">
<ListBox className="max-h-[32rem] overflow-y-auto py-2 outline-none">
{(item: AutocompleteItem) => {
return (
<ListBoxItem
className="relative flex w-full select-none items-center rounded-md px-8 py-2 hover:bg-brand-50 hover:outline-none selected:bg-brand-50 disabled:pointer-events-none disabled:text-neutral-500"
textValue={item.label}
>
{({ isSelected }) => {
return (
<Fragment>
{isSelected ? (
<span className="absolute left-2 inline-flex items-center justify-center">
<CheckIcon
aria-hidden={true}
className="size-4 shrink-0 text-brand-600"
/>
</span>
) : null}
<div className="grid">
<span>{item.label}</span>
<span className="text-xs text-neutral-600">{legendLabels[item.kind]}</span>
</div>
</Fragment>
);
}}
</ListBoxItem>
);
}}
</ListBox>
<Virtualizer layout={layout}>
<ListBox className="max-h-[32rem] overflow-y-auto py-2 outline-none">
{(item: AutocompleteItem) => {
return (
<ListBoxItem
className="relative flex w-full select-none items-center rounded-md pl-9 pr-6 py-1.5 hover:bg-brand-50 hover:outline-none selected:bg-brand-50 disabled:pointer-events-none disabled:text-neutral-500"
textValue={item.label}
>
{({ isSelected }) => {
return (
<Fragment>
{isSelected ? (
<span className="absolute left-3 inline-flex items-center justify-center">
<CheckIcon
aria-hidden={true}
className="size-4 shrink-0 text-brand-600"
/>
</span>
) : null}
<div className="grid">
<span className="truncate">{item.label}</span>
<span className="text-xs text-neutral-600">
{legendLabels[item.kind]}
</span>
</div>
</Fragment>
);
}}
</ListBoxItem>
);
}}
</ListBox>
</Virtualizer>
</Popover>
</ComboBox>
);
Expand Down
6 changes: 3 additions & 3 deletions app/(app)/hierarchy/_components/graph-type-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,22 @@ export function GraphTypeSelect(props: GraphTypeSelectProps): ReactNode {

<Button className="inline-flex w-full items-center justify-between gap-x-2.5 rounded-md border border-brand-100 bg-white py-2.5 pl-4 pr-3 leading-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-w-96">
<SelectValue className="placeholder-shown:text-neutral-500" />
<ChevronDownIcon aria-hidden={true} className="size-5 shrink-0 text-brand-500" />
<ChevronDownIcon aria-hidden={true} className="size-4 shrink-0 text-brand-500" />
</Button>

<Popover className="absolute z-10 w-[var(--trigger-width)] overflow-hidden rounded-md border border-neutral-200 bg-white text-brand-900 shadow-lg animate-in fade-in slide-in-from-top-2">
<ListBox className="py-2 outline-none" items={options}>
{(item) => {
return (
<ListBoxItem
className="relative flex w-full select-none items-center rounded-md px-8 py-2 hover:bg-brand-50 hover:outline-none selected:bg-brand-50 disabled:pointer-events-none disabled:text-neutral-500"
className="relative flex w-full select-none items-center rounded-md pl-9 pr-6 py-1.5 hover:bg-brand-50 hover:outline-none selected:bg-brand-50 disabled:pointer-events-none disabled:text-neutral-500"
textValue={item.label}
>
{({ isSelected }) => {
return (
<Fragment>
{isSelected ? (
<span className="absolute left-2 inline-flex items-center justify-center">
<span className="absolute left-3 inline-flex items-center justify-center">
<CheckIcon
aria-hidden={true}
className="size-4 shrink-0 text-brand-600"
Expand Down
2 changes: 1 addition & 1 deletion app/(app)/hierarchy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ async function SearchPanel(props: SearchPanelProps): Promise<ReactNode> {

return (
<aside className="border-b border-brand-100 bg-brand-50 text-brand-900">
<div className="container flex flex-col flex-wrap gap-x-6 gap-y-4 p-4 xs:px-8 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col flex-wrap gap-x-6 gap-y-4 p-4 xs:px-8 sm:flex-row sm:items-center sm:justify-between">
<SearchFilterPanel
directionOptions={directionOptions}
directionSelectLabel={t("direction.label")}
Expand Down
Loading