Skip to content

feat: implement FilterOptionsList component - #53

Open
Sher-Bakhodirov wants to merge 4 commits into
mainfrom
cdx-469-components-ui-add-recursive-hierarchical-filter-list
Open

feat: implement FilterOptionsList component#53
Sher-Bakhodirov wants to merge 4 commits into
mainfrom
cdx-469-components-ui-add-recursive-hierarchical-filter-list

Conversation

@Sher-Bakhodirov

@Sher-Bakhodirov Sher-Bakhodirov commented Aug 10, 2026

Copy link
Copy Markdown

[CDX-469] Add recursive hierarchical filter list component

Resolves CDX-469

What this adds

FilterOptionsList lets customers render a list of filter options, including nested lists, swatch rows, and collapsible branches. The component takes plain data and presents it - no business logic like restructuring, fetching, or knowing about Constructor.io facets or PLP. Customers map their own data into the shape and it renders it.

The library already had FilterOption for a single row and FilterOptionVisual for a swatch row, but nothing to render a list of them and nothing that handled nesting. This adds that.

The data shape

One type, FilterOptionData, describes every row:

{
  id: 'apparel-mens',
  optionValue: 'apparel/mens',
  displayValue: "Men's",
  displayCountValue: '540',
  isChecked: false,
  visual: { type: 'color', value: '#3B82F6' },  // optional - makes it a swatch row
  options: [ /* ...more of the same */ ],       // optional - makes it a branch
}

It's Picked from FilterOptionProps. The display fields (id, optionValue, displayValue, …) come straight from FilterOption's props, so if a prop gets renamed this breaks at compile time instead of silently.

Nested children live under options - the same key the list itself takes. The shape is self-similar, so any subtree is a valid options array and there's no separate "children" concept to learn.

A row is a swatch row exactly when it carries a visual. There's no isVisual flag to keep in sync - visual.type and visual.value travel together because neither means anything alone, and carrying both is what makes the row render as FilterOptionVisual. If only one half is present (easy to do from JS with no types), the row falls back to a plain row rather than rendering an empty swatch. Visual and plain rows are the same shape, so they mix and nest freely at any depth.

ids must be unique across the document. FilterOption uses id for its <input id> / <label htmlFor> pair, and a branch also derives <id>-hierarchy for its toggle's aria-controls target. Duplicate ids would let one click toggle the wrong row, so a page rendering several lists namespaces them itself, and the -hierarchy suffix is reserved. Both are noted in the type and there are tests for them.

How nesting works

Nesting reuses the row's children slot. Each option renders a FilterOption (or FilterOptionVisual). If it has nested options, that nested list is rendered into the row's children, which FilterOption already renders after its <label> - so a nested <ul> inside the <li> is valid HTML and needs no new nesting part.

Depth is unlimited and indentation adds up on its own. Each nested level adds one step of padding (cio:pl-4) and the DOM nesting compounds it, so there's no depth counter to track. The root list isn't indented; every nested one is.

Public wrapper + private recursive renderer. FilterOptionsList is a thin wrapper around an internal FilterOptionsListInner, which carries the props the recursion needs but customers shouldn't see (root-vs-nested flag, expansion state). Keeping them private means they don't show up in autodocs or get set from outside.

Collapsible branches

Rows with nested options get a toggle that collapses their own nested list. This collapses branches within the list - it never collapses the list itself, which is the parent's business.

  • collapsible (default true) - whether branches get a toggle at all.
  • defaultCollapsed (default false) - whether they start closed.
  • Both also exist per row, and the row wins. So one tree can hold a short branch pinned open with no toggle, a long branch that starts closed, and the rest following the list default.
  • A branch holding a checked option auto-expands even when the list defaults to collapsed, so an applied filter is never hidden. A per-row defaultCollapsed overrides even that - if you explicitly say a row starts closed, it starts closed.

Expansion is uncontrolled. Selection is fully controlled (isChecked in, onChange out) because the consumer owns it, but which branches are open has no effect outside this list, so there's nothing for a parent to synchronize and no reason to make them wire up state for it. The data supplies the starting point and the component owns it from there.

The open/closed state lives at the root as toggledIds, and expansion is derived per render rather than seeded into state per branch. The precedence is: a toggle the user actually performed → the row's defaultCollapsed → auto-expand for a checked descendant → the list's defaultCollapsed. Deriving it means branches that appear later (a facet reloads, options get filtered) pick up their default the first time they're seen with no bookkeeping, and toggledIds only ever holds rows the user touched. Lookups use Object.prototype.hasOwnProperty so an option with id: 'toString' behaves like any other id - there's a test for that.

Accessibility. The toggle is a real <button> with aria-expanded, an aria-label that includes the row's label ("Collapse Men's" rather than a bare "Collapse"), and aria-controls pointing at the nested list - dropped while the branch is collapsed, since collapsing unmounts the list and aria-controls shouldn't point at a missing id. The toggle sits beside the <label>, not inside it, so clicking it doesn't also tick the checkbox.

Alignment spacer. On a level where some rows have a toggle and others don't, the leaf rows get an aria-hidden spacer of the same width. Without it those rows would step left and the counts wouldn't line up in a column. Levels with no toggles at all get no spacers.

Layout

