Search, filter and sort
Search fields, filter chips, saved views and the filtered-to-nothing state.
The problem#
Cedarline has thousands of invoices. People need to narrow them to the twelve that matter, see what they narrowed by, and come back to the same slice tomorrow.
Lists go wrong in quiet ways. A filter set in a menu is invisible once the menu closes, so someone wonders why Northwind's invoices are missing. A search that returns nothing looks exactly like an account with no invoices. A carefully built slice is lost on reload, and can't be sent to a colleague. And every list invents its own sort and filter controls.
The solution in Canon#
One toolbar shape for every list: view tabs on the left; search, Filter, Sort and Display on the right; active filters as chips beneath; the whole state in the URL.
Filters stay visible
Nothing says what hid it
The URL is the state
A filterable list of invoices
Switch views, search for north, filter by supplier or payment method, sort and change density. Filter On hold to Pinecrest Supply to reach the filtered-to-nothing state. The URL line shows what would be shared.
| Supplier | Invoice | Due | Method | Status | Amount |
|---|---|---|---|---|---|
| Northwind Freight | INV-20388 | Sep 21 | ACH | On hold | $6,420.00 |
| Orchard Street Foods | INV-20415 | Sep 22 | Check | Paid | $2,143.88 |
| Bluewater Logistics | INV-20376 | Sep 24 | ACH | Overdue | $22,100.75 |
| Northwind Freight | INV-20391 | Sep 26 | ACH | Overdue | $6,420.00 |
| Kestrel Office Supply | INV-20402 | Sep 30 | Virtual card | Paid | $389.12 |
| Halcyon Packaging | INV-20409 | Oct 1 | ACH | On hold | $9,050.00 |
| Northwind Freight | INV-20417 | Oct 2 | ACH | Needs approval | $18,240.00 |
| Halcyon Packaging | INV-20431 | Oct 5 | ACH | On hold | $4,912.50 |
| Orchard Street Foods | INV-20438 | Oct 6 | Check | Scheduled | $7,305.18 |
| Pinecrest Supply | INV-20442 | Oct 9 | Virtual card | Needs approval | $1,264.00 |
| Meridian Print Co. | INV-20447 | Oct 12 | Check | Scheduled | $875.00 |
| Northwind Freight | INV-20449 | Oct 14 | ACH | Needs approval | $11,850.00 |
/payables/invoicesimport { Button } from "@oration/canon/components/button";import { EmptyState } from "@oration/canon/components/data-state";import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { FilterChip, FilterChipRow } from "@oration/canon/components/filter-chip";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { StatusLabel, type StatusTone } from "@oration/canon/components/status-dot";import type { TagColor } from "@oration/canon/components/tag";import { ToolbarRow, ToolbarSearch } from "@oration/canon/components/toolbar";import { cn } from "@oration/canon/lib/utils";import { ArrowUpDownIcon, Building2Icon, CreditCardIcon, ListFilterIcon, SlidersHorizontalIcon,} from "lucide-react";import { motion } from "motion/react";import * as React from "react";export function InvoiceList() { type Status = | "needs-approval" | "on-hold" | "overdue" | "scheduled" | "paid"; type Method = "ACH" | "Check" | "Virtual card"; type Invoice = { id: string; supplier: string; color: TagColor; cents: number; due: string; dueLabel: string; method: Method; status: Status; }; type ViewId = "all" | "needs-approval" | "on-hold" | "overdue" | "paid"; type SortKey = "due" | "amount" | "supplier"; const invoices: Invoice[] = [ { id: "INV-20417", supplier: "Northwind Freight", color: "blue", cents: 1824000, due: "2026-10-02", dueLabel: "Oct 2", method: "ACH", status: "needs-approval", }, { id: "INV-20431", supplier: "Halcyon Packaging", color: "teal", cents: 491250, due: "2026-10-05", dueLabel: "Oct 5", method: "ACH", status: "on-hold", }, { id: "INV-20438", supplier: "Orchard Street Foods", color: "orange", cents: 730518, due: "2026-10-06", dueLabel: "Oct 6", method: "Check", status: "scheduled", }, { id: "INV-20442", supplier: "Pinecrest Supply", color: "violet", cents: 126400, due: "2026-10-09", dueLabel: "Oct 9", method: "Virtual card", status: "needs-approval", }, { id: "INV-20376", supplier: "Bluewater Logistics", color: "green", cents: 2210075, due: "2026-09-24", dueLabel: "Sep 24", method: "ACH", status: "overdue", }, { id: "INV-20391", supplier: "Northwind Freight", color: "blue", cents: 642000, due: "2026-09-26", dueLabel: "Sep 26", method: "ACH", status: "overdue", }, { id: "INV-20402", supplier: "Kestrel Office Supply", color: "amber", cents: 38912, due: "2026-09-30", dueLabel: "Sep 30", method: "Virtual card", status: "paid", }, { id: "INV-20409", supplier: "Halcyon Packaging", color: "teal", cents: 905000, due: "2026-10-01", dueLabel: "Oct 1", method: "ACH", status: "on-hold", }, { id: "INV-20415", supplier: "Orchard Street Foods", color: "orange", cents: 214388, due: "2026-09-22", dueLabel: "Sep 22", method: "Check", status: "paid", }, { id: "INV-20447", supplier: "Meridian Print Co.", color: "pink", cents: 87500, due: "2026-10-12", dueLabel: "Oct 12", method: "Check", status: "scheduled", }, { id: "INV-20449", supplier: "Northwind Freight", color: "blue", cents: 1185000, due: "2026-10-14", dueLabel: "Oct 14", method: "ACH", status: "needs-approval", }, { id: "INV-20388", supplier: "Northwind Freight", color: "blue", cents: 642000, due: "2026-09-21", dueLabel: "Sep 21", method: "ACH", status: "on-hold", }, ]; const statusMeta: Record<Status, { label: string; tone: StatusTone }> = { "needs-approval": { label: "Needs approval", tone: "info" }, "on-hold": { label: "On hold", tone: "warning" }, overdue: { label: "Overdue", tone: "danger" }, scheduled: { label: "Scheduled", tone: "neutral" }, paid: { label: "Paid", tone: "success" }, }; const views: { id: ViewId; label: string }[] = [ { id: "all", label: "All invoices" }, { id: "needs-approval", label: "Needs approval" }, { id: "on-hold", label: "On hold" }, { id: "overdue", label: "Overdue" }, { id: "paid", label: "Paid" }, ]; const sorts: Record<SortKey, string> = { due: "Due date, soonest first", amount: "Amount, largest first", supplier: "Supplier, A to Z", }; const suppliers = [...new Set(invoices.map((row) => row.supplier))].sort(); const methods: Method[] = ["ACH", "Check", "Virtual card"]; const money = (cents: number) => (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD", }); const slug = (text: string) => text .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/-$/, ""); const id = React.useId(); const [view, setView] = React.useState<ViewId>("all"); const [query, setQuery] = React.useState(""); const [supplierFilter, setSupplierFilter] = React.useState<string[]>([]); const [methodFilter, setMethodFilter] = React.useState<Method[]>([]); const [sort, setSort] = React.useState<SortKey>("due"); const [compact, setCompact] = React.useState(false); const [showMethod, setShowMethod] = React.useState(true); const toggle = <T,>(list: T[], value: T) => list.includes(value) ? list.filter((item) => item !== value) : [...list, value]; const needle = query.trim().toLowerCase(); const rows = invoices .filter((row) => view === "all" || row.status === view) .filter( (row) => !supplierFilter.length || supplierFilter.includes(row.supplier), ) .filter( (row) => !methodFilter.length || methodFilter.includes(row.method), ) .filter( (row) => !needle || row.supplier.toLowerCase().includes(needle) || row.id.toLowerCase().includes(needle), ) .sort((a, b) => sort === "amount" ? b.cents - a.cents : sort === "supplier" ? a.supplier.localeCompare(b.supplier) : a.due.localeCompare(b.due), ); const total = rows.reduce((sum, row) => sum + row.cents, 0); const filterCount = (supplierFilter.length ? 1 : 0) + (methodFilter.length ? 1 : 0); const clearFilters = () => { setSupplierFilter([]); setMethodFilter([]); setQuery(""); }; const params = new URLSearchParams(); if (view !== "all") params.set("view", view); for (const name of supplierFilter) params.append("supplier", slug(name)); for (const method of methodFilter) params.append("method", slug(method)); if (sort !== "due") params.set("sort", sort); if (needle) params.set("q", needle); const search = params.toString(); const url = `/payables/invoices${search ? `?${search}` : ""}`; const hidden: string[] = []; if (view !== "all") hidden.push( `the ${views.find((item) => item.id === view)?.label} view`, ); if (supplierFilter.length) hidden.push(`Supplier is ${supplierFilter.join(" or ")}`); if (methodFilter.length) hidden.push(`Payment method is ${methodFilter.join(" or ")}`); if (needle) hidden.push(`a search for “${query.trim()}”`); return ( <div className="flex w-full max-w-4xl flex-col overflow-hidden rounded-xl bg-background text-left shadow-border"> <ToolbarRow className="gap-x-3"> <div role="tablist" aria-label="Invoice views" className="no-scrollbar flex min-w-0 items-center gap-0.5 overflow-x-auto" > {views.map((item) => { const selected = item.id === view; const count = invoices.filter( (row) => item.id === "all" || row.status === item.id, ).length; return ( <button key={item.id} type="button" role="tab" aria-selected={selected} aria-controls={`${id}-results`} onClick={() => setView(item.id)} className={cn( "relative flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-13 whitespace-nowrap outline-none transition-colors duration-150 focus-visible:ring-3 focus-visible:ring-ring/40", selected ? "font-medium text-foreground" : "text-muted-foreground hover:text-foreground", )} > {selected ? ( <motion.span layoutId={`${id}-view`} className="absolute inset-0 rounded-md bg-muted" transition={{ type: "spring", duration: 0.3, bounce: 0, }} /> ) : null} <span className="relative">{item.label}</span> <span className="relative text-xs font-normal text-muted-foreground tabular-nums"> {count} </span> </button> ); })} </div> <div className="ml-auto flex items-center gap-1 max-sm:w-full"> <ToolbarSearch value={query} onChange={setQuery} placeholder="Search invoices" className="text-[13px] max-sm:flex-1" /> <DropdownMenu> <DropdownMenuTrigger render={ <Button variant="ghost" size="sm" className={cn( "text-muted-foreground", filterCount && "text-foreground", )} /> } > <ListFilterIcon data-icon="inline-start" aria-hidden="true" /> Filter {filterCount ? ( <span className="text-xs text-primary tabular-nums"> {filterCount} </span> ) : null} </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-52"> <DropdownMenuSub> <DropdownMenuSubTrigger> <Building2Icon aria-hidden="true" className="text-muted-foreground" /> Supplier </DropdownMenuSubTrigger> <DropdownMenuSubContent className="w-56"> {suppliers.map((name) => ( <DropdownMenuCheckboxItem key={name} checked={supplierFilter.includes( name, )} onCheckedChange={() => setSupplierFilter((list) => toggle(list, name), ) } > {name} </DropdownMenuCheckboxItem> ))} </DropdownMenuSubContent> </DropdownMenuSub> <DropdownMenuSub> <DropdownMenuSubTrigger> <CreditCardIcon aria-hidden="true" className="text-muted-foreground" /> Payment method </DropdownMenuSubTrigger> <DropdownMenuSubContent className="w-44"> {methods.map((method) => ( <DropdownMenuCheckboxItem key={method} checked={methodFilter.includes( method, )} onCheckedChange={() => setMethodFilter((list) => toggle(list, method), ) } > {method} </DropdownMenuCheckboxItem> ))} </DropdownMenuSubContent> </DropdownMenuSub> </DropdownMenuContent> </DropdownMenu> <DropdownMenu> <DropdownMenuTrigger render={ <Button variant="ghost" size="sm" className="text-muted-foreground" /> } > <ArrowUpDownIcon data-icon="inline-start" aria-hidden="true" /> Sort </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-56"> <DropdownMenuGroup> <DropdownMenuLabel>Sort by</DropdownMenuLabel> <DropdownMenuRadioGroup value={sort} onValueChange={(value) => setSort(value as SortKey) } > {(Object.keys(sorts) as SortKey[]).map( (key) => ( <DropdownMenuRadioItem key={key} value={key} > {sorts[key]} </DropdownMenuRadioItem> ), )} </DropdownMenuRadioGroup> </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> <DropdownMenu> <DropdownMenuTrigger render={ <Button variant="ghost" size="icon-sm" aria-label="Display options" className="text-muted-foreground" /> } > <SlidersHorizontalIcon aria-hidden="true" /> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-52"> <DropdownMenuGroup> <DropdownMenuLabel> Row height </DropdownMenuLabel> <DropdownMenuRadioGroup value={compact ? "compact" : "default"} onValueChange={(value) => setCompact(value === "compact") } > <DropdownMenuRadioItem value="compact"> Compact, 32px </DropdownMenuRadioItem> <DropdownMenuRadioItem value="default"> Default, 36px </DropdownMenuRadioItem> </DropdownMenuRadioGroup> </DropdownMenuGroup> <DropdownMenuSeparator /> <DropdownMenuGroup> <DropdownMenuLabel>Columns</DropdownMenuLabel> <DropdownMenuCheckboxItem checked={showMethod} onCheckedChange={setShowMethod} > Payment method </DropdownMenuCheckboxItem> </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> </div> </ToolbarRow> {filterCount ? ( <FilterChipRow className="border-b border-border px-3 py-2"> {supplierFilter.length ? ( <FilterChip icon={ <Building2Icon aria-hidden="true" className="size-3.5 text-muted-foreground" /> } field="Supplier" valuesLabel={supplierFilter.join(", ")} multiple={supplierFilter.length > 1} onRemove={() => setSupplierFilter([])} /> ) : null} {methodFilter.length ? ( <FilterChip icon={ <CreditCardIcon aria-hidden="true" className="size-3.5 text-muted-foreground" /> } field="Payment method" valuesLabel={methodFilter.join(", ")} multiple={methodFilter.length > 1} onRemove={() => setMethodFilter([])} /> ) : null} <Button variant="ghost" size="xs" className="text-muted-foreground" onClick={clearFilters} > Clear filters </Button> </FilterChipRow> ) : null} <div id={`${id}-results`} role="tabpanel" aria-label="Invoices" className="min-h-72" > {rows.length ? ( <div className="overflow-x-auto"> <table className="w-full min-w-[40rem] border-collapse text-13"> <thead> <tr className="h-8 border-b border-border text-left text-muted-foreground"> <th scope="col" className="px-3 font-medium" > Supplier </th> <th scope="col" className="px-3 font-medium" > Invoice </th> <th scope="col" className="px-3 font-medium" > Due </th> {showMethod ? ( <th scope="col" className="px-3 font-medium" > Method </th> ) : null} <th scope="col" className="px-3 font-medium" > Status </th> <th scope="col" className="px-3 text-right font-medium" > Amount </th> </tr> </thead> <tbody> {rows.map((row) => ( <tr key={row.id} className={cn( "border-b border-border hover:bg-surface", compact ? "h-8" : "h-9", )} > <td className="px-3"> <span className="flex items-center gap-2"> <MonogramTile name={row.supplier} color={row.color} size="sm" /> <span className="truncate font-medium"> {row.supplier} </span> </span> </td> <td className="px-3 font-mono text-xs text-muted-foreground"> {row.id} </td> <td className="px-3 tabular-nums"> {row.dueLabel} </td> {showMethod ? ( <td className="px-3 text-muted-foreground"> {row.method} </td> ) : null} <td className="px-3"> <StatusLabel tone={ statusMeta[row.status].tone } > {statusMeta[row.status].label} </StatusLabel> </td> <td className="px-3 text-right font-medium tabular-nums"> {money(row.cents)} </td> </tr> ))} </tbody> </table> </div> ) : ( <EmptyState illustration={filterCount ? "filters" : "search"} title={ filterCount ? "No invoices match these filters" : "No invoices match this search" } description={`Hidden by ${hidden.join(", ")}.`} action={ <Button variant="outline" size="sm" onClick={clearFilters} > {filterCount ? "Clear filters" : "Clear search"} </Button> } /> )} </div> <div className="flex h-9 items-center justify-between gap-3 border-t border-border px-3 text-xs text-muted-foreground tabular-nums"> <span aria-live="polite"> {rows.length} of {invoices.length} invoices </span> <span> Total{" "} <span className="font-medium text-foreground"> {money(total)} </span> </span> </div> <div className="flex items-center gap-2 border-t border-border bg-muted/50 px-3 py-2"> <span className="shrink-0 text-xs text-muted-foreground"> URL </span> <code className="min-w-0 truncate font-mono text-xs text-foreground"> {url} </code> </div> </div> );}Decision guide#
Five tools narrow or reshape a list. Each has one job.
| Tool | Job | Lives in | Example |
|---|---|---|---|
| Search | Find rows by text you remember: a name, an ID. | 28px field in the toolbar | north, INV-20431 |
| Filter | Keep rows whose field has certain values. | Filter menu, then chips | Supplier is any of Northwind Freight, Halcyon Packaging |
| View | A saved filter and sort that people return to. | View tabs with counts | Needs approval 12 |
| Sort | Order the rows. One key at a time. | Sort menu | Due date, soonest first |
| Display | Change how rows look, not which rows show. | Display menu | Compact rows, hide Method |
Search fields#
In a toolbar, search is a 28px field that widens on focus. On a page or in a picker, it is a full-width 32px field. Both filter as you type, with no submit button.
In a toolbar: 28px, beside filters
7 of 7 suppliers
On a page or in a picker: 32px, full width
- Bluewater Logistics
- Halcyon Packaging
- Kestrel Office Supply
import { SearchField } from "@oration/canon/components/search-field";import { ToolbarRow, ToolbarSearch } from "@oration/canon/components/toolbar";import * as React from "react";export function SearchPlacements() { const suppliers = [ "Bluewater Logistics", "Halcyon Packaging", "Kestrel Office Supply", "Meridian Print Co.", "Northwind Freight", "Orchard Street Foods", "Pinecrest Supply", ]; const [toolbar, setToolbar] = React.useState(""); const [page, setPage] = React.useState(""); const match = (text: string) => suppliers.filter((name) => name.toLowerCase().includes(text.trim().toLowerCase()), ); return ( <div className="grid w-full gap-6 text-left md:grid-cols-2"> <div className="flex min-w-0 flex-col gap-2"> <p className="text-xs text-muted-foreground"> In a toolbar: 28px, beside filters </p> <div className="overflow-hidden rounded-xl bg-background shadow-border"> <ToolbarRow> <span className="text-13 font-medium">Suppliers</span> <ToolbarSearch value={toolbar} onChange={setToolbar} placeholder="Search suppliers" className="ml-auto text-[13px]" /> </ToolbarRow> <p className="px-3 py-2.5 text-xs text-muted-foreground tabular-nums"> {match(toolbar).length} of {suppliers.length} suppliers </p> </div> </div> <div className="flex min-w-0 flex-col gap-2"> <p className="text-xs text-muted-foreground"> On a page or in a picker: 32px, full width </p> <div className="flex flex-col gap-2 rounded-xl bg-card p-4 shadow-border"> <SearchField value={page} onChange={setPage} label="Search suppliers" placeholder="Search by name, EIN or remit-to email" inputClassName="h-8 pl-8 text-base md:text-[13px]" /> <ul className="flex flex-col"> {match(page) .slice(0, 3) .map((name) => ( <li key={name} className="flex h-8 items-center px-1 text-13" > {name} </li> ))} </ul> </div> </div> </div> );}| Where | Component | Size | Behavior |
|---|---|---|---|
| List toolbar | ToolbarSearch from Toolbar | 28px, 11rem widening to 14rem | Hairline lift, indigo ring on focus, clear button once there's text. Full width below 640px. |
| Page or picker | Search field | 32px, full width | A 14px search icon inside the left edge, 16px text below 768px. |
| Everything | Command menu | ⌘K | Pages, records and actions across the suite. |
- The placeholder says what is searched: Search invoices, or Search by name, EIN or remit-to email when it isn't obvious.
- Match names and IDs, case-insensitively, anywhere in the string.
- Search narrows the current view. It doesn't reset filters, and filters don't clear it.
ToolbarSearch and SearchField both lose their 13px size to the cn inside packages/canon, which reads text-13 as a color. Pass text-[13px] (and md:text-[13px] on SearchField), as these examples do. Recorded on Toolbar and Search field.
Filter chips#
Filters are chosen in a menu and shown as chips. A chip is a control, not a tag: 28px, a 6% indigo tint and a 22% inset indigo ring.
import { Button } from "@oration/canon/components/button";import { FilterChip, FilterChipRow } from "@oration/canon/components/filter-chip";import { Building2Icon, CreditCardIcon } from "lucide-react";import * as React from "react";export function FilterChipAnatomy() { const [chips, setChips] = React.useState([ { field: "Supplier", values: ["Northwind Freight", "Halcyon Packaging"], icon: "supplier", }, { field: "Payment method", values: ["ACH"], icon: "method" }, ]); return ( <div className="flex w-full max-w-xl flex-col gap-3 text-left"> <FilterChipRow> {chips.map((chip) => ( <FilterChip key={chip.field} icon={ chip.icon === "supplier" ? ( <Building2Icon aria-hidden="true" className="size-3.5 text-muted-foreground" /> ) : ( <CreditCardIcon aria-hidden="true" className="size-3.5 text-muted-foreground" /> ) } field={chip.field} valuesLabel={chip.values.join(", ")} multiple={chip.values.length > 1} onRemove={() => setChips((list) => list.filter( (item) => item.field !== chip.field, ), ) } /> ))} {chips.length ? null : ( <span className="text-13 text-muted-foreground"> No filters </span> )} </FilterChipRow> {chips.length < 2 ? ( <Button variant="ghost" size="sm" className="self-start" onClick={() => setChips([ { field: "Supplier", values: [ "Northwind Freight", "Halcyon Packaging", ], icon: "supplier", }, { field: "Payment method", values: ["ACH"], icon: "method", }, ]) } > Reset example </Button> ) : null} </div> );}| Part | Spec |
|---|---|
| Chip | 28px tall, 6px corners, bg-primary/6 with a 1px inset ring of primary at 22%, 13px text. |
| Icon | The field's 14px icon in Slate Meta. |
| Field and operator | Slate Meta: Supplier is, or Supplier is any of with more than one value. |
| Values | Medium Graphite Ink, joined with commas, truncated at 12rem. |
| Remove | A 24px button with a 14px X, labelled Remove Supplier filter. |
| Row | Chips wrap with 6px gaps under the toolbar, with a ghost Clear filters at the end. |
| Filter button | Ghost, 28px. With filters on, its label turns Graphite Ink and the count shows in indigo. |
The chip body isn't a button, so a filter can't be edited from its chip, and the only operators are is and is any of. See Filter chip.
Sort, display and saved views#
Sort orders rows, Display changes how they look, and views save the combination people come back to.
Sort and display menus#
Both are ghost 28px triggers at the right of the toolbar, opening menus of radio and checkbox items.
| Menu | Holds | Rule |
|---|---|---|
| Sort | One radio group. Each option names its direction: Due date, soonest first. | One key at a time. The default sort is left out of the URL. |
| Display | Row height (compact 32px, default 36px, comfortable 44px) and column visibility. | Display never hides rows. It is remembered per list. |
Saved views#
A view is a named filter and sort. View tabs sit at the left of the toolbar: 28px pills with a sliding Well Gray fill and the count in 12px Slate Meta.
- Name views for the job, not the filter: Needs approval, not Status = Pending.
- Counts are the view's total, not the current search result, so they don't jump while typing.
- Changing filters inside a view shows the chips; saving them updates the view or creates a new one.
- The row scrolls with a 2rem fade on the clipped edge when views outgrow the toolbar.
- See Navigation and wayfinding for how view tabs relate to other tabs.
Filtered to nothing#
When search or filters hide every row, say what hid them and how to get them back. Never reuse the first-use empty state.
No invoices match these filters
Hidden by the On hold view, Supplier is Pinecrest Supply.
No invoices yet
Invoices from your suppliers show up here.
- Use the
filtersillustration when filters hid everything,searchwhen only a search did. - List what's hiding the rows in the description, in words: Supplier is Pinecrest Supply.
- The action clears filters and search but keeps the view. If the view itself is empty, use the view's own empty state.
- Keep the toolbar and chips visible above it, so each filter can also be removed on its own.
URL state#
Whatever narrows the list goes in the query string, written with replaceState so Back leaves the page rather than undoing a keystroke.
| Parameter | Holds | Omitted when |
|---|---|---|
| view | The saved view's id: on-hold. | It's the default view. |
| supplier, method | One entry per value: supplier=northwind-freight&supplier=halcyon-packaging. | No values are selected. |
| sort | The sort key: amount. | It's the default sort. |
| q | The search text. | The search is empty. |
// View, filters, sort and search live in the URL. replaceState keeps// Back meaning "the previous page", not "the previous keystroke".React.useEffect(() => { const params = new URLSearchParams(); if (view !== "all") params.set("view", view); for (const supplier of suppliers) params.append("supplier", supplier); if (sort !== "due") params.set("sort", sort); if (query) params.set("q", query); const search = params.toString(); window.history.replaceState( window.history.state, "", search ? `?${search}` : window.location.pathname, );}, [view, suppliers, sort, query]);- Read the parameters on load so a shared link opens the same slice.
- Display preferences (density, columns) are personal. They live in local storage, not the URL.
- Opening a record from the list keeps the list's URL one Back away.
Accessibility#
Narrowing a list changes what's on screen without moving focus, so say so.
- Every search field has a label, visible or
aria-label, that matches its placeholder. - The result count (8 of 12 invoices) is a polite live region, so typing a search announces how many rows remain.
- Filter, Sort and Display are menu buttons with visible labels; the icon-only Display button has an
aria-labeland a tooltip. - Each chip's remove button names the filter: Remove Supplier filter.
- View tabs use tab roles with
aria-selected; the list is their tab panel. - Inputs are 16px below 768px so focusing search doesn't zoom the page.
| Keys | Action |
|---|---|
| Esc | Close an open filter, sort or display menu. |
| ↑↓ | Move through menu items; → opens a filter's values. |
| Space | Toggle a value in a filter menu without closing it. |
Components involved#
The parts this pattern is built from.
| Component | Role here |
|---|---|
| Toolbar | ToolbarRow and ToolbarSearch, the 28px row above every list. |
| Search field | Page and picker search. |
| Filter chip | Active filters under the toolbar. |
| Dropdown menu | Filter, Sort and Display menus with checkbox and radio items. |
| Data state | EmptyState for filtered to nothing. |
| Data grid | The record table these controls drive. |
| Status label | Invoice status with a label beside the dot. |
| Command menu | Search across the whole suite. |