# Filtering Plugin

> Add column-level filtering with built-in filter panel and custom filters.

The Filtering plugin adds column header filters with text search, dropdown options, and custom filter panels. It supports both local filtering for small datasets and async handlers for server-side filtering on large datasets.

## Installation

```ts
import '@toolbox-web/grid/features/filtering';
```

## Basic Usage

#### TypeScript

```ts
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/filtering';

const grid = queryGrid('tbw-grid');
grid.gridConfig = {
  columns: [
    { field: 'name', header: 'Name', filterable: true },
    { field: 'status', header: 'Status', filterable: true },
    { field: 'email', header: 'Email', filterable: true },
  ],
  features: { filtering: { debounceMs: 300 } },
};
grid.rows = data;
```

#### React

```tsx
import '@toolbox-web/grid-react/features/filtering';
import { DataGrid } from '@toolbox-web/grid-react';

function MyGrid({ data }) {
  return (
    <DataGrid
      rows={data}
      columns={[
        { field: 'name', header: 'Name', filterable: true },
        { field: 'status', header: 'Status', filterable: true },
        { field: 'email', header: 'Email', filterable: true },
      ]}
      filtering={{ debounceMs: 300 }}
      style={{ height: '400px' }}
    />
  );
}
```

#### Vue

```html
<script setup>
import '@toolbox-web/grid-vue/features/filtering';
import { TbwGrid, TbwGridColumn } from '@toolbox-web/grid-vue';

const data = [
  { name: 'Alice', status: 'active', email: 'alice@example.com' },
  { name: 'Bob', status: 'inactive', email: 'bob@example.com' },
];
</script>

<template>
  <TbwGrid :rows="data" :filtering="{ debounceMs: 300 }" style="height: 400px">
    <TbwGridColumn field="name" header="Name" filterable />
    <TbwGridColumn field="status" header="Status" filterable />
    <TbwGridColumn field="email" header="Email" filterable />
  </TbwGrid>
</template>
```

#### Angular

```typescript
// Feature import - enables the [filtering] input
import { GridFilteringDirective } from '@toolbox-web/grid-angular/features/filtering';
import { Component } from '@angular/core';
import { Grid } from '@toolbox-web/grid-angular';
import type { ColumnConfig } from '@toolbox-web/grid';

@Component({
  selector: 'app-my-grid',
  imports: [Grid, GridFilteringDirective],
  template: `
    <tbw-grid
      [rows]="rows"
      [columns]="columns"
      [filtering]="{ debounceMs: 300 }"
      style="height: 400px; display: block;">
    </tbw-grid>
  `,
})
export class MyGridComponent {
  rows = [...];

  columns: ColumnConfig[] = [
    { field: 'name', header: 'Name', filterable: true },
    { field: 'status', header: 'Status', filterable: true },
    { field: 'email', header: 'Email', filterable: true },
  ];
}
```

## Demos

### Default Filtering

```ts
// FilteringDefaultDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/filtering';

const container = document.getElementById('filtering-default-demo');
if (container) {
  const sampleData = [
    { id: 1, name: 'Alice Johnson', department: 'Engineering', salary: 95000, status: 'Active' },
    { id: 2, name: 'Bob Smith', department: 'Marketing', salary: 75000, status: 'Active' },
    { id: 3, name: 'Carol Williams', department: 'Engineering', salary: 105000, status: 'On Leave' },
    { id: 4, name: 'Dan Brown', department: 'Sales', salary: 85000, status: 'Active' },
    { id: 5, name: 'Eve Davis', department: 'Marketing', salary: 72000, status: 'Inactive' },
    { id: 6, name: 'Frank Miller', department: 'Engineering', salary: 98000, status: 'Active' },
    { id: 7, name: 'Grace Lee', department: 'Sales', salary: 82000, status: 'Active' },
    { id: 8, name: 'Henry Wilson', department: 'HR', salary: 68000, status: 'Active' },
    { id: 9, name: 'Ivy Chen', department: 'Engineering', salary: 112000, status: 'Active' },
    { id: 10, name: 'Jack Taylor', department: 'Marketing', salary: 78000, status: 'On Leave' },
  ];
  const columns = [
    { field: 'id', header: 'ID', type: 'number', filterable: false },
    { field: 'name', header: 'Name' },
    { field: 'department', header: 'Department' },
    { field: 'salary', header: 'Salary', type: 'number' },
    { field: 'status', header: 'Status' },
  ];

  const grid = queryGrid('tbw-grid', container)!;

  function rebuild(debounceMs = 150, caseSensitive = false) {
    grid.gridConfig = {
      columns,
      features: { filtering: { debounceMs, caseSensitive } },
    };
    grid.rows = sampleData;
  }

  rebuild();

  container.addEventListener('control-change', ((e: CustomEvent) => {
    const v = e.detail.allValues;
    rebuild(v.debounceMs as number, v.caseSensitive as boolean);
  }) as EventListener);
}
```

