Searchable Container
Drop-in searchable grid/list container — combines PaginatedCollection, ExpandableSearch, and useVisibleItems into one component
Overview
SearchableContainer is the primary way to render a filterable, auto-paginated grid or list in FoodEase apps. It wraps PaginatedCollection with an overlay ExpandableSearch and the useVisibleItems hook to handle filtering automatically.
You provide items, a renderItem function, and a gridConfig — it handles layout, pagination, and search.
Installation
npx shadcn@latest add https://foodease-dev-registry.cap.reachcinema.io/r/v1/searchable-container.jsonThis installs:
SearchableContainercomponentPaginatedCollectioncomponentExpandableSearchcomponentuseVisibleItemshookCaseRendercomponentmatch-sorternpm package
Usage
Basic Grid
import SearchableContainer from "@/components/ui/searchable-container"
interface Product {
id: string
name: string
price: number
}
export default function ProductGrid({ products }: { products: Product[] }) {
return (
<div className="flex-1 h-full">
<SearchableContainer
items={products}
options={{ keys: ["name"] }}
gridConfig={{ minCardWidth: 180, minCardHeight: 120, gap: 8 }}
showSearch
allowSearchAsYouType
renderItem={(product) => (
<button
type="button"
className="absolute inset-0 rounded-xl bg-primary/10 p-2 text-start"
>
<p className="font-bold truncate">{product.name}</p>
<p className="text-sm">${product.price.toFixed(2)}</p>
</button>
)}
/>
</div>
)
}Important: The container must have a defined height (e.g.
h-full,h-[400px]) for the ResizeObserver to calculate layout correctly.
List Layout
Render items as a single-column list that fills the available height:
<SearchableContainer
items={items}
options={{ keys: ["name"] }}
gridConfig={{ minCardHeight: 60, gap: 4 }}
showSearch
allowSearchAsYouType
renderItem={(item) => (
<div className="absolute inset-0 flex items-center px-3 border rounded-lg">
{item.name}
</div>
)}
/>Pass layout="list" to PaginatedCollection via a custom renderPagination if you need explicit list mode — or just set minCardWidth large enough that only one column fits.
Custom Empty State
<SearchableContainer
items={items}
options={{ keys: ["name"] }}
renderItem={(item) => <ItemCard item={item} />}
renderEmpty={() => (
<div className="center flex-col gap-2 h-full">
<p className="text-muted-foreground">No items found</p>
<button onClick={refetch} className="text-primary underline text-sm">
Retry
</button>
</div>
)}
/>Search with Multiple Keys
options accepts any match-sorter options — search across multiple fields:
<SearchableContainer
items={staff}
options={{ keys: ["firstName", "lastName", "roleName"] }}
gridConfig={{ minCardWidth: 200, minCardHeight: 80, gap: 8 }}
showSearch
allowSearchAsYouType
renderItem={(member) => <StaffCard member={member} />}
/>Without Search Bar
Omit showSearch to render the grid without the search overlay — useful when you want external search control:
<SearchableContainer
items={filteredItems}
options={{ keys: ["name"] }}
gridConfig={{ minCardWidth: 150, minCardHeight: 45, gap: 8 }}
renderItem={(item) => <ItemClassButton item={item} />}
/>Typical POS Pattern (Item Classes + Items)
This is the standard two-section layout used in FoodEase POS pages:
// Top section — item classes (categories), short cards
<section className="flex">
<SearchableContainer
items={classes}
options={{ keys: ["name"] }}
gridConfig={{ minCardWidth: 150, minCardHeight: 45, gap: 8 }}
showSearch
allowSearchAsYouType
renderItem={(cls) => (
<button
type="button"
className="absolute inset-0 rounded-xl p-1 text-sm truncate"
style={{ backgroundColor: cls.color }}
>
{cls.name}
</button>
)}
/>
</section>
// Bottom section — items, taller cards
<section className="flex">
<SearchableContainer
items={filteredItems}
options={{ keys: ["name"] }}
gridConfig={{ minCardWidth: 180, minCardHeight: 105, gap: 8 }}
showSearch
allowSearchAsYouType
renderItem={(item) => <ItemCard item={item} />}
/>
</section>How It Works
useVisibleItemsfiltersitemsagainst the current search query usingmatch-sorterExpandableSearch(top-right overlay) callssetQueryas the user typesPaginatedCollectionreceives the filtered list and usesResizeObserverto measure its container, then auto-calculates how many columns and rows fit givenminCardWidth/minCardHeight- Items are sliced into pages based on the calculated
cols × rowscount - Built-in page buttons appear when there's more than one page
API Reference
SearchableContainer Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | T[] | — | Array of items to display |
options | MatchSorterOptions<T> | — | match-sorter search options. keys specifies which fields to search |
renderItem | (item: T) => ReactNode | — | Render function for each item. Use absolute inset-0 inside for full card coverage |
gridConfig | GridConfig | see below | Card size and gap constraints |
showSearch | boolean | false | Show the expandable search overlay |
allowSearchAsYouType | boolean | false | Filter on every keystroke (vs on Enter) |
containerClassName | string | — | Extra classes on the outer <section> |
expandedClassName | string | — | Extra classes applied to the search input when expanded |
GridConfig
| Field | Type | Default | Description |
|---|---|---|---|
minCardWidth | number | 320 | Minimum card width in px. More columns fit as the container grows |
minCardHeight | number | 400 | Minimum card height in px. More rows fit as the container grows |
gap | number | 16 | Gap between cards in px |
renderItem Pattern
Each item is rendered inside a relative wrapper div that fills its grid cell. Use absolute inset-0 on your card element to fill the cell completely:
renderItem={(item) => (
<button className="absolute inset-0 rounded-xl p-2 bg-card border">
{item.name}
</button>
)}Dependencies
| Package | Purpose |
|---|---|
match-sorter | Fuzzy search filtering |
lucide-react | Search and Shrink icons in ExpandableSearch |
Registry dependencies auto-installed: paginated-collection, expand-search, use-visible-items, case-render.