<!-- Generated by scripts/build-agent-kit.ts for @aistrike-dev/ui@5.0.1. Do not edit. -->

# FilterMenu vs FilterPopover vs FilterPanel

<!-- use-when: The user needs to narrow a table or list: where do the filter controls go? -->

All three of these put checkboxes in front of a user and hand back a set of values, so in a mockup
they are near-indistinguishable — a filter is a filter. What separates them is not what they collect
but **how much screen the filtering deserves, and whether the fields are committed one at a time or
together.** Answer that and the component falls out.

| Control | Lives | Fields at once | Commits |
| --- | --- | --- | --- |
| `FilterMenu` | A chip in a filter bar | One | Live, or behind Apply |
| `FilterPopover` | Behind one button | Many | Together, behind Apply |
| `FilterPanel` | A persistent sidebar | Many | However you wire it |

The mnemonic: **one chip, one field. One button, many fields. One sidebar, filtering is the page.**

## Decision flow

```
Is filtering a primary, sustained activity on this screen?
        │
        ├── Yes, the user lives in these controls ────────► FilterPanel
        └── No, it is occasional
             │
             ├── Should the applied value be visible without opening anything?
             │     ├── Yes, and the user may change it there ──► FilterMenu
             │     └── Yes, but it is read-only ───────────────► Chip purpose="filter"
             │
             └── Several fields the user sets in one sitting ──► FilterPopover
```

## FilterMenu

**Reach for it when:** a filter bar above a table where each field is its own chip; the applied value
should be readable at a glance and changeable in the same place; a closed set of values such as
severities, priorities, tenants or data sources.

```tsx
import { FilterMenu } from '@aistrike-dev/ui';

<FilterMenu
  field="Severity"
  options={[{ label: 'Critical', value: 'critical' }, { label: 'High', value: 'high' }]}
  value={severities}
  onChange={setSeverities}
  onRemove={() => dropFilter('severity')}
/>
```

**Practical limits:** one field per chip — a second field in the same panel is `FilterPopover`. Past
about ten options add `searchable`; past about twenty, reconsider whether the value is really a closed
set and not an `Autocomplete`. Four or five chips is the practical ceiling for a bar before it wraps
and stops scanning as a row. Selections apply live unless you pass `commit="apply"`. See
**Compact Labels** for the chip's two summary modes and why it is the one chip allowed both a body
action and an X.

**Fixed bar or removable bar — decide before you start.** If the fields are permanent, omit
`onRemove`: the chips have no X, and a user empties one from its panel's `Reset` or the group's
`Clear all`. If the user assembles the bar, pass `onRemove` and give them an `Add filter` menu of the
fields not yet present. What you cannot do is offer removal with no way back, which strands the user
one click from a filter they can no longer reach.

**Reacting to the panel closing** is `onClose`, which hands you the applied selection. Use it to fire
one query per visit while leaving `commit="immediate"` on, so the checkboxes and the chip stay live as
the user works.

## FilterPopover

**Reach for it when:** several fields belong to one act of filtering; the user should be able to set
severity, status and tenant and see the result once rather than three times; the toolbar has room for
one button but not five chips.

```tsx
import { FilterPopover } from '@aistrike-dev/ui';

<FilterPopover
  filters={[
    { key: 'severity', label: 'Severity', options: severityOptions },
    { key: 'status', label: 'Status', options: statusOptions },
  ]}
  value={filters}
  onApply={setFilters}
  searchable
/>
```

**Practical limits:** it commits on Apply by design, which is the point — a draft is what lets a user
set three fields without paying for three queries. The trigger carries a count of *active fields*, not
of selected values, so a badge of `2` means two fields are filtered rather than two options ticked.
It does not show the applied values on the surface, so pair it with `Chip purpose="filter"` if the
user needs to see them without reopening. `persistKey` stores the applied selection in
`localStorage`.

## FilterPanel

**Reach for it when:** filtering is the primary activity on the screen — a hunting view, a detection
catalogue, an asset inventory — and the controls should never be more than a glance away.

```tsx
import { FilterPanel, Search, Chip } from '@aistrike-dev/ui';

<FilterPanel onApply={apply} onClear={clear}>
  <Search placeholder="Search assets..." onSearch={setQuery} />
  <Chip purpose="select" label="Critical" selected={on} onToggle={setOn} />
</FilterPanel>
```

**Practical limits:** it is a container, not a filter model — you compose the controls and own the
state. It costs permanent horizontal space, so on a screen where filtering is occasional it takes room
from the data the user came for. Individual options inside it are `Chip purpose="select"`, not
pickers: the panel is already the surface, so a chip that opens another one is a popover inside a
sidebar.