Type in column header filter inputs to filter rows. The plugin debounces input to avoid excessive re-filtering while typing.

### Custom Filter Panel

```ts
// FilteringCustomFilterPanelDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';

import '@toolbox-web/grid/features/filtering';

const container = document.getElementById('filtering-custom-filter-panel-demo');
if (container) {
  // Sample data for filtering demos
  const sampleData = [
    { id: 1, name: 'Alice Johnson', department: 'Engineering', salary: 95000, status: 'Active' },
    { id: 2, name: 'Bob Smith', department: 'Marketing', salary: 75000, status: 'Active' },
    { id: 3, name: 'Carol Williams', department: 'Engineering', salary: 105000, status: 'On Leave' },
    { id: 4, name: 'Dan Brown', department: 'Sales', salary: 85000, status: 'Active' },
    { id: 5, name: 'Eve Davis', department: 'Marketing', salary: 72000, status: 'Inactive' },
    { id: 6, name: 'Frank Miller', department: 'Engineering', salary: 98000, status: 'Active' },
    { id: 7, name: 'Grace Lee', department: 'Sales', salary: 82000, status: 'Active' },
    { id: 8, name: 'Henry Wilson', department: 'HR', salary: 68000, status: 'Active' },
    { id: 9, name: 'Ivy Chen', department: 'Engineering', salary: 112000, status: 'Active' },
    { id: 10, name: 'Jack Taylor', department: 'Marketing', salary: 78000, status: 'On Leave' },
  ];
  const columns = [
    { field: 'id', header: 'ID', type: 'number', filterable: false },
    { field: 'name', header: 'Name' },
    { field: 'department', header: 'Department' },
    { field: 'salary', header: 'Salary', type: 'number' },
    { field: 'status', header: 'Status' },
  ];

  const grid = queryGrid('tbw-grid', container);

      // Custom filter panel for Status column (radio-button style)
      const statusFilterPanel = (container, params) => {
        container.innerHTML = `
          <div style="padding: 8px; min-width: 140px;">
            <div style="font-weight: 600; margin-bottom: 8px; color: var(--tbw-color-fg);">
              Filter by Status
            </div>
            <div class="status-options"></div>
            <button class="clear-btn" style="margin-top: 8px; width: 100%; padding: 6px; cursor: pointer;">
              Clear
            </button>
          </div>
        `;

        const optionsDiv = container.querySelector('.status-options');
        if (!optionsDiv) return;

        const options = ['All', ...params.uniqueValues.map((v) => String(v))];

        options.forEach((opt) => {
          const label = document.createElement('label');
          label.style.cssText = 'display: flex; align-items: center; gap: 6px; padding: 4px 0; cursor: pointer;';
          const isAll = opt === 'All';
          label.innerHTML = `<input type="radio" name="status" value="${opt}" ${
            isAll && params.excludedValues.size === 0 ? 'checked' : ''
          }> ${opt}`;
          const input = label.querySelector('input');
          if (input) {
            input.addEventListener('change', () => {
              if (isAll) {
                params.clearFilter();
              } else {
                // Exclude all except selected
                const excluded = params.uniqueValues.filter((v) => String(v) !== opt);
                params.applySetFilter(excluded);
              }
            });
          }
          optionsDiv.appendChild(label);
        });

        const clearBtn = container.querySelector('.clear-btn');
        if (clearBtn) {
          clearBtn.addEventListener('click', () => params.clearFilter());
        }
      };

      grid.gridConfig = {
        columns,
        features: {
          filtering: {
            debounceMs: 150,
            caseSensitive: false,
            filterPanelRenderer: (container, params) => {
              if (params.field === 'status') {
                statusFilterPanel(container, params);
              } else {
                return undefined; // Use default panel
              }
            },
          },
        },
      };
      grid.rows = sampleData;
}
```