Rows with children are flex-wrap, and the nested list is a full-width flex item (cio:basis-full), so it wraps onto the line below the row. cio:w-0 cio:min-w-full on it means a nested list can't push the parent list wider when a branch expands - the list doesn't jump around as branches open and close.

onChange gives you the value and the option

onChange: (value: string, option: FilterOptionData) => void;

The second argument is the same object the consumer passed in, so === and id both identify it. Two options in different branches can legitimately share an optionValue (blue under Color, blue under Brand), and the value alone can't tell them apart.

checkboxPosition is optional

Forwarded to every row when set. When omitted, each row keeps its own component default - left for plain options, right for visual ones - so mixed lists don't lose the swatch layout unless you ask them to.

Overrides - three levels

componentOverrides works at three levels:

  1. The whole list - reactNode replaces the whole <ul>. The render-prop form gets the list's props (options, onChange, collapsible, …), so you can lay it out your own way off the same data.
  2. Every row - filterOption as an object applies to every row at every level, plain and visual alike.
  3. A single row - filterOption as a function runs per option. Return an override for the rows you want and undefined for the rest, e.g. (option) => option.id === 'x' ? {...} : undefined.

Only the row-level override recurses; the list-level reactNode stays at the root, since it would otherwise replace every nested list wholesale and break the recursion. It's threaded down as its own prop rather than inside componentOverrides to make that explicit.

A filterOption.reactNode render-prop function receives props.children - the row's toggle and nested list - so a parent row can be overridden without dropping the branch beneath it; re-emit {props.children} to keep it. A static reactNode (fixed JSX) has nowhere to receive children and renders without them.

Changes to the two row components

filter-option.tsx

  1. Hover styling sits on the <label>, not the <li>. With hover on the <li>, hovering a nested child row also highlighted its parents, since the child <li> sits inside the parent <li>. On the <label> (along with cio:group, which the checkbox's checked styling relies on) only the row under the cursor lights up.
  2. The label takes the leftover space instead of sizing to its text (grow basis-0 min-w-0). A long display value now wraps inside the label rather than claiming the whole flex line and pushing the toggle onto its own row.
  3. children is included in renderProps. This is what lets a render-prop override re-emit a row's toggle and nested list.

filter-option--visual.tsx now accepts and renders children, the same way FilterOption does, so a swatch row can host a toggle and a nested list too. Previously it dropped them.

Both keep their existing look and their tests pass; new tests cover the additions.

Pull Request Checklist

Before you submit a pull request, please make sure you have to following:

  • I have added or updated TypeScript types for my changes, ensuring they are compatible with the existing codebase.
  • I have added JSDoc comments to my TypeScript definitions for improved documentation.
  • I have added tests that prove my fix is effective or that my feature works.
  • I have added any necessary documentation (if appropriate).
  • I have made sure my PR is up-to-date with the main branch.

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no API changes)
  • Documentation content changes
  • TypeScript type definitions update
  • Other... Please describe:

@Sher-Bakhodirov
Sher-Bakhodirov requested review from a team, Mudaafi and esezen and a lite review from Copilot August 10, 2026 13:26
@Sher-Bakhodirov
Sher-Bakhodirov requested a review from a team as a code owner August 10, 2026 13:26
constructor-claude-bedrock[bot]

This comment was marked as outdated.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new presentational FilterOptionsList component to render flat or recursively nested filter option trees using the existing FilterOption row component (nesting is implemented via FilterOption’s children slot). The change also tweaks FilterOption hover/group styling so nested rows don’t cause parent rows to highlight, and expands render-prop support to include children.

Changes:

  • Introduces FilterOptionsList (recursive renderer + override model) and exports it (and its types) from the package entrypoint.
  • Updates FilterOption styling so hover/group behavior applies to the label rather than the <li>, preventing nested-hover bleed-through.
  • Adds Storybook examples and comprehensive Vitest coverage for recursion, indentation, overrides, and the children render-prop contract.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/stories/components/FilterOptionsList/FilterOptionsList.stories.tsx Adds Storybook stories demonstrating flat/hierarchical data and the three override levels.
src/index.ts Exports FilterOptionsList and related public types from the library entrypoint.
src/components/filter-options-list.tsx Implements the recursive hierarchical list component and override propagation rules.
src/components/filter-option.tsx Moves hover/group styling to the <label> and includes children in render-prop payload.
spec/components/FilterOptionsList/FilterOptionsList.test.tsx Adds tests for recursion, indentation, selection behavior, overrides, and unique-id expectations.
spec/components/FilterOption/FilterOption.test.tsx Adds a test ensuring render-prop overrides receive children for re-emitting nested content.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

constructor-claude-bedrock[bot]

This comment was marked as outdated.

constructor-claude-bedrock[bot]

This comment was marked as outdated.

@constructor-claude-bedrock constructor-claude-bedrock Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This PR introduces a well-designed FilterOptionsList component with recursive hierarchical rendering, collapsible branches, swatch support, and a three-level override system. The implementation is thoughtful, the PR description is exceptional, and test coverage is comprehensive.

Inline comments: 6 discussions added

Overall Assessment: ⚠️ Needs Work

Comment thread src/components/filter-options-list.tsx
Comment thread src/components/filter-options-list.tsx
Comment thread src/components/filter-options-list.tsx
Comment thread src/components/filter-options-list.tsx
Comment thread spec/components/FilterOptionsList/FilterOptionsList.test.tsx
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants