> ## Documentation Index
> Fetch the complete documentation index at: https://anvil.servicetitan.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Filter Options

> Guide for adopting the new custom filter options, including a custom trigger icon, and for the changed custom filter defaults.

Unreleased

> [!NOTE]
>
> This guide covers changes to the **beta** FilterBar component. These APIs may
> continue to evolve before the stable release.

## Overview

Custom filters (`type: "custom"`) can now work and look like the built-in filter types:

* **`emptyValue`** - The value that means "no filter". Clear Filters and the drawer count respect it.
* **`isEqual`** - How the filter compares values. Defaults to a structural comparison.
* **`formatLabel`** - Trigger content while the filter is active.
* **`keepOpenOnChange`** - Keeps the popover open between changes when `controlledFiltering` is off.
* **`clear`** - Adds a footer Clear button to the popover.
* **`commitOnSelect`** - Commits each popover change and closes.
* **`FilterRenderProps`** - Adds `committedValue`, `isDraft`, `commit`, `close`, and `filter`.
* **`icon`** - A leading trigger icon on a custom filter. It replaces the chevron. The drawer does not show it.

A filter this library does not ship is a contribution back to the library.

Two custom filter bugs are also fixed. Editing a string-array value no longer throws, and Apply no longer drops edits to fields other than `id`.

The popover for custom, text, and numeric range filters also handles fields with their own menu, such as a SelectField:

* Pressing a label or other non-focusable spot inside the popover no longer closes it.
* Escape and Enter go to an open menu first. Escape then closes the popover without clearing a SelectField inside it.
* With `controlledFiltering`, pressing Enter to apply no longer reopens the popover.