The `filterPanelRenderer` option lets you replace the default filter panel with custom UI.
When the renderer returns `undefined`, the default panel is used for that column.

#### FilterPanelParams

Your custom renderer receives a `params` object with:

**Properties**

| Property         | Type           | Description                            |
| ---------------- | -------------- | -------------------------------------- |
| `field`          | `string`       | The field being filtered               |
| `column`         | `ColumnConfig` | Column configuration                   |
| `uniqueValues`   | `unknown[]`    | All unique values for this field       |
| `excludedValues` | `Set<unknown>` | Currently excluded values (set filter) |
| `searchText`     | `string`       | Current search text                    |
| `currentFilter`  | `FilterModel?` | Active filter model for this field (if any) — use to pre-populate custom UI |

**Methods**

| Method            | Signature                                                                           | Description                               |
| ----------------- | ----------------------------------------------------------------------------------- | ----------------------------------------- |
| `applySetFilter`  | `(excluded: unknown[], valueTo?: unknown) => void`                                  | Apply a set filter; optional `valueTo` stores metadata alongside the filter |
| `applyTextFilter` | `(op: FilterOperator, val: string \| number, valueTo?: string \| number) => void` | Apply a text/number/date filter with operator |
| `clearFilter`     | `() => void`                                                                        | Clear the filter for this field           |
| `closePanel`      | `() => void`                                                                        | Close the filter panel                    |

#### Example: Radio-button Filter

#### TypeScript

```ts
import { queryGrid } from '@toolbox-web/grid';

const grid = queryGrid('tbw-grid');

grid.gridConfig = {
  features: {
    filtering: {
      filterPanelRenderer: (container, params) => {
        // Custom panel only for 'status' column
        if (params.field !== 'status') return undefined;

        const activeValue = params.currentFilter?.value;

        container.innerHTML = `
          <div style="padding: 8px;">
            <label><input type="radio" name="status" value="all" ${!activeValue ? 'checked' : ''}> All</label>
            ${params.uniqueValues
              .map((v) => `<label><input type="radio" name="status" value="${v}" ${v === activeValue ? 'checked' : ''}> ${v}</label>`)
              .join('')}
          </div>
        `;

        container.querySelectorAll('input').forEach((input) => {
          input.addEventListener('change', () => {
            if (input.value === 'all') {
              params.clearFilter();
            } else {
              const excluded = params.uniqueValues.filter((v) => v !== input.value);
              params.applySetFilter(excluded);
            }
            params.closePanel();
          });
        });
      },
    },
  },
};
```

#### React

```tsx
import '@toolbox-web/grid-react/features/filtering';
import { DataGrid } from '@toolbox-web/grid-react';

// React filterPanelRenderer receives only params (no container)
// and returns JSX — the adapter bridges it automatically
function StatusFilterPanel({ params }) {
  if (params.field !== 'status') return undefined;

  const activeValue = params.currentFilter?.value;

  return (
    <div style={{ padding: 8 }}>
      {['all', ...params.uniqueValues].map((v) => (
        <label key={v}>
          <input
            type="radio"
            name="status"
            value={v}
            defaultChecked={v === 'all' ? !activeValue : v === activeValue}
            onChange={() => {
              if (v === 'all') {
                params.clearFilter();
              } else {
                const excluded = params.uniqueValues.filter((u) => u !== v);
                params.applySetFilter(excluded);
              }
              params.closePanel();
            }}
          />
          {v === 'all' ? ' All' : ` ${v}`}
        </label>
      ))}
    </div>
  );
}

function MyGrid({ data }) {
  return (
    <DataGrid
      rows={data}
      columns={[
        { field: 'name', header: 'Name', filterable: true },
        { field: 'status', header: 'Status', filterable: true },
      ]}
      filtering={{
        filterPanelRenderer: (params) => <StatusFilterPanel params={params} />,
      }}
    />
  );
}
```

#### Vue