## Worked examples

| Scenario | Control | Why |
| --- | --- | --- |
| Alerts table with severity and priority above it | `FilterMenu` per field | Applied values stay visible and editable in one place |
| Same table, plus status, tenant, region and a date range | `FilterPopover` | Six chips would wrap; one Apply avoids six queries |
| Threat hunting workspace | `FilterPanel` | Filtering is the activity, not an aside |
| Datastore indexes, ten options across two stores the user can enable | `Chip purpose="select"` per store plus `FilterMenu searchable showSelectAll` with `option.group` | Standalone chips add or remove a store's indexes from the menu |
| Read-only summary of what a saved view filters | `Chip purpose="filter"` | Nothing to pick; the criteria are just being reported |
| Severity toggles inside a hunting sidebar | `Chip purpose="select"` | The sidebar is already the surface |
| A single "Only show unresolved" switch | `Switch` in a toolbar | One boolean is not a filter control |
| Free-text asset owner lookup | `Autocomplete` | The value set is open, so checkboxes cannot enumerate it |

## Anti-patterns

- **A `FilterMenu` per field when there are six fields.** The bar wraps to two lines and stops reading
  as a row. That is what `FilterPopover` is for.
- **A `FilterPopover` whose applied values are invisible.** If the user has to open the popover to
  remember what is filtered, show the criteria beside it as `filter` chips or switch to `FilterMenu`.
- **A `FilterPanel` on a screen where filtering is occasional.** A permanent sidebar spends layout on
  a control used once a session, and it takes that space from the table.
- **A picker chip inside a `FilterPanel`.** The panel is already the surface; opening a popover from
  inside a sidebar gives the user two dismissal models for one selection.
- **`commit="immediate"` on a filter that costs a slow query.** Each tick fires a request and the user
  pays for intermediate states they never wanted to see. Use `commit="apply"`.
- **`commit="apply"` on a cheap filter.** An Apply button on a five-option severity list adds a click
  to every change for no benefit.
- **An X on a picker chip that empties the selection instead of removing the field.** Beside a
  `filter` chip whose X removes, the same glyph doing something else makes the user test each one.
  Emptying is `Reset`, inside the panel.
- **`onRemove` with no `Add filter`.** The user takes a field out of the bar and cannot put it back.
- **Mixing the models.** A `FilterPopover` that writes one field and a `FilterMenu` that writes
  another, with no shared state, produces a view whose filters disagree with its chips.

## Accessibility

- `FilterMenu` and `FilterPopover` both anchor a named dialog and return focus to the trigger on
  close, so a keyboard user is never dropped at the page body. `FilterPanel` has no dismissal to
  manage because it never leaves.
- All three use real `Checkbox` inputs rather than `menuitemcheckbox` rows. That is deliberate: a menu
  role cannot legally contain a search field or an Apply button, and native checkboxes announce their
  own state without reimplementation.
- Whatever the control, **the result set needs an accessible announcement.** A filter that silently
  changes a table from 400 rows to 12 tells a screen-reader user nothing. Announce the count.
- A select-all that acts on a filtered list must say so. "All" meaning "the three you can see" and
  "All" meaning "all four hundred" are different promises, and only the first is honest while a search
  is active.

## When you are unsure, ask

Whether filtering deserves a chip, a button or a sidebar is a judgement about how the screen is used,
which a mockup rarely settles. **If you cannot tell, stop and ask — name the control you are leaning
toward, the field count you are assuming, and what would change your mind.**

> "There are two filter chips in this mockup, but the spec lists six filterable fields. I'm building
> the two as `FilterMenu` and leaving the rest out. Should all six go behind one `FilterPopover`
> instead, so the bar does not wrap?"

> "This detections view filters by tactic, product and coverage. I'm treating filtering as the primary
> activity and putting a `FilterPanel` down the left. If it is more of an occasional narrowing, I would
> use a `FilterPopover` in the toolbar and give the space back to the table — which is it?"

## References

- **Molecules → FilterMenu** — the picker chip and its panel, both commit modes, grouping and search.
- **Organisms → FilterPopover** — the multi-field draft model, counts and persistence.
- **Organisms → FilterPanel** — the sidebar container.
- **Guidelines → Choosing → Compact Labels** — `purpose="filter"` vs `"select"` vs `"picker"`.
- **Guidelines → Choosing → Input Pickers** — when the value set is open rather than closed.
- [MUI Popover](https://mui.com/material-ui/react-popover/) · [MUI Checkbox](https://mui.com/material-ui/react-checkbox/)
