diff --git a/spec/components/FilterOption/FilterOption.test.tsx b/spec/components/FilterOption/FilterOption.test.tsx index eed8a41..7ed6ccf 100644 --- a/spec/components/FilterOption/FilterOption.test.tsx +++ b/spec/components/FilterOption/FilterOption.test.tsx @@ -209,6 +209,84 @@ describe('FilterOption component', () => { }); }); + describe('radio selection type', () => { + test('renders radio input with correct attributes and group name', () => { + render( + {}} + />, + ); + const radio = screen.getByRole('radio'); + expect(radio).toBeInTheDocument(); + expect(radio).toHaveAttribute('id', 'radio-1'); + expect(radio).toHaveAttribute('value', 'red'); + expect(radio).toHaveAttribute('name', 'color-facet'); + expect(radio).not.toBeChecked(); + }); + + test('renders radio visual indicator and responds to checked state', () => { + render( + {}} + />, + ); + const radio = screen.getByRole('radio'); + expect(radio).toBeChecked(); + const radioIndicator = document.querySelector('.cio-radio'); + expect(radioIndicator).toBeInTheDocument(); + expect(document.querySelector('.cio-checkbox')).not.toBeInTheDocument(); + }); + + test('calls onChange and respects checkboxPosition for radio', () => { + const handleChange = vi.fn(); + render( + , + ); + const radio = screen.getByRole('radio'); + fireEvent.click(radio); + expect(handleChange).toHaveBeenCalledWith('green'); + const label = document.querySelector('.cio-filter-option-label'); + const radioIndicator = label?.querySelector('.cio-radio'); + const displayDiv = label?.querySelector('.cio-filter-multiple-option-display'); + expect(displayDiv?.nextElementSibling).toBe(radioIndicator); + }); + + test('hides radio indicator when checkboxPosition is none', () => { + render( + {}} + />, + ); + expect(document.querySelector('.cio-radio')).not.toBeInTheDocument(); + expect(screen.getByRole('radio')).toBeInTheDocument(); + }); + }); + describe('CSS classes', () => { test('has cio-filter-option class', () => { render( {}} />); diff --git a/spec/components/FilterOptionVisual/FilterOptionVisual.test.tsx b/spec/components/FilterOptionVisual/FilterOptionVisual.test.tsx index 86f8a85..62de7ad 100644 --- a/spec/components/FilterOptionVisual/FilterOptionVisual.test.tsx +++ b/spec/components/FilterOptionVisual/FilterOptionVisual.test.tsx @@ -164,6 +164,29 @@ describe('FilterOptionVisual component', () => { }); }); + describe('radio selection type', () => { + test('renders radio input with group name and visual swatch', () => { + render( + {}} + />, + ); + const radio = screen.getByRole('radio'); + expect(radio).toBeInTheDocument(); + expect(radio).toHaveAttribute('name', 'color-facet'); + expect(document.querySelector('.cio-radio')).toBeInTheDocument(); + expect(document.querySelector('.cio-checkbox')).not.toBeInTheDocument(); + expect(document.querySelector('.cio-filter-visual-swatch')).toBeInTheDocument(); + }); + }); + describe('layout structure', () => { test('swatch appears before option name in DOM order', () => { render( diff --git a/src/components/filter-option.tsx b/src/components/filter-option.tsx index 627f91b..d6351eb 100644 --- a/src/components/filter-option.tsx +++ b/src/components/filter-option.tsx @@ -21,6 +21,15 @@ export interface FilterOptionProps onChange: (value: string) => void; /** Position of the checkbox. Can be 'left', 'right', or 'none'. Defaults to 'left' */ checkboxPosition?: 'left' | 'right' | 'none'; + /** + * Selection input type. + * @default 'checkbox' + * @remarks The checkbox default for single-selection facets will be deprecated + * in the next major version — radio buttons will become the default for single-type facets. + */ + selectionType?: 'checkbox' | 'radio'; + /** Group name for the input. Required for radio inputs to form a radio group. */ + groupName?: string; /** Optional content to render before the display value (e.g., color swatch) */ startContent?: ReactNode; /** Optional children to render inside the component */ @@ -38,6 +47,8 @@ export default function FilterOption({ isChecked = false, onChange, checkboxPosition = 'left', + selectionType = 'checkbox', + groupName, startContent, componentOverrides, children, @@ -53,6 +64,8 @@ export default function FilterOption({ isChecked, onChange, checkboxPosition, + selectionType, + groupName, startContent, className, }), @@ -65,13 +78,16 @@ export default function FilterOption({ isChecked, onChange, checkboxPosition, + selectionType, + groupName, startContent, className, ], ); - const checkboxVisible = checkboxPosition !== 'none'; - const checkboxEl = checkboxVisible && ( + const indicatorVisible = checkboxPosition !== 'none'; + + const checkboxEl = (
); + const radioEl = ( +
+
+
+ ); + + const indicatorEl = indicatorVisible && (selectionType === 'radio' ? radioEl : checkboxEl); + return (
  • @@ -92,14 +116,15 @@ export default function FilterOption({ htmlFor={id} className='cio-filter-option-label cio:text-sm cio:flex cio:flex-row cio:items-center cio:cursor-pointer cio:grow cio:p-1'> onChange(optionValue)} className='cio-filter-option-input cio:hidden' /> - {checkboxPosition === 'left' && checkboxEl} + {checkboxPosition === 'left' && indicatorEl}
    {startContent} {displayValue} @@ -109,7 +134,7 @@ export default function FilterOption({ )}
    - {checkboxPosition === 'right' && checkboxEl} + {checkboxPosition === 'right' && indicatorEl} {children}
  • diff --git a/src/stories/components/FilterOption/FilterOption.stories.tsx b/src/stories/components/FilterOption/FilterOption.stories.tsx index 15e295e..da8016d 100644 --- a/src/stories/components/FilterOption/FilterOption.stories.tsx +++ b/src/stories/components/FilterOption/FilterOption.stories.tsx @@ -16,6 +16,10 @@ const meta = { control: 'radio', options: ['left', 'right', 'none'], }, + selectionType: { + control: 'radio', + options: ['checkbox', 'radio'], + }, }, decorators: [ (Story) => ( @@ -145,6 +149,86 @@ export const FilterList: Story = { }, }; +// --- Radio Selection Type --- + +export const Radio: Story = { + args: { + id: 'radio-1', + optionValue: 'small', + displayValue: 'Small', + displayCountValue: '320', + selectionType: 'radio', + groupName: 'size', + }, + name: 'Radio', +}; + +export const RadioChecked: Story = { + args: { + id: 'radio-2', + optionValue: 'medium', + displayValue: 'Medium', + displayCountValue: '512', + selectionType: 'radio', + groupName: 'size', + isChecked: true, + }, + name: 'Radio Checked', +}; + +export const RadioList: Story = { + args: { + id: 'radio-list', + optionValue: 'small', + displayValue: 'Small', + }, + render: () => ( +
      + {}} + /> + {}} + /> + {}} + /> + {}} + /> +
    + ), + parameters: { + controls: { disable: true }, + }, + name: 'Radio List (Single Select)', +}; + // componentOverrides example const componentOverrides = { filterOption: { diff --git a/src/stories/components/FilterOptionVisual/FilterOptionVisual.stories.tsx b/src/stories/components/FilterOptionVisual/FilterOptionVisual.stories.tsx index d69bc53..9bac110 100644 --- a/src/stories/components/FilterOptionVisual/FilterOptionVisual.stories.tsx +++ b/src/stories/components/FilterOptionVisual/FilterOptionVisual.stories.tsx @@ -23,6 +23,10 @@ const meta = { control: 'radio', options: ['color', 'image'], }, + selectionType: { + control: 'radio', + options: ['checkbox', 'radio'], + }, }, } satisfies Meta; @@ -217,6 +221,75 @@ export const MixedVisualTypes: Story = { }, }; +// --- Radio Selection Type --- + +export const RadioColor: Story = { + args: { + id: 'radio-color-red', + optionValue: 'red', + displayValue: 'Red', + displayCountValue: '646', + visualType: 'color', + visualValue: '#EF4444', + selectionType: 'radio', + groupName: 'color', + isChecked: true, + }, + name: 'Radio Color', +}; + +export const RadioColorList: Story = { + args: { + id: 'radio-color-list', + optionValue: 'red', + displayValue: 'Red', + visualType: 'color', + visualValue: '#EF4444', + }, + render: () => ( +
      + {}} + /> + {}} + /> + {}} + /> +
    + ), + parameters: { + controls: { disable: true }, + }, + name: 'Radio Color List (Single Select)', +}; + // --- componentOverrides --- const componentOverrides = {