```html
<script setup>
import '@toolbox-web/grid-vue/features/filtering';
import { TbwGrid, TbwGridColumn } from '@toolbox-web/grid-vue';
import { h } from 'vue';

const data = [...];

// Vue filterPanelRenderer receives only params and returns a VNode
const filterPanelRenderer = (params) => {
  if (params.field !== 'status') return undefined;

  const activeValue = params.currentFilter?.value;

  return h('div', { style: 'padding: 8px' },
    ['all', ...params.uniqueValues].map((v) =>
      h('label', [
        h('input', {
          type: 'radio',
          name: 'status',
          value: v,
          checked: v === 'all' ? !activeValue : v === activeValue,
          onChange: () => {
            if (v === 'all') {
              params.clearFilter();
            } else {
              const excluded = params.uniqueValues.filter((u) => u !== v);
              params.applySetFilter(excluded);
            }
            params.closePanel();
          },
        }),
        v === 'all' ? ' All' : ` ${v}`,
      ])
    )
  );
};
</script>

<template>
  <TbwGrid :rows="data" :filtering="{ filterPanelRenderer }" style="height: 400px">
    <TbwGridColumn field="name" header="Name" filterable />
    <TbwGridColumn field="status" header="Status" filterable />
  </TbwGrid>
</template>
```

#### Angular

```typescript
// Angular uses a component class extending BaseFilterPanel
import { Component, ViewChild, ElementRef } from '@angular/core';
import { BaseFilterPanel } from '@toolbox-web/grid-angular/features/filtering';

@Component({
  selector: 'app-status-filter',
  template: `
    <div style="padding: 8px;">
      @for (value of ['all'].concat(params().uniqueValues); track value) {
        <label>
          <input
            type="radio"
            name="status"
            [value]="value"
            [checked]="value === 'all' ? !activeValue : value === activeValue"
            (change)="onSelect(value)" />
          {{ value === 'all' ? 'All' : value }}
        </label>
      }
    </div>
  `,
})
export class StatusFilterComponent extends BaseFilterPanel {
  get activeValue() {
    return this.params().currentFilter?.value;
  }

  applyFilter(): void { /* Not used — onSelect handles it */ }

  onSelect(value: string) {
    if (value === 'all') {
      this.params().clearFilter();
    } else {
      const excluded = this.params().uniqueValues.filter((v) => v !== value);
      this.params().applySetFilter(excluded);
    }
    this.params().closePanel();
  }
}
```

```typescript
// In your grid component — pass the component class as filterPanelRenderer
import { GridFilteringDirective } from '@toolbox-web/grid-angular/features/filtering';
import { Grid } from '@toolbox-web/grid-angular';
import { StatusFilterComponent } from './status-filter.component';

@Component({
  imports: [Grid, GridFilteringDirective],
  template: `<tbw-grid [rows]="data" [columns]="columns" [filtering]="filterConfig" />`
})
export class MyGridComponent {
  data = [...];
  columns = [
    { field: 'name', header: 'Name', filterable: true },
    { field: 'status', header: 'Status', filterable: true },
  ];

  filterConfig = {
    filterPanelRenderer: StatusFilterComponent,
  };
}
```

### Type-Specific Filter Panels

```ts
// FilteringTypeSpecificFiltersDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';

import '@toolbox-web/grid/features/filtering';

const container = document.getElementById('filtering-type-specific-filters-demo');
if (container) {
  const typedData = [
    { id: 1, name: 'Alice Johnson', salary: 95000, hireDate: '2020-03-15', rating: 4.5 },
    { id: 2, name: 'Bob Smith', salary: 75000, hireDate: '2019-07-22', rating: 3.8 },
    { id: 3, name: 'Carol Williams', salary: 105000, hireDate: '2018-11-10', rating: 4.9 },
    { id: 4, name: 'Dan Brown', salary: 85000, hireDate: '2021-01-05', rating: 4.2 },
    { id: 5, name: 'Eve Davis', salary: 72000, hireDate: '2022-06-18', rating: 3.5 },
    { id: 6, name: 'Frank Miller', salary: 98000, hireDate: '2017-09-30', rating: 4.7 },
    { id: 7, name: 'Grace Lee', salary: 82000, hireDate: '2020-12-01', rating: 4.0 },
    { id: 8, name: 'Henry Wilson', salary: 68000, hireDate: '2023-02-14', rating: 3.2 },
  ];

  const grid = queryGrid('tbw-grid', container);

      grid.gridConfig = {
        columns: [
          { field: 'id', header: 'ID', type: 'number', filterable: false },
          { field: 'name', header: 'Name' }, // Set filter (default)
          {
            field: 'salary',
            header: 'Salary',
            type: 'number', // Range slider filter
            filterParams: { min: 50000, max: 150000, step: 5000 },
          },
          {
            field: 'hireDate',
            header: 'Hire Date',
            type: 'date', // Date range picker filter
            filterParams: { min: '2015-01-01', max: '2025-12-31' },
          },
          {
            field: 'rating',
            header: 'Rating',
            type: 'number', // Range slider with smaller step
            filterParams: { min: 1, max: 5, step: 0.1 },
          },
        ],
        features: { filtering: true },
      };
      grid.rows = typedData;
}
```

