Column Filters Implementation
Type-aware, per-column filtering at the DataManager layer. A structured,
source-agnostic filter model is applied client-side by StaticDataManager and
serialized to server-side OData $filter by ODataDataManager. Composes with
free-text search (03-data-manager-implementation.md §11)
and the raw base $filter.
This document is the authoritative API/behavior reference for the shipped feature.
The filter model
A plain object keyed by column key (JSON-serializable). Columns are joined by
AND; within a column the (≤2) conditions join by the per-column combinator.
{
country: { type: 'string', combinator: 'and', conditions: [ { operator: 'equals', value: 'Italy' } ] },
salary: { type: 'number', conditions: [ { operator: 'between', value: 30000, valueTo: 50000 } ] },
hired: { type: 'date', combinator: 'or',
conditions: [ { operator: 'before', value: '2020-01-01' },
{ operator: 'after', value: '2024-01-01' } ] }
}
type— drives the operator catalog and (OData) literal formatting.combinator—'and'(default) |'or', joins this column's conditions.conditions[]— 1–2{ operator, value?, valueTo? }.valueTois used only by range operators (between/notBetween). Unary operators take no operand.field(optional) — underlying field path when it differs from the key (custom-rendered / remapped columns). Dotted paths → OData slash notation.
Operator catalog (by type)
| Type | Operators |
|---|---|
string |
equals notEquals contains notContains startsWith endsWith isEmpty isNotEmpty in |
number / uid |
equals notEquals greaterThan greaterThanOrEqual lessThan lessThanOrEqual between notBetween isEmpty isNotEmpty in |
uuid |
equals notEquals isEmpty isNotEmpty in |
date / datetime / time |
on notOn before after onOrBefore onOrAfter between isEmpty isNotEmpty |
boolean |
isTrue isFalse isEmpty isNotEmpty |
uidvsuuid. Auidcolumn holds a numeric unique identifier, so for filtering it behaves exactly likenumber(number operators, a numeric<input>,Number()coercion, and a bare OData literal —Id eq 44). Auuidcolumn is anEdm.Guid: equality /in/ null checks only (substring and ordering operators don't apply to a GUID), and an unquoted OData literal —Id eq 0f9e1c2a-1b2c-3d4e-5f60-718293a4b5c6, neverId eq '0f9e…'. Any type not listed (e.g.checkbox) falls back tostring.
Operator arity (number of value inputs): unary (isEmpty/isNotEmpty/isTrue/
isFalse) = 0; range (between/notBetween) = 2; in = a list; everything
else = 1. The catalog, arity, and validation live in the shared, pure
src/vanilla-grid/filter-model.js (window.VanillaGridFilterModel):
FILTER_OPERATORS,OPERATOR_ARITY,arityOf(op)validateOperatorForType(type, op),isConditionComplete(cond)operatorsForColumn(type, fieldCount, allowed?),isNegativeOperator(op)— see the fan-out section and the allow-list sectionnormalizeFilterModel(model)— drops invalid operators + incomplete conditions (and, when an entry carries anoperatorsallow-list, conditions using an operator outside it), caps each column at 2 conditions, normalizescombinator, drops empty columns. This is what managers store and compare for change detection.cloneFilterModel(model),isEmptyModel(model)— clones preservefieldsandoperators
The filter panel renders one value input per arity, except in which renders a
dynamic value-list editor: one typed input per value (so a number/date
column gets native pickers and a localized decimal like 100,20 is one
unambiguous value — no separator to parse), each with a remove (−) control, plus
an "add value" (+) button. Enter inside a value row adds another row rather than
applying. Labels come from messages.filterListAdd / messages.filterListRemove.
Operator-change focus. When the user changes the operator by clicking a
new option, the panel drops the caret into the first freshly-rendered value input
(the common next step is entering the value, so this saves a click). Unary
operators (isEmpty/isNotEmpty/isTrue/isFalse) render no input, so focus
stays on the operator dropdown. Changing the operator by keyboard deliberately
does not move focus — a native <select> fires change on every arrow step, so
moving focus then would break keyboard operator navigation; the focus move is
gated on a pointer-initiated change (header-menu.feature.js). The caret is placed
at the end of any value carried over from the previous operator (not selected), so
tweaking only the operator never risks clobbering an intact value.
DataManager contract
Four methods (no-ops on the base DataManager):
dm.setColumnFilters(model); // replace whole model
dm.getColumnFilters(); // → normalized clone
dm.setColumnFilter(key, columnFilter); // set/clear one column (null clears)
dm.clearColumnFilters(); // clear all
Setters store the normalized model and fire _fireConfigChanged('setColumnFilters')
— so with setAutoReloadOnConfigChange(true) the grid reloads automatically;
otherwise call reloadDataManager(). Setters are no-ops when the normalized
model is unchanged (avoids a redundant reload). The active model persists
across reset()/reloadDataManager() (like the search term and $filter).
StaticDataManager (client-side)
Compiles the model into a single (row) => boolean predicate (memoized), applied
after the free-text search inside _getFilteredRowsAsync() — so paging and count
operate on the filtered set. Options:
caseInsensitive(defaulttrue) — string operators fold case.columnFilters— initial model.filterValueGetters—{ columnKey: (row) => value }for computed/custom columns with no plain backing field. Resolution order per column:filterValueGetters[key](row)→_readField(row, field || key).
Coercion: numbers via Number() (non-finite → no match), dates via Date.parse
/ epoch (unparseable → no match), booleans via the grid's tri-state coercion.
on/notOn compare by calendar day.
ODataDataManager (server-side)
Serializes the model to a $filter fragment and AND-merges it with the base
$filter and the 'filter'-mode search disjunction via _composeEffectiveFilter():
$filter = (base) and (columnFilters) and (searchDisjunction)
_buildUrl() and _refreshTotalRowCount() both use the composite, so paged fetches
and the total count reflect the filters. Literal formatting by type:
| Type | Literal | Example |
|---|---|---|
| string | '…', ' → '' |
'O''Brien' |
| number / uid | bare | 42 |
| uuid | unquoted Edm.Guid | 0f9e1c2a-1b2c-3d4e-5f60-718293a4b5c6 |
| boolean | true/false |
true |
| date | unquoted | 2024-01-31 |
| datetime | unquoted ISO | 2024-01-31T09:30:00.000Z |
| time | unquoted | 09:30:00 |
Operator mapping highlights: contains→contains(f,'v'),
startsWith→startswith, endsWith→endswith, between→(f ge a and f le b),
isEmpty (string)→(f eq null or f eq ''). For a
datetime column, on/notOn expand to a half-open day window
(f ge dayStart and f lt nextDay).
in ("is any of"): the OData in operator is 4.01+, and many endpoints
reject it (OData V2/V3, OData 4.0 services, and the Northwind demo service's older
parser — "Syntax error … in 'f in (…)'"). So by default in is serialized as an
eq/or disjunction — (f eq a or f eq b or …) — which is valid on every OData
version. Set the useInOperator: true option against a known 4.01+ service to emit
the compact f in (a,b,…) form instead.
Options: caseInsensitive, columnFilters, useInOperator (default false).
<vn-grid> element API
await grid.setColumnFilters(model); // returns rows (or [] under auto-reload)
await grid.setColumnFilter('salary', { type: 'number', conditions: [ { operator: 'greaterThan', value: 1000 } ] });
await grid.clearColumnFilters(); // filters only
await grid.clearColumnFiltersAndSorting(); // filters + sort, keeps column layout
clearColumnFiltersAndSorting() is the middle-ground reset: it clears the active
filter model and all sorting (including their persisted values) with a single
coordinated reload, while preserving persisted column layout (widths, order,
hidden, frozen). Contrast clearColumnFilters() (filters only) and
clearPersistedSettings() (everything, layout included).
The element is the single authority on column capabilities (managers hold no column defs). Before delegating it:
- drops non-filterable columns (
filtering.enabled === falsegrid-wide, orcolumn.filterable === false) with a warning; - enriches each kept entry with
fields(fromfilterFields, defaulting tokey; multiple fields are OR-ed together — fan-out),type(fromfilterType/type) andoperators(fromfilterOperators, when set); - forwards any
filterValueGetters to managers that support them.
Header filter icon
Every filterable column header shows a small funnel icon at its far right
(.vn-grid-filter-icon). Clicking it opens the same filter panel as the
context-menu "Filter…" item (anchored under the icon). When the column has an
active filter the icon switches from an outline funnel to a filled one
(.vn-grid-filter-icon-active). The fill state is kept in sync by the element:
after every setColumnFilter/setColumnFilters/clearColumnFilters it calls
grid.setFilteredColumnKeys(activeKeys). The icon is hidden for non-filterable
columns and when filtering is disabled grid-wide.
The funnel button carries no SVG markup — the glyph is a CSS-owned masked
::before whose shape comes from the --vn-grid-filter-icon /
--vn-grid-filter-icon-active theme tokens (see
15-themes-implementation.md). This lets each theme ship
a different funnel without any JS change; the active class just swaps the mask to
the filled variant.
The funnel is absolutely positioned (right: 13px) and therefore out of flow,
so the header label reserves right-side space for it to keep the column's text
and the inline sort indicator clear of the funnel. A :has() rule applies
padding-right to .vn-grid-header-label only on headers that actually render
a funnel — without it, a sorted column's sort arrow would collide with and hide
the funnel.
Per-column opt-out & targeting
Mirrors sortable:
<vn-grid-column field="avatar" filterable="false"></vn-grid-column>
<!-- filter a different field than is displayed (custom renderer) -->
<vn-grid-column field="EmployeeID" filter-fields="Employee.LastName" filter-type="string"></vn-grid-column>
<!-- composite column: filter several backing fields, OR-ed together (fan-out) -->
<vn-grid-column field="Employee.Location" filter-fields="Employee.City,Employee.Country" filter-type="string"></vn-grid-column>
filter-fields accepts a comma-separated list of dotted field paths. With a
single field this is the plain remap case; with several it covers composite /
derived columns (e.g. a "City, Country" cell) that have no single backing scalar.
On the OData manager each path is converted .→/.
Fan-out semantics (multi-field columns)
Each condition is evaluated against every backing field, and the per-field results are combined by operator polarity:
- Positive operators (
contains, …) join with OR — a row matches when any field matches:(contains(Employee/City,'…') or contains(Employee/Country,'…')). - Negative / absence operators (
notContains,notEquals,isEmpty, …) join with AND — by De Morgan, "the displayed value does not match" ⟺ "no field matches":(not contains(City,'…') and not contains(Country,'…')). (Plain OR here would be a bug — it passes almost every row.)
The per-condition results then join by the column's own combinator (and/or).
Single-field columns collapse to one clause, so polarity is a no-op for them.
Operator reduction (multi-field columns)
Only operators that stay accurate across several fields are offered when a
column has 2+ filter-fields: contains, notContains, isEmpty,
isNotEmpty. Whole-value / anchored / ordering operators (equals,
startsWith, endsWith, in, >, between, dates) compare a single part
rather than the assembled value, so they're filtered out — both in the filter UI
(operatorsForColumn(type, fieldCount)) and in normalizeFilterModel (a stray
unsafe operator on a multi-field column is dropped, leaving the column inactive).
A multi-field numeric/date column is therefore left with only the presence
checks — multi-field fan-out is really a string-search feature. Single-field
columns keep the full type catalog. To filter a composite by its assembled
value (e.g. equals "Seattle, USA"), use a filterValueGetter (static) or a
server-side concat() expression instead of fan-out.
{ key: 'rating', type: 'number', renderCell, /* filters on raw number — no config */ }
{ key: 'orderTotal', filterable: false } // computed column, no server field
Grid-global toggle: initializeGrid({ filtering: { enabled: false } }).
Per-column operator allow-list (filterOperators)
A column can restrict its filter panel to a subset of its type's operator
catalog — for server-side sources whose backend can't honor every operator the
client type implies (e.g. a GraphQL API whose genre/format args are
exact-match tokens with no substring filter):
<vn-grid-column field="format" filter-operators="in,equals" default-filter-operator="in"></vn-grid-column>
{ key: 'format', filterOperators: ['in', 'equals'], defaultFilterOperator: 'in' }
Semantics (purely additive — absent ⇒ today's behavior, full type catalog):
- The panel's operator dropdown = type catalog ∩ fan-out-safe set ∩
filterOperators, catalog order preserved (unknown entries are ignored byoperatorsForColumn(type, fieldCount, allowed)). - The panel's preselected operator: the column's current-condition operator
when it has an active filter (persistence restore), else
defaultFilterOperatorwhen it's in the resolved list, else the first offered operator. - Normalization enforces it: the element carries the allow-list onto each
model entry as
operators(exactly likefields), andnormalizeFilterModeldrops conditions whose operator isn't allowed — so a persisted model or a hostsetColumnFilters()call can't smuggle a disallowed operator past the UI. - An empty
filterOperators: []is treated as unset (full catalog); usefilterable: falseto make a column unfilterable. setColumns()validates that everyfilterOperatorsentry is legal for the resolved filter type and thatdefaultFilterOperatoris legal and inside the allow-list — throwing in strict mode, warning otherwise (consistent with the other column-def checks).
defaultFilterOperator works on its own — it does not require a
filterOperators allow-list. Use it to change only the operator a column's
panel opens on. For example, a string column whose type default is Contains but
which is more naturally an exact match:
<vn-grid-column key="status" title="Status" filter-type="string"
default-filter-operator="equals"></vn-grid-column>
{ key: 'status', filterType: 'string', defaultFilterOperator: 'equals' }
The full operator catalog is still offered; only the initially-selected operator changes (and only when the column has no active filter — a persisted filter's operator still wins on reopen).
Boolean column type
type: 'boolean' is a first-class type: strict tri-state (true/false/empty),
a default read-only checkbox renderer (checked / unchecked / disabled-unchecked
for empty), a text fallback in _formatByType for export (✓/✗/``), a
false < true sort comparator, and the boolean filter operators above. Raw
values are coerced via VanillaGrid._coerceBoolean (strict by default; opt into
1/0,'yes'/'no',… with booleanCoerce: 'loose'). A custom renderCell
overrides the default checkbox.
Files
| File | Role |
|---|---|
filter-model.js |
catalog + normalize/validate/clone (pure, shared) |
data-managers/data-manager.js |
base no-op contract |
data-managers/static-data-manager.js |
predicate engine |
data-managers/odata-data-manager.js |
$filter serialization + composite |
vanilla-grid-element.js |
delegation + filterable enforcement + attributes |
vanilla-grid.js |
boolean type (_coerceBoolean, _formatByType), filtering.enabled |
features/rendering.feature.js |
boolean checkbox renderer |
features/sorting-comparator.feature.js |
boolean comparator |
features/header-menu.feature.js |
header-menu filter UI |