Filters
Compose Dropdown chips over a results list. Nested is / is not lives on the Dropdown sub-panel.
Filter bar
Each chip is field, operator, value and remove. One value uses is or is not; several values switch to is any of or is none of and pluralise the field name. The bar shows only while filters are active and wraps to more rows as needed.
Agent
Status
Models
In a page
The filter button sits at the end of the toolbar. Adding a filter opens the bar between the toolbar and the list, and the list updates as chips change.
Roles
Manage what people in your organisation can do.
- OwnerFull, unrestricted access to organisation settings and billing.Admin
- AdminManages agents, numbers and telephony for every workspace.Admin
- MemberBuilds and tests agents and runs campaigns.Member
- GuestUses the playground and listens to call recordings.Member
import { useState, type ReactNode } from "react"; import { Button, ChevronDownIcon, ChipIcon, CloseIcon, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSearch, DropdownMenuSub, DropdownMenuSubPanel, DropdownMenuSubTrigger, DropdownMenuTrigger, Entity, EntityAside, EntityContent, EntityMedia, FilterIcon, FlagIcon, List, ListItem, PlusIcon, Tabs, TabsList, TabsTrigger, cn, } from "@smallest-inc/ui"; type Operator = "is" | "is not" | "is any of" | "is none of"; type FieldId = "role" | "permission"; type Field = { id: FieldId; label: string; plural: string; options: string[]; multiple: boolean }; type Filter = { id: FieldId; operator: Operator; values: string[] }; const FIELDS: Field[] = [ { id: "role", label: "Role", plural: "Roles", options: ["Owner", "Admin", "Member", "Guest"], multiple: true }, { id: "permission", label: "Permission", plural: "Permissions", options: ["Admin", "Member"], multiple: false }, ]; const ROLES = [ { title: "Owner", description: "Full, unrestricted access to organisation settings and billing.", permission: "Admin" }, { title: "Admin", description: "Manages agents, numbers and telephony for every workspace.", permission: "Admin" }, { title: "Member", description: "Builds and tests agents and runs campaigns.", permission: "Member" }, { title: "Guest", description: "Uses the playground and listens to call recordings.", permission: "Member" }, ]; function field(id: FieldId) { return FIELDS.find((item) => item.id === id)!; } /** One value uses is / is not; several use is any of / is none of. */ function operatorFor(operator: Operator, count: number): Operator { const negative = operator === "is not" || operator === "is none of"; if (count > 1) return negative ? "is none of" : "is any of"; return negative ? "is not" : "is"; } function FieldIcon({ id }: { id: FieldId }) { const Icon = id === "role" ? FlagIcon : ChipIcon; return <Icon radius="2" className="size-4 text-fg-secondary" />; } function Segment({ children, className }: { children: ReactNode; className?: string }) { return ( <div className={cn( "relative flex h-8 shrink-0 items-center after:absolute after:top-1/2 after:right-0 after:h-1/2 after:w-px after:-translate-y-1/2 after:bg-neutral-150", className )} > {children} </div> ); } const segmentButtonClassName = "inline-flex h-8 cursor-pointer items-center gap-1 whitespace-nowrap px-2.5 text-sm font-normal text-fg outline-none hover:bg-neutral-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-border-focus"; function OperatorMenu({ value, plural, onValueChange }: { value: Operator; plural: boolean; onValueChange: (value: Operator) => void }) { const operators: Operator[] = plural ? ["is any of", "is none of"] : ["is", "is not"]; return ( <DropdownMenu modal={false}> <DropdownMenuTrigger className={cn(segmentButtonClassName, "text-fg-secondary")}>{value}</DropdownMenuTrigger> <DropdownMenuContent align="start" sideOffset={6} className="w-36"> <DropdownMenuRadioGroup value={value} onValueChange={(next) => onValueChange(next as Operator)}> {operators.map((operator) => ( <DropdownMenuRadioItem key={operator} value={operator}> {operator} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> ); } function ValueMenu({ filter, onValuesChange }: { filter: Filter; onValuesChange: (values: string[]) => void }) { const { options, multiple, plural } = field(filter.id); const summary = filter.values.length > 1 ? `${filter.values.length} ${plural.toLowerCase()}` : (filter.values[0] ?? "Select"); return ( <DropdownMenu> <DropdownMenuTrigger className={segmentButtonClassName}> {summary} <ChevronDownIcon radius="0" className="size-[18px] text-fg-quaternary" /> </DropdownMenuTrigger> <DropdownMenuContent align="start" className="w-56"> {multiple ? ( options.map((option) => ( <DropdownMenuCheckboxItem key={option} checked={filter.values.includes(option)} onCheckedChange={(checked) => onValuesChange(checked ? [...filter.values, option] : filter.values.filter((value) => value !== option)) } > {option} </DropdownMenuCheckboxItem> )) ) : ( <DropdownMenuRadioGroup value={filter.values[0]} onValueChange={(next) => onValuesChange([String(next)])}> {options.map((option) => ( <DropdownMenuRadioItem key={option} value={option}> {option} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> )} </DropdownMenuContent> </DropdownMenu> ); } function FieldSubmenu({ item, selected, onApply }: { item: Field; selected: string[]; onApply: (values: string[]) => void }) { const [query, setQuery] = useState(""); const matches = item.options.filter((option) => option.toLowerCase().includes(query.trim().toLowerCase())); return ( <DropdownMenuSub> <DropdownMenuSubTrigger leading={<FieldIcon id={item.id} />}>{item.label}</DropdownMenuSubTrigger> <DropdownMenuSubPanel search={ <DropdownMenuSearch placeholder="Search" aria-label={`Search ${item.plural.toLowerCase()}`} value={query} onChange={(event) => setQuery(event.target.value)} /> } > {item.multiple ? ( matches.map((option) => ( <DropdownMenuCheckboxItem key={option} checked={selected.includes(option)} onCheckedChange={(checked) => onApply(checked ? [...selected, option] : selected.filter((value) => value !== option)) } > {option} </DropdownMenuCheckboxItem> )) ) : ( <DropdownMenuRadioGroup value={selected[0]} onValueChange={(next) => onApply([String(next)])}> {matches.map((option) => ( <DropdownMenuRadioItem key={option} value={option}> {option} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> )} </DropdownMenuSubPanel> </DropdownMenuSub> ); } function AddFilterMenu({ filters, onApply, size = "md", children, }: { filters: Filter[]; onApply: (filter: Filter) => void; size?: "sm" | "md"; children: ReactNode; }) { return ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="transparent" size={size} iconOnly aria-label="Add filter" />}> {children} </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-56"> <DropdownMenuLabel>Filter by</DropdownMenuLabel> {FIELDS.map((item) => { const current = filters.find((filter) => filter.id === item.id); return ( <FieldSubmenu key={item.id} item={item} selected={current?.values ?? []} onApply={(values) => onApply({ id: item.id, values, operator: operatorFor(current?.operator ?? "is", values.length) }) } /> ); })} </DropdownMenuContent> </DropdownMenu> ); } /** Whether a row passes one filter, honouring is / is not / is any of / is none of. */ function matches(filter: Filter, value: string) { const hit = filter.values.includes(value); return filter.operator === "is not" || filter.operator === "is none of" ? !hit : hit; } export default function FiltersInPage() { const [filters, setFilters] = useState<Filter[]>([]); const upsert = (next: Filter) => setFilters((current) => [...current.filter((filter) => filter.id !== next.id), next]); const update = (id: FieldId, patch: Partial<Filter>) => setFilters((current) => current.map((filter) => (filter.id === id ? { ...filter, ...patch } : filter))); const rows = ROLES.filter((role) => filters.every((filter) => filter.values.length === 0 || matches(filter, filter.id === "role" ? role.title : role.permission)) ); return ( <div className="flex w-full flex-col gap-4"> <div className="flex flex-col gap-1"> <h2 className="text-xl font-medium text-fg">Roles</h2> <p className="text-sm text-fg-secondary">Manage what people in your organisation can do.</p> </div> <div className="flex items-center justify-between gap-3"> <Tabs defaultValue="all"> <TabsList> <TabsTrigger value="all">All roles</TabsTrigger> <TabsTrigger value="custom">Custom roles</TabsTrigger> </TabsList> </Tabs> <AddFilterMenu filters={filters} onApply={upsert}> <FilterIcon radius="0" className="size-[18px] text-fg-secondary" /> </AddFilterMenu> </div> {filters.length > 0 ? ( <div className="flex w-full items-center gap-2 rounded-[12px] bg-neutral-100 p-2"> <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2"> {filters.map((filter) => { const item = field(filter.id); const plural = filter.values.length > 1; const label = plural ? item.plural : item.label; return ( <div key={filter.id} className="inline-flex max-w-full min-w-0 items-stretch overflow-hidden rounded-[10px] bg-neutral-0 shadow-sm" > <Segment> <span className="flex items-center gap-1.5 px-2.5 text-sm font-medium text-fg"> <FieldIcon id={filter.id} /> {label} </span> </Segment> <Segment> <OperatorMenu value={operatorFor(filter.operator, filter.values.length)} plural={plural} onValueChange={(operator) => update(filter.id, { operator })} /> </Segment> <Segment> <ValueMenu filter={filter} onValuesChange={(values) => update(filter.id, { values, operator: operatorFor(filter.operator, values.length) }) } /> </Segment> <Segment className="after:hidden"> <button type="button" aria-label={`Remove ${label.toLowerCase()} filter`} onClick={() => setFilters((current) => current.filter((value) => value.id !== filter.id))} className="flex size-8 shrink-0 cursor-pointer items-center justify-center text-fg-quaternary outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-border-focus" > <CloseIcon radius="0" className="size-[18px]" /> </button> </Segment> </div> ); })} <AddFilterMenu filters={filters} onApply={upsert} size="sm"> <PlusIcon radius="0" className="size-[18px] text-fg-quaternary" /> </AddFilterMenu> </div> <Button variant="transparent" size="sm" className="shrink-0" onClick={() => setFilters([])}> Clear </Button> </div> ) : null} <List> {rows.map((role) => ( <ListItem key={role.title}> <Entity> <EntityMedia /> <EntityContent title={role.title} description={role.description} /> <EntityAside> <span className="text-sm text-fg-secondary">{role.permission}</span> </EntityAside> </Entity> </ListItem> ))} </List> {rows.length === 0 ? <p className="text-sm text-fg-secondary">No roles match these filters.</p> : null} </div> ); }
Best practices
When to use
- Use to narrow an existing collection by structured values such as status, role, agent or model.
- Do not use filters as the primary way to move between unrelated product areas.
Behavior
- Open the filter menu from an icon-only button at the end of the toolbar; the bar appears between toolbar and results only while a filter is active.
- Each chip is field, operator, value and remove. One value uses is / is not; several values switch to is any of / is none of and pluralise the field (“Statuses”).
- Reapplying a field updates its chip instead of adding a second chip. Clear removes every filter.
- Reflect active filters in the result count or URL when the view is shareable, and show an empty state when nothing matches.
Accessibility
- Every remove button is labelled with its field, for example “Remove status filter”.