The Filtering plugin automatically displays appropriate filter UIs based on column type:

| Column Type | Filter UI | Description |
| --- | --- | --- |
| `number` | **Range Slider** | Dual-thumb slider with min/max inputs. Includes a **"Blank" checkbox** to filter rows with no value. |
| `date` | **Date Range Picker** | From/to date inputs with range selection. Includes a **"Show only blank" checkbox** for empty dates. |
| (other) | **Checkbox Set** | Standard multi-select with search. Rows with `null`/`undefined`/empty values appear as a **(Blank)** entry. |

Configure bounds and step via `filterParams` on the column:

```ts
const columns = [
  {
    field: 'salary',
    header: 'Salary',
    type: 'number',
    filterParams: { min: 50000, max: 150000, step: 5000 },
  },
  {
    field: 'hireDate',
    header: 'Hire Date',
    type: 'date',
    filterParams: { min: '2015-01-01', max: '2025-12-31' },
  },
];
```

If `filterParams` is not provided, the plugin auto-detects min/max from the data.

## Server-Side Filtering

```ts
// FilteringAsyncFilteringDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';

import '@toolbox-web/grid/features/filtering';

const container = document.getElementById('filtering-async-filtering-demo');
if (container) {
  // Sample data for filtering demos
  const sampleData = [
    { id: 1, name: 'Alice Johnson', department: 'Engineering', salary: 95000, status: 'Active' },
    { id: 2, name: 'Bob Smith', department: 'Marketing', salary: 75000, status: 'Active' },
    { id: 3, name: 'Carol Williams', department: 'Engineering', salary: 105000, status: 'On Leave' },
    { id: 4, name: 'Dan Brown', department: 'Sales', salary: 85000, status: 'Active' },
    { id: 5, name: 'Eve Davis', department: 'Marketing', salary: 72000, status: 'Inactive' },
    { id: 6, name: 'Frank Miller', department: 'Engineering', salary: 98000, status: 'Active' },
    { id: 7, name: 'Grace Lee', department: 'Sales', salary: 82000, status: 'Active' },
    { id: 8, name: 'Henry Wilson', department: 'HR', salary: 68000, status: 'Active' },
    { id: 9, name: 'Ivy Chen', department: 'Engineering', salary: 112000, status: 'Active' },
    { id: 10, name: 'Jack Taylor', department: 'Marketing', salary: 78000, status: 'On Leave' },
  ];
  const columns = [
    { field: 'id', header: 'ID', type: 'number', filterable: false },
    { field: 'name', header: 'Name' },
    { field: 'department', header: 'Department' },
    { field: 'salary', header: 'Salary', type: 'number' },
    { field: 'status', header: 'Status' },
  ];

  function generateMockData(count: number) {
    const departments = ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'];
    const statuses = ['Active', 'Inactive', 'On Leave'];
    const data = [];
    for (let i = 0; i < count; i++) {
      data.push({
        id: i + 1,
        name: `Employee ${i + 1}`,
        department: departments[i % departments.length],
        salary: 50000 + Math.floor(Math.random() * 50000),
        status: statuses[i % statuses.length],
      });
    }
    return data;
  }

  const grid = queryGrid('tbw-grid', container);

      const serverData = generateMockData(1000);

      // Simulate server-side unique value extraction
      const getUniqueValues = async (field: string): Promise<unknown[]> => {
        await new Promise((r) => setTimeout(r, 200));
        const values = [...new Set(serverData.map((row) => (row as Record<string, unknown>)[field]))];
        return values.sort();
      };

      // Simulate server-side filtering
      // FilterModel uses { field, operator: 'notIn', value: excludedValues[] }
      const applyServerFilters = async (
        filters: { field: string; operator: string; value: unknown[] }[],
      ): Promise<typeof serverData> => {
        await new Promise((r) => setTimeout(r, 300));

        if (filters.length === 0) return serverData;

        return serverData.filter((row) => {
          return filters.every((filter) => {
            const excludedValues = filter.value;
            if (!excludedValues || excludedValues.length === 0) return true;

            const val = (row as Record<string, unknown>)[filter.field];
            // 'notIn' means exclude these values, so row passes if value is NOT in excluded list
            return !excludedValues.includes(val);
          });
        });
      };

      grid.gridConfig = {
        columns,
        features: {
          filtering: {
            valuesHandler: getUniqueValues,
            filterHandler: applyServerFilters,
          },
        },
      };

      grid.rows = serverData;
}
```

