Searchable multi-select attach list
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 listwalmart-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.