Pattern gallery

Searchable multi-select attach list

A search box over a browsable list, a running count, and one submit scoped to what's checked.

What this solves

Ease of use for the "pick from what already exists" half of an attach flow: the whole library stays visible and checkable as you narrow it, so picking five files out of eighty is a scan-and-click, not five separate lookups.
Use it when
  • Attaching existing files to a job, before or alongside uploading new onesspeedway NewJobForm.tsx: the picker sits under the dropzone once files exist
  • Attaching existing records to a catalog from a shared librarywalmart-mvp SourceFilePicker.tsx: a modal's entire body, search over a table

Rendered

Fixture data; check both themes.
3 selected
patterns/searchable-attach-list/example.tsxCopy this into a fresh route and it renders as above.
"use client";
import { Button, EmptyState, Input, pushAlert, SkeletonGroup } from "@versable-git/ui";
import { useEffect, useState } from "react";
// A catalog's source-file library, standing in for whatever record type a
// real attach flow picks from (speedway: uploaded files; walmart: catalog
// source files). Three are pre-checked so "some selected" is visible without
// typing or clicking anything first.
interface AttachableFile {
id: string;
filename: string;
type: "CSV" | "XLSX" | "XML" | "JSON";
rows: number;
}
const FILES: AttachableFile[] = [
{ id: "f1", filename: "fitment-2024.csv", type: "CSV", rows: 1204 },
{ id: "f2", filename: "pricing-update.xlsx", type: "XLSX", rows: 340 },
{ id: "f3", filename: "vendor-catalog.xml", type: "XML", rows: 5610 },
{ id: "f4", filename: "warehouse-inventory.csv", type: "CSV", rows: 892 },
{ id: "f5", filename: "supplier-mapping.json", type: "JSON", rows: 76 },
{ id: "f6", filename: "brake-pads-spec.xlsx", type: "XLSX", rows: 58 },
{ id: "f7", filename: "tire-sizes.csv", type: "CSV", rows: 214 },
{ id: "f8", filename: "oil-filters-q3.xlsx", type: "XLSX", rows: 130 },
];
// Fixture request standing in for the real attach call. After
// walmart-mvp/frontend/src/features/catalog/SourceFilePicker.tsx:60-63:
// catalogApi.attachSourceFiles(catalogId, ids), then onAttached refreshes
// the parent record. Here that refresh is "mark these rows attached."
function attachFiles(ids: string[]): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 900));
}
export function SearchableAttachList() {
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set(["f2", "f5", "f7"]));
const [attached, setAttached] = useState<Set<string>>(new Set());
useEffect(() => {
const t = setTimeout(() => setLoading(false), 700);
return () => clearTimeout(t);
}, []);
const filtered = FILES.filter((f) => f.filename.toLowerCase().includes(query.toLowerCase()));
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function handleAttach() {
const ids = Array.from(selected);
if (ids.length === 0) return;
try {
// After NewJobForm.tsx / SourceFilePicker.tsx: the count drives both
// the button label and the toast text, never a second confirm step.
await pushAlert.promise(attachFiles(ids), {
pending: `Attaching ${ids.length} ${ids.length === 1 ? "file" : "files"}`,
success: `${ids.length} ${ids.length === 1 ? "file" : "files"} attached.`,
error: "Something went wrong. Try again.",
});
setAttached((prev) => new Set([...prev, ...ids]));
setSelected(new Set());
} catch {
// the promise toast already showed the error
}
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Input
aria-label="Search files"
presets="search"
size="sm"
placeholder={`Search ${FILES.length} files`}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
fullWidth
/>
{selected.size > 0 && (
<span className="text-base-content/65 shrink-0 text-xs">{selected.size} selected</span>
)}
</div>
{loading ? (
<SkeletonGroup preset="lines" lines={5} stagger />
) : filtered.length === 0 ? (
<EmptyState
compact
Icon="Search"
title={`No files match "${query}"`}
description="Try a different name, or clear the search."
action={<Button size="sm" variant="ghost" content="Clear search" onClick={() => setQuery("")} />}
/>
) : (
<ul className="border-base-300 divide-base-200 max-h-64 min-h-0 divide-y overflow-y-auto rounded-lg border">
{filtered.map((f) => {
const isAttached = attached.has(f.id);
const isChecked = isAttached || selected.has(f.id);
return (
<li
key={f.id}
className={`flex items-center justify-between gap-3 px-3 py-2 ${
isChecked && !isAttached ? "bg-primary/5" : ""
}`}
>
<Input
element="checkbox"
size="sm"
checked={isChecked}
disabled={isAttached}
onChange={() => toggle(f.id)}
after={
<span className="flex min-w-0 flex-col">
<span className="mono truncate text-sm">{f.filename}</span>
<span className="text-base-content/65 text-xs">
{f.type} · {f.rows.toLocaleString()} rows
</span>
</span>
}
/>
{isAttached && (
<span className="text-base-content/45 shrink-0 text-xs font-medium tracking-wide uppercase">
Attached
</span>
)}
</li>
);
})}
</ul>
)}
<div className="flex justify-end">
<Button
size="sm"
color="primary"
content={selected.size > 0 ? `Attach ${selected.size}` : "Attach"}
disabled={selected.size === 0}
onClick={handleAttach}
/>
</div>
</div>
);
}

Where it ships

  • speedway/app/components/NewJobForm.tsx:576-592search Input, a "N selected" count shown only once something is picked, then a scrollable checkbox list
  • walmart-mvp/frontend/src/features/catalog/SourceFilePicker.tsx:73-157the same shape as a DataTable: toggleSelected, an attachedSet of already-attached rows disabled rather than hidden, Attach button labelled with the count

App-specific: This is not Select multiple wearing a bigger hat. A multi Select (the "Select, multiple" row on the Menus and pickers page) is a closed-by-default control that opens a floating panel and collapses back to "Owner · 2", right for a compact toolbar or form field choosing among a handful of values. This pattern is the opposite shape: an always-open, embedded list built for browsing dozens of real records with full context (a type, a row count, an already-attached state) visible per row, not a value picked once and folded away. Real callers wire the toggle handler into a fetch-backed list and the attach action into a real endpoint; the fixture here is static and the "attached" state is local only.

@versable-git/ui · composites proven in the apps