For large or server-side datasets, the default local filtering may not be suitable:

- **Not all unique values are available locally** for the filter panel
- **Filtering should happen on the backend** rather than in the browser

Use `valuesHandler` and `filterHandler` to customize this behavior:

```ts
grid.gridConfig = {
  columns: [...],
  features: {
    filtering: {
      // Fetch unique values for a column from the server
      valuesHandler: async (field, column) => {
        const response = await fetch(`/api/distinct-values?field=${field}`);
        return response.json(); // Returns: ['Engineering', 'Marketing', ...]
      },

      // Apply filters on the server
      // FilterModel: { field, type, operator: 'notIn', value: excludedValues[] }
      filterHandler: async (filters, currentRows) => {
        const response = await fetch('/api/data', {
          method: 'POST',
          body: JSON.stringify({ filters }),
        });
        return response.json();
      },
    },
  },
};
```

**Handler signatures:**

| Handler         | Signature                                                    | Returns                  |
| --------------- | ------------------------------------------------------------ | ------------------------ |
| `valuesHandler` | `(field: string, column: ColumnConfig) => Promise<T[]>`      | Unique values for filter |
| `filterHandler` | `(filters: FilterModel[], rows: T[]) => T[] \| Promise<T[]>` | Filtered rows            |

When `valuesHandler` is provided, the filter panel shows a loading indicator while fetching values.
When `filterHandler` is provided, filter application bypasses the local `processRows` hook.

## Configuration Options

### Grid-Level Toggle

You can disable filtering grid-wide using `gridConfig.filterable`:

```ts
grid.gridConfig = {
  filterable: false, // Disables ALL filtering, even on filterable columns
  features: { filtering: true },
};
```

This is useful for toggling filtering on/off at runtime without removing the plugin.

### Plugin Options

| Option                | Type                  | Default | Description                                                       |
| --------------------- | --------------------- | ------- | ----------------------------------------------------------------- |
| `debounceMs`          | `number`              | `300`   | Debounce delay for filter input                                   |
| `caseSensitive`       | `boolean`             | `false` | Case-sensitive string matching                                    |
| `trackColumnState`    | `boolean`             | `false` | Include filter state in column state persistence                  |
| `filterPanelRenderer` | `FilterPanelRenderer` | -       | Custom filter panel renderer                                      |
| `valuesHandler`       | `FilterValuesHandler` | -       | Async handler to fetch unique filter values                       |
| `filterHandler`       | `FilterHandler<TRow>` | -       | Async handler to apply filters remotely                           |

## Column Configuration

```ts
const columns = [
  {
    field: 'name',
    filterable: true, // Enable filtering
    filterType: 'text', // 'text' | 'select' | 'number' | 'date'
  },
  {
    field: 'status',
    filterable: true,
    filterType: 'select',
    filterOptions: ['Active', 'Inactive', 'Pending'],
  },
];
```

## Programmatic API

```ts
const plugin = grid.getPluginByName('filtering');

// Set filter value
plugin.setFilter('name', 'Alice');

// Get current filters
const filters = plugin.getFilters();

// Clear all filters
plugin.clearFilters();

// Clear specific filter
plugin.clearFilter('name');

// Silent mode: update filter state without triggering re-render
// Useful for batching multiple filter changes
plugin.setFilter('name', 'Alice', { silent: true });
plugin.setFilter('dept', 'Engineering'); // last call applies all
```

## Column State Persistence

By default, filter state is **not** included in column state and does not fire `column-state-change`.
Enable `trackColumnState` to opt in:

