From 9e694ef052e940161f8e745d69d6541f2d4cc570 Mon Sep 17 00:00:00 2001 From: Sherzod Baxodirov Date: Mon, 10 Aug 2026 18:17:11 +0500 Subject: [PATCH 1/4] feat: implement FilterOptionsList component --- .../FilterOption/FilterOption.test.tsx | 20 ++ .../FilterOptionsList.test.tsx | 284 ++++++++++++++++++ src/components/filter-option.tsx | 6 +- src/components/filter-options-list.tsx | 139 +++++++++ src/index.ts | 7 + .../FilterOptionsList.stories.tsx | 225 ++++++++++++++ 6 files changed, 679 insertions(+), 2 deletions(-) create mode 100644 spec/components/FilterOptionsList/FilterOptionsList.test.tsx create mode 100644 src/components/filter-options-list.tsx create mode 100644 src/stories/components/FilterOptionsList/FilterOptionsList.stories.tsx diff --git a/spec/components/FilterOption/FilterOption.test.tsx b/spec/components/FilterOption/FilterOption.test.tsx index eed8a41..4ab16ad 100644 --- a/spec/components/FilterOption/FilterOption.test.tsx +++ b/spec/components/FilterOption/FilterOption.test.tsx @@ -207,6 +207,26 @@ describe('FilterOption component', () => { ); expect(screen.queryByText('Red')).not.toBeInTheDocument(); }); + + test('render-prop reactNode receives children so nested content can be re-emitted', () => { + render( +
  • {props.children}
  • , + }} + onChange={() => {}}> + Nested +
    , + ); + const override = screen.getByTestId('custom-override'); + expect(override).toBeInTheDocument(); + expect(screen.getByTestId('nested-child')).toBeInTheDocument(); + // The nested child is inside the overridden node, not lost. + expect(override).toContainElement(screen.getByTestId('nested-child')); + }); }); describe('CSS classes', () => { diff --git a/spec/components/FilterOptionsList/FilterOptionsList.test.tsx b/spec/components/FilterOptionsList/FilterOptionsList.test.tsx new file mode 100644 index 0000000..17c5cab --- /dev/null +++ b/spec/components/FilterOptionsList/FilterOptionsList.test.tsx @@ -0,0 +1,284 @@ +import React from 'react'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { describe, test, expect, afterEach, vi } from 'vitest'; +import FilterOptionsList, { type FilterOptionData } from '@/components/filter-options-list'; + +// A 3-level fixture reused across tests. +const nestedOptions: FilterOptionData[] = [ + { + id: 'l0', + optionValue: 'level-0', + displayValue: 'Level 0', + displayCountValue: '100', + hierarchies: [ + { + id: 'l1', + optionValue: 'level-1', + displayValue: 'Level 1', + isChecked: true, + hierarchies: [ + { + id: 'l2', + optionValue: 'level-2', + displayValue: 'Level 2', + }, + ], + }, + ], + }, +]; + +const flatOptions: FilterOptionData[] = [ + { id: 'a', optionValue: 'a', displayValue: 'Alpha' }, + { id: 'b', optionValue: 'b', displayValue: 'Bravo' }, + { id: 'c', optionValue: 'c', displayValue: 'Charlie' }, +]; + +describe('FilterOptionsList component', () => { + afterEach(() => { + cleanup(); + }); + + describe('flat data', () => { + test('renders all options as sibling rows', () => { + render( { }} />); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Bravo')).toBeInTheDocument(); + expect(screen.getByText('Charlie')).toBeInTheDocument(); + expect(screen.getAllByRole('listitem')).toHaveLength(3); + }); + + test('renders a single top-level list without indentation', () => { + const { container } = render( { }} />); + const lists = container.querySelectorAll('[data-slot="filter-options-list"]'); + expect(lists).toHaveLength(1); + expect(lists[0].classList.contains('cio:pl-4')).toBeFalsy(); + }); + }); + + describe('hierarchical rendering', () => { + test('recurses into hierarchies at every depth', () => { + render( { }} />); + // A leaf several levels deep is present. + expect(screen.getByText('Level 0')).toBeInTheDocument(); + expect(screen.getByText('Level 1')).toBeInTheDocument(); + expect(screen.getByText('Level 2')).toBeInTheDocument(); + }); + + test('renders a nested list per level with a data-slot', () => { + const { container } = render( + { }} />, + ); + // Root + Level 0's children + Level 1's children = 3 lists. + expect(container.querySelectorAll('[data-slot="filter-options-list"]')).toHaveLength(3); + }); + + test('indents nested lists but not the root', () => { + const { container } = render( + { }} />, + ); + const lists = Array.from(container.querySelectorAll('[data-slot="filter-options-list"]')); + const indented = lists.filter((el) => el.classList.contains('cio:pl-4')); + // Every list except the root is indented. + expect(indented).toHaveLength(lists.length - 1); + expect(lists[0].classList.contains('cio:pl-4')).toBeFalsy(); + }); + + test('parent rows with children get cio:flex-col to stack the nested list below', () => { + render( { }} />); + const parentRow = screen.getByText('Level 0').closest('li'); + expect(parentRow?.classList.contains('cio:flex-col')).toBeTruthy(); + const leafRow = screen.getByText('Level 2').closest('li'); + expect(leafRow?.classList.contains('cio:flex-col')).toBeFalsy(); + }); + }); + + describe('selection at each depth', () => { + test.each([ + ['Level 0', 'level-0'], + ['Level 1', 'level-1'], + ['Level 2', 'level-2'], + ])('calls onChange with the option value when %s is clicked', (label, value) => { + const handleChange = vi.fn(); + render(); + fireEvent.click(screen.getByText(label)); + expect(handleChange).toHaveBeenCalledWith(value); + }); + + test('reflects isChecked state per depth', () => { + render( { }} />); + // Level 1 is the only pre-checked node. + expect(screen.getByText('Level 0').closest('li')?.querySelector('input')).not.toBeChecked(); + expect(screen.getByText('Level 1').closest('li')?.querySelector('input')).toBeChecked(); + expect(screen.getByText('Level 2').closest('li')?.querySelector('input')).not.toBeChecked(); + }); + }); + + describe('componentOverrides', () => { + test('list-level reactNode replaces the entire list', () => { + render( + { }} + componentOverrides={{ + reactNode:
      Custom list
    , + }} + />, + ); + expect(screen.getByTestId('custom-list')).toBeInTheDocument(); + expect(screen.queryByText('Alpha')).not.toBeInTheDocument(); + }); + + test('filterOption slot overrides every row', () => { + render( + { }} + componentOverrides={{ + filterOption: { + reactNode:
  • Custom row
  • , + }, + }} + />, + ); + expect(screen.getAllByTestId('custom-row')).toHaveLength(3); + expect(screen.queryByText('Alpha')).not.toBeInTheDocument(); + }); + + test('filterOption function targets a single option, leaving others at default', () => { + render( + { }} + componentOverrides={{ + filterOption: (option) => + option.id === 'b' + ? { reactNode:
  • Custom Bravo
  • } + : undefined, + }} + />, + ); + // Only Bravo is overridden; Alpha and Charlie render normally. + expect(screen.getByTestId('only-bravo')).toBeInTheDocument(); + expect(screen.queryByText('Bravo')).not.toBeInTheDocument(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Charlie')).toBeInTheDocument(); + }); + + test('filterOption function targets an option at depth', () => { + render( + { }} + componentOverrides={{ + filterOption: (option) => + option.id === 'l2' + ? { reactNode:
  • Deep
  • } + : undefined, + }} + />, + ); + // The depth-2 leaf is overridden; its ancestors render normally. + expect(screen.getByTestId('deep-override')).toBeInTheDocument(); + expect(screen.queryByText('Level 2')).not.toBeInTheDocument(); + expect(screen.getByText('Level 0')).toBeInTheDocument(); + expect(screen.getByText('Level 1')).toBeInTheDocument(); + }); + + test('render-prop override on a parent row re-emits its nested children', () => { + const { container } = render( + { }} + componentOverrides={{ + filterOption: (option) => + option.id === 'l0' + ? { + reactNode: (props) => ( +
  • + {props.displayValue} + {props.children} +
  • + ), + } + : undefined, + }} + />, + ); + // The parent row is overridden but its subtree survives via props.children. + expect(screen.getByTestId('parent-override')).toBeInTheDocument(); + expect(screen.getByText('Level 1')).toBeInTheDocument(); + expect(screen.getByText('Level 2')).toBeInTheDocument(); + // Nested list is still rendered beneath the overridden parent. + expect(container.querySelectorAll('[data-slot="filter-options-list"]')).toHaveLength(3); + }); + }); + + describe('empty hierarchies guard', () => { + test('a node with an empty hierarchies array renders no nested list and no flex-col', () => { + const options: FilterOptionData[] = [ + { id: 'leaf', optionValue: 'leaf', displayValue: 'Leaf', hierarchies: [] }, + ]; + const { container } = render( { }} />); + expect(container.querySelectorAll('[data-slot="filter-options-list"]')).toHaveLength(1); + expect( + screen.getByText('Leaf').closest('li')?.classList.contains('cio:flex-col'), + ).toBeFalsy(); + }); + }); + + describe('conventions', () => { + test('has data-slot attribute on the root list', () => { + render( { }} />); + expect(screen.getByRole('list')).toHaveAttribute('data-slot', 'filter-options-list'); + }); + + test('has cio-filter-options-list class', () => { + render( { }} />); + expect(screen.getByRole('list').classList.contains('cio-filter-options-list')).toBeTruthy(); + }); + + test('merges custom className onto the root list', () => { + render( + { }} />, + ); + expect(screen.getByRole('list').classList.contains('my-custom-class')).toBeTruthy(); + }); + + test('spreads data-* attributes onto the root list', () => { + render( + { }} + />, + ); + expect(screen.getByTestId('my-list').dataset.facet).toBe('color'); + }); + }); + + describe('unique-id contract', () => { + test('rows across branches toggle only their own input', () => { + const handleChange = vi.fn(); + const options: FilterOptionData[] = [ + { + id: 'branch-a', + optionValue: 'branch-a', + displayValue: 'Branch A', + hierarchies: [{ id: 'a-child', optionValue: 'a-child', displayValue: 'A Child' }], + }, + { + id: 'branch-b', + optionValue: 'branch-b', + displayValue: 'Branch B', + hierarchies: [{ id: 'b-child', optionValue: 'b-child', displayValue: 'B Child' }], + }, + ]; + render(); + fireEvent.click(screen.getByText('A Child')); + expect(handleChange).toHaveBeenCalledTimes(1); + expect(handleChange).toHaveBeenCalledWith('a-child'); + }); + }); +}); diff --git a/src/components/filter-option.tsx b/src/components/filter-option.tsx index 627f91b..13b7558 100644 --- a/src/components/filter-option.tsx +++ b/src/components/filter-option.tsx @@ -2,7 +2,7 @@ import React, { ReactNode } from 'react'; import { cn, RenderPropsWrapper } from '@/utils'; import { ComponentOverrideProps, IncludeComponentOverrides } from '@/types'; const baseClasses = - 'cio-components cio-filter-option cio-filter-multiple-option cio:group cio:cursor-pointer cio:flex cio:list-none cio:text-base cio:hover:bg-neutral-100 cio:hover:rounded'; + 'cio-components cio-filter-option cio-filter-multiple-option cio:flex cio:list-none cio:text-base'; export interface FilterOptionProps extends Omit, 'onChange' | 'children'>, @@ -55,6 +55,7 @@ export default function FilterOption({ checkboxPosition, startContent, className, + children, }), [ props, @@ -67,6 +68,7 @@ export default function FilterOption({ checkboxPosition, startContent, className, + children, ], ); @@ -90,7 +92,7 @@ export default function FilterOption({