feat(board): multiselect combobox with text filtering for milestone + label filters #583

Closed
opened 2026-04-30 18:58:09 +00:00 by claude-desktop · 0 comments
Collaborator

User story

As an operator, I want the Board's Milestone and Label filters to be multiselect comboboxes — typeahead-filterable, picking values from the union of what's actually on the loaded cards — so I can stack area:dashboard + area:sessions or filter to two milestones at once without typing substrings and hoping.

Acceptance criteria

Component

  • New <MultiselectCombobox> primitive in apps/web/src/components/multiselect-combobox.tsx. Built on Base UI's <Combobox> if available, otherwise a custom popover + listbox.
  • Trigger: rounded-compact border border-border chip showing selected count (e.g. +2) or the single value when only one is picked, or the placeholder when empty.
  • Popover: search input pinned at top, listbox below, each row a checkbox + label + (optional) trailing meta (e.g. count). Keyboard navigation: ↑/↓, Enter toggles, Esc closes, ⌘A selects all visible matches.
  • A11y: role="combobox" on the trigger, aria-expanded, aria-controls; the listbox has role="listbox" and rows are role="option" with aria-selected.
  • Reduced-motion: no popover slide animation when prefers-reduced-motion is set.

Board wiring

  • Replace the <TextFilter> for Milestone and Label in apps/web/src/components/board/board-filters.tsx with the new combobox.
  • Available values are derived client-side from BoardResponse.columns.flatMap(c => c.cards):
    • Milestones: Set(cards.map(c => c.milestone).filter(Boolean)), sorted alphabetically.
    • Labels: Set(cards.flatMap(c => c.labels)), sorted alphabetically; excluded prefixes: stage: (covered by group=stage), type:user-story (always present, noise).
  • Each row shows a count: number of cards in the current projection that carry that label / milestone.
  • Empty state when zero cards loaded: the combobox stays disabled with placeholder No cards loaded.

Filter semantics

  • Selecting multiple values is OR within a facet (a card matches if it has any selected label) and AND across facets (matches must satisfy both milestone and label sets).
  • An empty selection means "no filter on this facet" (current behaviour preserved).
  • The substring matcher behaviour disappears — exact-match-from-list only, since the combobox already provides typeahead.

URL

  • Search params switch to repeatable arrays: ?label=area:dashboard&label=area:sessions&milestone=v0.2.0.
  • Backwards-compat: a single legacy ?milestone=foo / ?label=bar value (substring) is decoded as an exact-match selection if foo / bar appears verbatim in the derived facet list. Otherwise dropped silently.
  • validateSearch in apps/web/src/routes/planner.board.tsx updated to decode string | string[] into a normalised string[].

Filter card chrome

  • When at least one combobox has a selection, the chip background flips to bg-accent/10 and a small × clears the facet without opening the popover.
  • The "Stalled (N)" count next to the toggles continues to reflect the filtered projection.

Out of scope

  • Remote search across labels not present on currently-loaded cards (would need a new /board/facets endpoint; defer until the loaded-only set feels insufficient).
  • Persisting selection across reloads beyond the URL (URL is already the source of truth).
  • Saved filter presets (separate ticket if appetite emerges).

References

  • Source: apps/web/src/components/board/board-filters.tsx, apps/web/src/routes/planner.board.tsx.
  • Audit context: § Board-specific in PR #579 spec — filter card collapse / multiselect were both flagged.
  • Adjacent prior art: apps/web/src/components/agents/match-labels-input.tsx for a similar add/remove chip pattern, if useful as a reference.
## User story As an operator, I want the Board's `Milestone` and `Label` filters to be multiselect comboboxes — typeahead-filterable, picking values from the union of what's actually on the loaded cards — so I can stack `area:dashboard + area:sessions` or filter to two milestones at once without typing substrings and hoping. ## Acceptance criteria ### Component - [ ] New `<MultiselectCombobox>` primitive in `apps/web/src/components/multiselect-combobox.tsx`. Built on Base UI's `<Combobox>` if available, otherwise a custom popover + listbox. - [ ] Trigger: `rounded-compact border border-border` chip showing selected count (e.g. `+2`) or the single value when only one is picked, or the placeholder when empty. - [ ] Popover: search input pinned at top, listbox below, each row a checkbox + label + (optional) trailing meta (e.g. count). Keyboard navigation: ↑/↓, Enter toggles, Esc closes, ⌘A selects all visible matches. - [ ] A11y: `role="combobox"` on the trigger, `aria-expanded`, `aria-controls`; the listbox has `role="listbox"` and rows are `role="option"` with `aria-selected`. - [ ] Reduced-motion: no popover slide animation when `prefers-reduced-motion` is set. ### Board wiring - [ ] Replace the `<TextFilter>` for Milestone and Label in `apps/web/src/components/board/board-filters.tsx` with the new combobox. - [ ] Available values are derived client-side from `BoardResponse.columns.flatMap(c => c.cards)`: - Milestones: `Set(cards.map(c => c.milestone).filter(Boolean))`, sorted alphabetically. - Labels: `Set(cards.flatMap(c => c.labels))`, sorted alphabetically; **excluded prefixes:** `stage:` (covered by group=stage), `type:user-story` (always present, noise). - [ ] Each row shows a count: number of cards in the current projection that carry that label / milestone. - [ ] Empty state when zero cards loaded: the combobox stays disabled with placeholder `No cards loaded`. ### Filter semantics - [ ] Selecting multiple values is **OR** within a facet (a card matches if it has any selected label) and **AND** across facets (matches must satisfy both milestone and label sets). - [ ] An empty selection means "no filter on this facet" (current behaviour preserved). - [ ] The substring matcher behaviour disappears — exact-match-from-list only, since the combobox already provides typeahead. ### URL - [ ] Search params switch to repeatable arrays: `?label=area:dashboard&label=area:sessions&milestone=v0.2.0`. - [ ] Backwards-compat: a single legacy `?milestone=foo` / `?label=bar` value (substring) is decoded as an exact-match selection if `foo` / `bar` appears verbatim in the derived facet list. Otherwise dropped silently. - [ ] `validateSearch` in `apps/web/src/routes/planner.board.tsx` updated to decode `string | string[]` into a normalised `string[]`. ### Filter card chrome - [ ] When at least one combobox has a selection, the chip background flips to `bg-accent/10` and a small `×` clears the facet without opening the popover. - [ ] The "Stalled (N)" count next to the toggles continues to reflect the filtered projection. ## Out of scope - Remote search across labels not present on currently-loaded cards (would need a new `/board/facets` endpoint; defer until the loaded-only set feels insufficient). - Persisting selection across reloads beyond the URL (URL is already the source of truth). - Saved filter presets (separate ticket if appetite emerges). ## References - Source: `apps/web/src/components/board/board-filters.tsx`, `apps/web/src/routes/planner.board.tsx`. - Audit context: § Board-specific in PR #579 spec — filter card collapse / multiselect were both flagged. - Adjacent prior art: `apps/web/src/components/agents/match-labels-input.tsx` for a similar add/remove chip pattern, if useful as a reference.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks#583
No description provided.