# Custom filters

> Register a filter kind of your own, with its own editor in the column's filter menu, when text, number, date and set filters do not fit.

The built-in filter menu covers text, numbers, dates and sets. When a column needs another rule (a band of values, a lot-size check, "within N km"), register a filter kind with `filterTypes` and point the column at it with `filter: 'name'`. The menu keeps its frame (title, **Clear**, **Apply**, Enter to apply), and your component edits the middle.

Open the funnel on **Chg %**: it shows a registered `move` filter that keeps rows by how far they moved.

Example: A registered filter kind (custom-filters.svelte)

```svelte
<script lang="ts">
  import { Grid, type ColumnDef } from 'bo-grid';
  import { quotes } from './data';
  import { move } from './custom-filters-move';

  const columns: ColumnDef[] = [
    { type: 'text', key: 'symbol', header: 'Symbol', sub: 'name', width: 170 },
    { type: 'price', key: 'price', header: 'Price' },
    // This column's funnel opens the registered `move` editor.
    { type: 'percent', key: 'change', header: 'Chg %', filter: 'move' },
    { type: 'volume', key: 'volume', header: 'Volume', flex: 1 },
  ];
  let rows = $state(quotes());
</script>

<Grid {rows} {columns} height={300} filterMenu filterTypes={{ move }} />
```

data.ts:

```ts
// Small, fixed data for the docs' examples: every example reads the same
// symbols, so a reader compares features, not datasets.
// A type, not an interface: it then fits GridRow's index signature.
export type Quote = {
  id: number;
  symbol: string;
  name: string;
  sector: string;
  price: number;
  change: number;
  volume: number;
};

const BASE: Omit<Quote, 'id'>[] = [
  { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 },
  { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 },
  { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 },
  { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 },
  { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 },
  { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 },
  { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 },
  { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 },
  { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 },
  { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 },
  { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 },
  { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 },
];

/** A fresh copy of the quotes, so one example's edits never reach another. */
export function quotes(): Quote[] {
  return BASE.map((q, i) => ({ id: i + 1, ...q }));
}
```

custom-filters-move.ts:

```ts
import type { CustomFilter, FilterTypeDef } from 'bo-grid';
import MoveEditor from './custom-filters-editor.svelte';

export type Move = 'down' | 'flat' | 'up';

/** A registered filter: a plain object with a `kind`, so it saves and restores like any other. */
export interface MoveFilter extends CustomFilter {
  kind: 'move';
  moves: Move[];
}

export const MOVES: { id: Move; label: string }[] = [
  { id: 'down', label: 'Down more than 0.5%' },
  { id: 'flat', label: 'Within ±0.5%' },
  { id: 'up', label: 'Up more than 0.5%' },
];

const moveOf = (pct: number): Move => (pct < -0.5 ? 'down' : pct > 0.5 ? 'up' : 'flat');

export const move: FilterTypeDef<MoveFilter> = {
  component: MoveEditor,
  isActive: (f) => f.moves.length > 0,
  test: (value, f) => f.moves.includes(moveOf(Number(value))),
};
```

## Define the filter kind

A filter kind is a test, an optional "is it narrowing anything" check, and an editor component:

```ts
import type { CustomFilter, FilterTypeDef } from 'bo-grid';
import MoveEditor from './MoveEditor.svelte';

type Move = 'down' | 'flat' | 'up';

export interface MoveFilter extends CustomFilter {
  kind: 'move';
  moves: Move[];
}

const moveOf = (pct: number): Move => (pct < -0.5 ? 'down' : pct > 0.5 ? 'up' : 'flat');

export const move: FilterTypeDef<MoveFilter> = {
  component: MoveEditor,
  isActive: (f) => f.moves.length > 0,            // optional; by default always active
  test: (value, f) => f.moves.includes(moveOf(Number(value))),
};
```

`test(value, filter, row)` decides one cell and runs only while the filter is active.

## Register it