```ts
features: { filtering: { trackColumnState: true } }
```

When enabled:
- Filter state is included in `getColumnState()` / `columnState` snapshots
- Filter changes fire the `column-state-change` event (debounced)
- `applyColumnState()` / `columnState` restores filter state

This is useful when you want to persist and restore the full grid state (including active filters)
via `localStorage` or a server:

```ts
// Save full state including filters
grid.on('column-state-change', (state) => {
  localStorage.setItem('grid-state', JSON.stringify(state));
});

// Restore state (filters are re-applied automatically)
gridConfig: {
  columnState: JSON.parse(localStorage.getItem('grid-state')),
  features: { filtering: { trackColumnState: true } },
}
```

## Events

```ts
// FilteringFilterEventsDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/filtering';

const container = document.getElementById('filtering-filter-events-demo');
if (container) {
  const sampleData = [
    { id: 1, name: 'Alice Johnson', department: 'Engineering', salary: 95000, status: 'Active' },
    { id: 2, name: 'Bob Smith', department: 'Marketing', salary: 75000, status: 'Active' },
    { id: 3, name: 'Carol Williams', department: 'Engineering', salary: 105000, status: 'On Leave' },
    { id: 4, name: 'Dan Brown', department: 'Sales', salary: 85000, status: 'Active' },
    { id: 5, name: 'Eve Davis', department: 'Marketing', salary: 72000, status: 'Inactive' },
    { id: 6, name: 'Frank Miller', department: 'Engineering', salary: 98000, status: 'Active' },
    { id: 7, name: 'Grace Lee', department: 'Sales', salary: 82000, status: 'Active' },
    { id: 8, name: 'Henry Wilson', department: 'HR', salary: 68000, status: 'Active' },
    { id: 9, name: 'Ivy Chen', department: 'Engineering', salary: 112000, status: 'Active' },
    { id: 10, name: 'Jack Taylor', department: 'Marketing', salary: 78000, status: 'On Leave' },
  ];

  const grid = queryGrid('tbw-grid', container);

  grid.gridConfig = {
    columns: [
      { field: 'name', header: 'Name' },
      { field: 'department', header: 'Department' },
      { field: 'status', header: 'Status' },
    ],
    features: { filtering: { debounceMs: 300 } },
  };

  grid.rows = sampleData;

  const log = container.querySelector('#filter-event-log');
  const clearBtn = container.querySelector('#clear-filter-log');

  function addLog(type: string, detail: string) {
    if (!log) return;
    const msg = document.createElement('div');
    msg.innerHTML = `<span class="event-type">[${type}]</span> ${detail}`;
    log.insertBefore(msg, log.firstChild);
    while (log.children.length > 15) {
      log.lastChild?.remove();
    }
  }

  clearBtn?.addEventListener('click', () => {
    if (log) log.innerHTML = '';
  });

  grid.on('filter-change', (d) => {
    const filterCount = d.filters?.length || 0;
    const fields = d.filters?.map((f: { field: string }) => f.field).join(', ') || 'none';
    addLog('filter-change', `${filterCount} filter(s) on: ${fields}`);
  });
}
```

The FilteringPlugin emits events when filter state changes. Open filter panels and
apply filters to see the events:

| Event | Description |
| ----- | ----------- |
| `filter-change` | Fired when filters are applied, changed, or cleared |

### `filter-change` Detail

```ts
interface FilterChangeDetail {
  filters: FilterModel[];  // Active filter configurations
}
```

## Styling

The filter panel and inputs support CSS custom properties for theming. Override these on `tbw-grid` or a parent container:

### CSS Custom Properties