Several custom filter defaults change, so existing custom filters can behave differently. See [Breaking Changes](#breaking-changes).

## Design Rationale

### Why compare values structurally?

The popover compared custom values the way it compares select options. Objects with an `id` matched by `id` alone, and arrays of those objects matched by their set of ids. Edits to any other field looked unchanged, so Apply ignored them. Arrays of strings threw an error.

```tsx theme={null}
// Before: Apply ignored this edit because `id` didn't change
onChange({ ...value, radiusMiles: 25 });
```

Consumers define custom values, so the default now compares every field. A filter whose values are identified by `id` can say so with `isEqual`.

### Why `emptyValue`?

A custom filter was active whenever its value wasn't `undefined`. A filter that clears to `[]` stayed active, kept Clear Filters visible, and counted toward the drawer badge. `emptyValue` names the value that means "no filter", and Clear Filters writes it back.

### Why `formatLabel`?

Showing the selection on the trigger meant rewriting `label` in `onFilterChange`. `formatLabel` derives the trigger content from the committed value, as the built-in filters do.

### Why `icon` on a custom filter?

A custom trigger showed only a chevron. `icon` lets that trigger show what the filter is for, such as a place for a nearby filter. The drawer doesn't show the icon, because each drawer cell already has a visible label.

## Reference

| Prop | Type | Default | Description |
| - | - | - | - |
| `emptyValue` | `T` | — | Value that means "no filter". `undefined` is always inactive. |
| `isEqual` | `(a: T, b: T) => boolean` | structural | Detects edits and emptiness. |
| `formatLabel` | `(value: T) => ReactNode` | — | Trigger content while active. Takes precedence over `labelChipCount`. |
| `keepOpenOnChange` | `boolean` | `false` | Keeps the popover open after each change when `controlledFiltering` is off. |
| `clear` | `boolean` | `false` | Adds a footer Clear button that commits `emptyValue`. |
| `commitOnSelect` | `boolean` | `false` | Commits each popover change and closes, even with `controlledFiltering`. |
| `icon` | `Svg` | — | Leading trigger icon on a custom filter. It replaces the chevron. The drawer does not show it. |

```tsx theme={null}
// `undefined` until a place is picked, so no `emptyValue` is needed.
type Nearby = { place: Place; radiusMiles: number };

const nearbyFilter: CustomFilter<Nearby> = {
  id: "nearby",
  type: "custom",
  label: "Nearby",
  icon: LocationOn,
  keepOpenOnChange: true,
  clear: true,
  formatLabel: ({ place, radiusMiles }) =>
    `Nearby: ${place.label} (${radiusMiles} mi)`,
  buttonRender: (props) => <NearbyPicker {...props} />,
  drawerRender: (props) => <NearbyPicker {...props} />,
};
```

### Filter render props

`buttonRender` and `drawerRender` receive the same props:

| Prop | Popover | Drawer |
| - | - | - |
| `value`, `onChange` | Draft with `controlledFiltering`, otherwise committed | Draft |
| `committedValue` | Committed value | Committed value |
| `isDraft` | `true` with `controlledFiltering` | `true` |
| `commit` | Applies and closes, like Apply | Does nothing |
| `close` | Discards the draft and closes, like Cancel | Does nothing |
| `filter` | The filter's configuration | The filter's configuration |

With `commitOnSelect`, the popover has no draft: `value` is the committed value and `isDraft` is `false`.

## Migration Guide

### Trigger labels

Move label logic out of `onFilterChange` and into `formatLabel`.

```tsx theme={null}
// Before
onFilterChange={(next) =>
  setFilters(
    next.map((filter) =>
      filter.id === "nearby" && filter.type === "custom"
        ? { ...filter, label: filter.value ? `Nearby: ${filter.value.place.label}` : "Nearby" }
        : filter,
    ),
  )
}

// After
const nearbyFilter: CustomFilter<Nearby> = {
  id: "nearby",
  type: "custom",
  label: "Nearby",
  formatLabel: ({ place, radiusMiles }) =>
    `Nearby: ${place.label} (${radiusMiles} mi)`,
  // ...
};

onFilterChange={setFilters}
```

### Empty values

Set `emptyValue` instead of mapping an empty value to `undefined`.

```tsx theme={null}
// Before
{ id: "tags", type: "custom", label: "Tags", value: tags.length ? tags : undefined }

// After
{ id: "tags", type: "custom", label: "Tags", value: tags, emptyValue: [] }
```

### Id-only comparison

Pass `isEqual` when a value is identified by `id` and other fields shouldn't count as edits.

```tsx theme={null}
// Before: compared by `id` implicitly
{ id: "owner", type: "custom", label: "Owner", value: owner }

// After
{
  id: "owner",
  type: "custom",
  label: "Owner",
  value: owner,
  isEqual: (a, b) => a.id === b.id,
}
```

### Drawer items compared by reference

The drawer edits a copy of the committed value, including nested arrays, plain objects, and Dates. Compare items by a key.

```tsx theme={null}
// Before
checked={value?.includes(option)}

// After
checked={value?.some((item) => item.id === option.id)}
```

### Tests that query the drawer cell by label

Each custom drawer cell is a group named by the filter's `label`. A label query for the same text now matches both the group and the control inside it, so query the control by role.

```tsx theme={null}
// Before: a custom filter labelled "Invoice" with an input labelled "Invoice"
screen.getByLabelText("Invoice");

// After
screen.getByRole("textbox", { name: "Invoice" });
```

### Calling render functions directly

Code that calls `buttonRender` or `drawerRender` itself, such as a unit test, must pass the new required props.

```tsx theme={null}
// Before
filter.buttonRender({ value, onChange });

// After
filter.buttonRender({
  value,
  onChange,
  committedValue: value,
  isDraft: false,
  commit: () => {},
  close: () => {},
  filter,
});
```

## Breaking Changes

> [!WARNING]
>
> These changes require code updates before upgrading. The previous behavior is
> no longer supported.

The following changes are **breaking**:

* Custom filter values now **compare structurally** by default. Edits to any field commit, reordering an array counts as a change, and a new value equal to the committed one doesn't commit. Pass `isEqual` to compare by `id`.
* Custom filter drawer drafts now **copy nested values**. Arrays, plain objects, and Dates in the draft are copies, so compare items by a key rather than by reference.
* Custom drawer cells now render in a **group** named by the filter's `label`, with `data-id` set to the filter's `id`. Query the control inside by role.
* `FilterRenderProps` now **requires** `isDraft`, `commit`, `close`, and `filter`. Code that calls `buttonRender` or `drawerRender` directly must pass them.

### Why Breaking?

Keeping the old defaults would keep the bugs: dropped edits and cleared filters that stayed active. These changes land while FilterBar is in beta, before they would require a major version.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.