feat: implement FilterOptionsList component - #53
Conversation
There was a problem hiding this comment.
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
FilterOptionstyling 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
childrenrender-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.
There was a problem hiding this comment.
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:
[CDX-469] Add recursive hierarchical filter list component
Resolves CDX-469
What this adds
FilterOptionsListlets 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
FilterOptionfor a single row andFilterOptionVisualfor 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:It's
Picked fromFilterOptionProps. The display fields (id,optionValue,displayValue, …) come straight fromFilterOption'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 validoptionsarray and there's no separate "children" concept to learn.A row is a swatch row exactly when it carries a
visual. There's noisVisualflag to keep in sync -visual.typeandvisual.valuetravel together because neither means anything alone, and carrying both is what makes the row render asFilterOptionVisual. 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.FilterOptionusesidfor its<input id>/<label htmlFor>pair, and a branch also derives<id>-hierarchyfor its toggle'saria-controlstarget. Duplicate ids would let one click toggle the wrong row, so a page rendering several lists namespaces them itself, and the-hierarchysuffix is reserved. Both are noted in the type and there are tests for them.How nesting works
Nesting reuses the row's
childrenslot. Each option renders aFilterOption(orFilterOptionVisual). If it has nestedoptions, that nested list is rendered into the row'schildren, whichFilterOptionalready 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.
FilterOptionsListis a thin wrapper around an internalFilterOptionsListInner, 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
optionsget 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(defaulttrue) - whether branches get a toggle at all.defaultCollapsed(defaultfalse) - whether they start closed.defaultCollapsedoverrides even that - if you explicitly say a row starts closed, it starts closed.Expansion is uncontrolled. Selection is fully controlled (
isCheckedin,onChangeout) 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'sdefaultCollapsed→ auto-expand for a checked descendant → the list'sdefaultCollapsed. 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, andtoggledIdsonly ever holds rows the user touched. Lookups useObject.prototype.hasOwnPropertyso an option withid: 'toString'behaves like any other id - there's a test for that.Accessibility. The toggle is a real
<button>witharia-expanded, anaria-labelthat includes the row's label ("Collapse Men's" rather than a bare "Collapse"), andaria-controlspointing at the nested list - dropped while the branch is collapsed, since collapsing unmounts the list andaria-controlsshouldn'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-hiddenspacer 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-fullon 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.onChangegives you the value and the optionThe second argument is the same object the consumer passed in, so
===andidboth identify it. Two options in different branches can legitimately share anoptionValue(blueunder Color,blueunder Brand), and the value alone can't tell them apart.checkboxPositionis optionalForwarded to every row when set. When omitted, each row keeps its own component default -
leftfor plain options,rightfor visual ones - so mixed lists don't lose the swatch layout unless you ask them to.Overrides - three levels
componentOverridesworks at three levels:reactNodereplaces 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.filterOptionas an object applies to every row at every level, plain and visual alike.filterOptionas a function runs per option. Return an override for the rows you want andundefinedfor the rest, e.g.(option) => option.id === 'x' ? {...} : undefined.Only the row-level override recurses; the list-level
reactNodestays 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 insidecomponentOverridesto make that explicit.A
filterOption.reactNoderender-prop function receivesprops.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 staticreactNode(fixed JSX) has nowhere to receive children and renders without them.Changes to the two row components
filter-option.tsx<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 withcio:group, which the checkbox's checked styling relies on) only the row under the cursor lights up.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.childrenis included inrenderProps. This is what lets a render-prop override re-emit a row's toggle and nested list.filter-option--visual.tsxnow accepts and renderschildren, the same wayFilterOptiondoes, 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:
PR Type
What kind of change does this PR introduce?