Pass the kinds to the grid by name, and select one on a column:

```svelte
<script lang="ts">
  import { Grid, type ColumnDef } from 'bo-grid';
  import { move } from './move';

  const columns: ColumnDef[] = [
    { type: 'percent', key: 'change', header: 'Chg %', filter: 'move' },
    // …
  ];
</script>

<Grid {rows} {columns} height={480} filterMenu filterTypes={{ move }} />
```

## Write the editor

The editor gets the draft as `filter` (the active filter when the menu opened, or `null`) and calls `onChange(next)` as the user edits. **Apply** commits the draft when it is active, and **Clear** removes the filter. Pass `null` for "no filter".

```svelte
<script lang="ts">
  import type { FilterEditorProps } from 'bo-grid';
  import type { MoveFilter } from './move';

  let { filter, onChange }: FilterEditorProps<MoveFilter> = $props();
  const picked = $derived(new Set(filter?.moves ?? []));

  function toggle(id: 'down' | 'flat' | 'up') {
    const next = new Set(picked);
    if (next.has(id)) next.delete(id);
    else next.add(id);
    onChange(next.size ? { kind: 'move', moves: [...next] } : null);
  }
</script>

<label><input type="checkbox" checked={picked.has('up')} onchange={() => toggle('up')} /> Up more than 0.5%</label>
<!-- … -->
```

The editor also receives `column`, `labels` and, when the kind sets `needsValues: true`, the column's distinct values as `values` (as the set filter uses them).

## Saving and server sources

A custom filter is a plain object with a `kind`, so it goes through `columnFilters`, `onFilterChange` and `getState()` unchanged, and reaches a `RowSource` in `params.columnFilters`. To apply it in `createArraySource`, pass the same kinds:

```ts
const source = createArraySource(rows, { filterTypes: { move } });
```

A filter whose kind is not registered is treated as inactive: it filters nothing, rather than emptying the grid.

## Reference

- `filterTypes`: `FilterTypes` (default `undefined`) — Custom filter kinds, by name. A column selects one with `filter: 'name'`; the header filter menu draws its `component` and the view keeps rows for which `test` passes. An unregistered kind filters nothing.

  ```svelte
  <Grid {rows} {columns} height={480} filterMenu
    filterTypes={{ band: { component: BandFilter, test: (v, f) => Number(v) >= f.min && Number(v) <= f.max } }} />
  ```

- `filterMenu`: `boolean` (default `false`) — Enable a per-column header filter menu (lazy-loaded on first open). Each filterable column shows a funnel; the menu's control matches the column type (text/number/date). Override or disable per column with `col.filter`. Works in source mode too (filters are delegated to the `RowSource`); set filters need in-memory data.
- `columnFilters`: `Record<string, AnyFilter>` (default `undefined`) — Controlled column filters (keyed by column key). When set, the grid reflects these and reports changes via `onFilterChange` instead of holding its own. Omit for uncontrolled filtering.

  ```svelte
  <Grid {rows} {columns} height={480} filterMenu
    columnFilters={{ sym: { kind: 'text', op: 'starts', q: 'V' }, last: { kind: 'number', op: 'gt', a: 50 } }} />
  ```

- `onFilterChange`: `(filters: Record<string, AnyFilter>) => void` — Called with the full column-filter map whenever a header filter changes.

  ```svelte
  <Grid {rows} {columns} height={480} filterMenu {columnFilters} onFilterChange={(next) => (columnFilters = next)} />
  ```


- `filter`: `false | FilterKind | (string & {})` (default `'date' for date columns, 'number' for number types, else 'text'`) — Header filter-menu control for this column (requires `filterMenu` on <Grid>). `'set'` shows a value checklist, a registered `filterTypes` name draws that filter, and `false` disables filtering for this column.

  ```ts
  { type: 'badge', key: 'status', header: 'Status', filter: 'set' }
  ```

---
Page: https://bonguynvan.github.io/bo-grid/docs/custom-filters/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt
