Filtering & Search
Built-in search, column filters, summary controls, and column visibility via the SmartFilterBar — plus an external search field wired through the table's view.
Smart Filter Bar
Enable enableSmartFilter to get a toolbar with
search, per-column filters, grouping controls, summary aggregations, and a column visibility
menu. Which columns take part is decided on the column, not on the bar: a column with an
accessor is searched and gets its own filter entry unless it carries searchable: false, and its dataType decides which operators that filter offers (Column Configuration).
Smart Filter Bar
Name | Role | Department | Location |
|---|---|---|---|
Emma Wilson | Staff Engineer | Platform | Berlin |
Liam Chen | Product Designer | Design | Hamburg |
Sofia Martinez | Engineering Manager | Platform | Munich |
James Park | Frontend Developer | Product | Remote |
Aisha Patel | Data Scientist | Data | Berlin |
Noah Kim | DevOps Engineer | Platform | Hamburg |
<Table
{items}
{columns}
enableSmartFilter={true}
searchPlaceholder="Search employees..."
searchDebounceMs={300}
viewDefaults={{ pageSize: 6 }}
/>Narrow Bar & Tools Sheet
The bar's tools — filters, sort, grouping, summaries and column visibility — normally sit in a capsule beside the search field. Below 28rem of bar content box that capsule is gone: the tools move into a bottom sheet reached from a single button. Nothing is taken away, only relocated. Each tool becomes a section of the sheet, rebuilt as a form instead of a menu: sort splits into a column list plus a separate direction control rather than offering every column×direction pair, and summaries become one aggregation choice per summable column (which is why this demo declares two numeric columns).
The sheet mirrors the capsule exactly, including what is missing from it: grouping drops out
while the table is virtualized, column visibility
when enableColumnVisibility is off. The demo below has all five
tools.
Drag the slider to squeeze the container past the threshold, then open the sheet. The switch is automatic; there is no prop for it. The sheet is a bottom Drawer, so it spans the window, not the bar: here a 360px table opens a full-width sheet. On a phone the two are usually the same width, though a bar inside a card is narrower than the sheet there too.
Name | Department | Salary | Projects |
|---|---|---|---|
Sofia Martinez | Platform | 165000 | 15 |
Alexander Novak | Data | 148000 | 7 |
Emma Wilson | Platform | 142000 | 12 |
Isabella Singh | Design | 140000 | 18 |
Mia Zhang | Product | 135000 | 14 |
This is not the table's own layout switch. Rows
become cards below cardsBelow of the table's
container (48rem by default), which is why the demo
above is a card list at every width the slider reaches. The bar's 28rem is separate from it and has no prop: set cardsBelow to move the rows-to-cards step, and the tools
keep switching where they always did.
The width is measured on the bar, not on the window. A filter bar can sit in a card, a drawer, a split pane or a dashboard tile, so a viewport media
query would happily leave a 400px bar in the wide layout it has no room for. The threshold is
one 28rem container step, the same one that stacks the
search field above the tools, and it resolves against your root font size: at a larger text size
the same bar moves its tools into the sheet at a larger pixel width.
Only layout="responsive", the bar's default,
switches. horizontal and vertical are explicit instructions and are left alone
at any width; all three live on SmartFilterBar, which you render yourself through the
table's toolbar snippet when you need one of them. Growing
back past the threshold closes the sheet, so a bar that is narrowed again does not re-open it
unprompted.
The badge on the button counts tools, not results. With the sheet shut, the lit triggers inside it are invisible, so the button carries the number
of things currently acting on the grid, at most five: one each for active filters (however many),
a sort column, a grouping, a summary row that is switched on, and hidden columns. The demo starts
at 1 because its viewDefaults seed a sort; add a filter or a grouping in
the sheet and watch it climb. Hidden columns count too, because a column that is not on screen
changes what the reader sees just as much as a filter does.
Reproducing It
<!-- The bar measures its own CONTENT box, so the wrapper has to be
narrower than 28rem plus the bar's padding and border (~474px at
size="md"): max-w-md is exactly 28rem and still trips the switch. -->
<div class="max-w-sm">
<Table {items} {columns} enableSmartFilter />
</div>Filter Operators
Per-column filters are added through the filter button in the SmartFilterBar. Each filter is
a plain object — { column, operator, value } — and every active filter must match for a row to stay visible (AND semantics). value is always a string, even for the comparing
operators — the comparison converts internally, which keeps filters serializable for
persistence. The six operators below are the whole of FilterOperator; which of them the menu offers is
driven by the column's dataType, and a type with no
set of its own (boolean, email, url)
gets the text operators. Columns with searchable: false (and synthetic columns without an accessor)
do not appear in the filter menu at all.
| Operator | Matches | Offered for |
|---|---|---|
contains | Substring, case-insensitive | text (the default) |
equals | The whole stringified value, case-insensitive; on a date column, the whole UTC day | text, number, date (labelled “on date”) |
startsWith | Prefix, case-insensitive | text |
endsWith | Suffix, case-insensitive | text |
greaterThan | Numbers when both sides convert, instants otherwise | number, date (labelled “after”) |
lessThan | Numbers when both sides convert, instants otherwise | number, date (labelled “before”) |
Comparing operators resolve in two steps. If the cell value and the filter value both convert via Number(), they are compared as numbers. Otherwise
both sides are read as instants — Date objects, numbers (epoch milliseconds) and
ISO-8601 strings (2021-03-15, 2021-03-15T09:00, 2021-03-15T09:00:00Z). Any other string format never
matches, so a malformed value filters everything out instead of matching everything. An
empty value is not an assertion at all and keeps every row, whichever
operator it carries; the filter menu never produces one, but a seeded or stored filter can.
A date column's filter input emits a bare calendar
date (YYYY-MM-DD), and for that shape on date, after and before compare on UTC day boundaries: "after 2021-03-15" starts at
the following midnight UTC, "before 2021-03-15" ends at that day's midnight UTC, and a row
stamped 2021-03-15T09:00Z matches neither. A filter
value that carries a time of day compares instants strictly. Store ISO strings or
UTC-constructed dates in those columns: a date-only string parses as UTC midnight while a
date-time string without an offset parses as local time, so a Date built from local parts (new Date(2021, 2, 15)) can land in the neighbouring UTC day.
To start with filters active, pass viewDefaults={{ filters: […] }}, an array of the same { column, operator, value } objects. That
is the view's baseline: the chips show them, and users can still remove or add filters. The
same array is what view.filters holds, so assigning to
it drives filters from your own UI the way the next section drives the search term.
The search field matches a case-insensitive substring, and both it and the filters read the
column accessor's output rather than what a custom cell renders. With source={{ processing: 'server', … }} the table does not filter locally: the active filters arrive as filters on the view your backend is given — see Server Processing.
Search from Outside
The search term is one of the six settings of the table's view: search, sort, filters, page, pageSize and groupBy. Hand the table a view of your own (createTableView()) and they are yours to write: an effect pushes your field into view.search, and reading it back is just view.search, with no callback in between. Your own
view replaces viewDefaults rather than joining it (passing both
throws), so its defaults go to createTableView({ defaults }). The table's
own search field writes the same setting, so the readout below shows what the table is
filtering by, whichever of the two was typed into last.
One thing comes with writing a field directly: it does not reset the page. The table's own handlers
do that on a new search, so write view.page = 1 alongside if you want the same behaviour.
Writing a setting does not subscribe the effect to it, so the effect above runs when your field
changes and not when the table writes the same setting.
Steering a setting from outside and persisting the view are independent. A setting whose
value comes from outside is usually one you do not want stored, so name the others instead: bindViewToStorage(view, { key: 'employees', axes: ['sort', 'filters', 'pageSize',
'groupBy'] }). It ships with createTableView in @urbicon-ui/table; what it stores when and how it
meets the URL is URL State & Persistence.
External Search Field
view.search, the table's own search field writes the same setting, and the readout reads it straight back.The table is searching for: —
Name | Role | Department | Location |
|---|---|---|---|
Emma Wilson | Staff Engineer | Platform | Berlin |
Liam Chen | Product Designer | Design | Hamburg |
Sofia Martinez | Engineering Manager | Platform | Munich |
James Park | Frontend Developer | Product | Remote |
Aisha Patel | Data Scientist | Data | Berlin |
Noah Kim | DevOps Engineer | Platform | Hamburg |
<script>
import { Table, createTableView } from '@urbicon-ui/table';
const view = createTableView({ defaults: { pageSize: 6 } });
let term = $state('');
// Outside → view. The table's own search field keeps working alongside this
// one, and writing here never re-runs this effect.
$effect(() => {
view.search = term;
view.page = 1; // a direct field write does not reset the page
});
</script>
<Input bind:value={term} label="Search from outside the table" clearable />
<p>The table is searching for: <code>{view.search || '—'}</code></p>
<Table {items} {columns} {view} />