FilterPopover

Canonical filter UI for data tables: a trigger button that opens a popover containing caller-supplied form fields, with a Clear All / Apply footer the compound enforces. Confirmed across Provider (Members, Past Visits, In-Visit), Member (Community), and Campus (Visits, Manage Accounts). For analytics dashboards where filters stay visible, use FilterCard instead.

Spec · from metadata

When to use

  • Filtering rows in a DataTable (Members list, Past Visits, Tickets, etc.)
  • Any list page that needs filtering AND already has a dense toolbar
  • When you want filters to apply on explicit user action, not on every keystroke

When not to use

  • Analytics dashboards / insights pages — use `FilterCard` instead (inline, persistent, immediate apply)
  • Single-field search — just use an `Input` with type='search'
  • Filters that need to update other parts of the page in real time as they change — caller wants a different UX shape; FilterPopover is explicitly batched-on-Apply

Anti-patterns

Avoid<FilterPopover
  triggerLabel="Filters"
  applyLabel="Save"
  clearLabel="Reset"
  onApply={...}
  onClear={...}
>
  ...
</FilterPopover>
Prefer<FilterPopover
  triggerLabel="Filters"
  onApply={...}
  onClear={...}
>
  ...
</FilterPopover>

Footer button labels are hard-coded — applyLabel and clearLabel props don't exist. The drift winner from §3 of the spec is enforced by the API. 'Reset' implies a saved default (wrong mental model); 'Save' implies persistence (wrong mental model — filters are session-scoped). Use 'Clear All' + 'Apply'.

Avoid<FilterPopover
  triggerLabel="Filters"
  onApply={...}
  onClear={...}
>
  <Input
    placeholder="Member ID"
    value={memberId}
    onChange={(e) => { setMemberId(e.target.value); applyFilter(); }}
  />
</FilterPopover>
Prefer<FilterPopover
  triggerLabel="Filters"
  onApply={() => applyFilter(draft)}
  onClear={() => setDraft({})}
>
  <Input
    placeholder="Member ID"
    value={draft.memberId}
    onChange={(e) => setDraft({ ...draft, memberId: e.target.value })}
  />
</FilterPopover>

Filters are batched — they apply when the user clicks Apply, not on every keystroke. Auto-apply causes UI thrash in popovers and conflicts with the established mental model. Caller manages draft state; the compound only commits on Apply.

Avoid// Analytics dashboard
<FilterPopover triggerLabel="Filter dashboard" ...>
  <DatePicker ... />
  <Select ... />
</FilterPopover>
Prefer// Analytics dashboard
<FilterCard>
  <DatePicker ... />
  <Select ... />
</FilterCard>

Analytics surfaces use FilterCard (inline, persistent, immediate apply) because the filters are context for what you're reading. Tables use FilterPopover (hidden until needed, batched). See helix-data-patterns.md for the rule.

Accessibility

  • Min touch target48px
  • Screen readerTrigger button has aria-expanded reflecting popover state (inherited from Popover primitive). Active-count Badge is read inline after the label (e.g. 'Filters, 2'). Footer buttons have explicit labels 'Clear All' and 'Apply'.
  • ContrastTrigger is outline Button variant; meets WCAG AA. Badge uses secondary variant on the outline button background.

Token bindings

TokenCategoryUsage
popovercolorPopover surface (from the Popover primitive)
popover-foregroundcolorPopover text color
bordercolorPopover border and the footer's top divider (border-border)
4 (16px) / 3 (12px)spacingBody padding / footer padding + field gaps
mdradiusPopover container rounding (inherited from Popover primitive)
Recent changes
  • new

    Compound components join the catalog

    2026-07-09

[!NOTE] Compound component — an opinionated composition of Helix primitives. Composes: Button, Popover, Badge.

Import

import { FilterPopover } from "@timelycare/helix-ui"

Usage

const [draft, setDraft] = useState({ memberId: "", visitId: "" })
const [applied, setApplied] = useState(draft)
const activeCount = Object.values(applied).filter(Boolean).length

<FilterPopover
  triggerLabel="Filters"
  activeCount={activeCount}
  onApply={() => setApplied(draft)}
  onClear={() => setDraft({ memberId: "", visitId: "" })}
>
  <Input
    placeholder="Member ID"
    value={draft.memberId}
    onChange={(e) => setDraft({ ...draft, memberId: e.target.value })}
  />
  <Input
    placeholder="Visit ID"
    value={draft.visitId}
    onChange={(e) => setDraft({ ...draft, visitId: e.target.value })}
  />
</FilterPopover>