| Property | Default | Description |
| --- | --- | --- |
| `--tbw-filter-panel-bg` | `var(--tbw-color-panel-bg)` | Panel background |
| `--tbw-filter-panel-fg` | `var(--tbw-color-fg)` | Panel text color |
| `--tbw-filter-panel-border` | `var(--tbw-color-border)` | Panel border |
| `--tbw-filter-panel-radius` | `var(--tbw-border-radius)` | Panel border radius |
| `--tbw-filter-panel-shadow` | `var(--tbw-color-shadow)` | Panel shadow |
| `--tbw-filter-input-bg` | `var(--tbw-color-bg)` | Input background |
| `--tbw-filter-input-border` | `var(--tbw-color-border)` | Input border |
| `--tbw-filter-input-focus` | `var(--tbw-color-accent)` | Input focus border |
| `--tbw-filter-active-color` | `var(--tbw-color-accent)` | Active filter indicator |
| `--tbw-filter-btn-padding` | `var(--tbw-button-padding)` | Filter button padding |
| `--tbw-filter-btn-font-weight` | `500` | Filter button font weight |
| `--tbw-filter-btn-min-height` | `auto` | Filter button min height |
| `--tbw-filter-search-padding` | `var(--tbw-spacing-sm) var(--tbw-spacing-md)` | Search input padding |
| `--tbw-filter-item-height` | `28px` | Filter value item height (for virtualization) |
| `--tbw-filter-btn-display` | `inline-flex` | Filter button display (set to `none` to hide) |
| `--tbw-filter-btn-visibility` | `visible` | Filter button visibility |
| `--tbw-panel-padding` | `0.75em` | Panel padding |
| `--tbw-panel-gap` | `0.5em` | Gap between elements |
| `--tbw-animation-duration` | `200ms` | Panel open/close animation |

### Theming Filter Panels

The filter panel is rendered in `document.body` for proper z-index stacking. To apply theme CSS variables:

1. **Add a theme class to the grid** (e.g., `tbw-grid.eds-theme`)
2. **The class is automatically copied** to the filter panel

```css
/* Define theme on a CSS class */
.eds-theme {
  --tbw-filter-panel-bg: #f5f5f5;
  --tbw-filter-btn-padding: 8px 16px;
  --tbw-filter-btn-font-weight: 500;
  --tbw-filter-btn-min-height: 48px;
  --tbw-filter-item-height: 48px;
}
```

```html
<!-- Apply class to grid - it cascades to filter panel -->
<tbw-grid class="eds-theme" ...></tbw-grid>
```

### Hiding Filter Buttons Until Hover

To show filter buttons only when hovering over a column header:

```css
tbw-grid {
  --tbw-filter-btn-visibility: hidden;
}

tbw-grid .header-row .cell:hover .tbw-filter-btn,
tbw-grid .header-row .cell .tbw-filter-btn.active {
  visibility: visible;
}
```

### Example

```css
tbw-grid {
  /* Custom filter panel styling */
  --tbw-filter-panel-bg: #1e1e1e;
  --tbw-filter-panel-fg: #ffffff;
  --tbw-filter-panel-border: #444444;
  --tbw-filter-input-bg: #2d2d2d;
  --tbw-filter-input-border: #555555;
  --tbw-filter-active-color: #4fc3f7;
}
```

### CSS Classes

The filter panel uses these class names for advanced customization:

| Class | Element |
| --- | --- |
| `.tbw-filter-panel` | Dropdown panel container |
| `.tbw-filter-search` | Text search input |
| `.tbw-filter-list` | Options list container |
| `.tbw-filter-option` | Individual filter option |
| `.tbw-filter-option.selected` | Selected option |
| `.tbw-filter-buttons` | Action button group |
| `.tbw-filter-clear` | Clear filter button |
| `.tbw-filter-apply` | Apply filter button |

## Row Insertion with Active Filters

When filtering is active, assigning `rows` automatically re-filters the data — so a
newly inserted row may be hidden if it doesn't match the current filter. If you want
the row to appear exactly where you placed it (e.g., after a user clicks "Add Row"),
use `insertRow()`:

```ts
grid.insertRow(3, newRow); // stays at visible index 3, auto-animates
```

The row is also added to source data, so the next full `grid.rows = freshData`
assignment re-filters normally. See the
[API Reference → Insert & Remove Rows](/grid/api-reference.md#insert--remove-rows)
for full details.

:::tip
Do **not** use `insertRow()` for data refreshes (API responses,
WebSocket updates). In those cases just set `grid.rows = newData` and let
the filter re-apply.
:::

## See Also

- **[Multi-Sort](/grid/plugins/multi-sort.md)** — Multi-column sorting
- **[Server-Side Data](../server-side/)** — Server-side filtering via `filterHandler`
- **[Selection](/grid/plugins/selection.md)** — Row and cell selection
- **[Common Patterns](/grid/guides/common-patterns.md)** — Full application recipes using filtering
- **[Plugins Overview](/grid/plugins.md)** — Plugin compatibility and combinations
