Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions spec/components/FilterOption/FilterOption.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,84 @@ describe('FilterOption component', () => {
});
});

describe('radio selection type', () => {
test('renders radio input with correct attributes and group name', () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: There is no test covering the case where selectionType='radio' is used without groupName. Given that the JSDoc marks groupName as required for radio, a test verifying the rendered name attribute is absent (or a warning is emitted) would document the expected behaviour and guard against regressions. Similarly, no test exercises the interaction between multiple radio options in the same group to confirm mutual-exclusion semantics are honoured at the component API level.

render(
<FilterOption
id='radio-1'
optionValue='red'
displayValue='Red'
selectionType='radio'
groupName='color-facet'
onChange={() => {}}
/>,
);
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', () => {
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
render(
<FilterOption
id='radio-2'
optionValue='blue'
displayValue='Blue'
selectionType='radio'
groupName='color-facet'
isChecked={true}
onChange={() => {}}
/>,
);
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', () => {
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
const handleChange = vi.fn();
render(
<FilterOption
id='radio-3'
optionValue='green'
displayValue='Green'
selectionType='radio'
groupName='color-facet'
checkboxPosition='right'
onChange={handleChange}
/>,
);
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(
<FilterOption
id='radio-4'
optionValue='red'
displayValue='Red'
selectionType='radio'
groupName='color-facet'
checkboxPosition='none'
onChange={() => {}}
/>,
);
expect(document.querySelector('.cio-radio')).not.toBeInTheDocument();
expect(screen.getByRole('radio')).toBeInTheDocument();
});
});

describe('CSS classes', () => {
test('has cio-filter-option class', () => {
render(<FilterOption id='test-1' optionValue='red' displayValue='Red' onChange={() => {}} />);
Expand Down
23 changes: 23 additions & 0 deletions spec/components/FilterOptionVisual/FilterOptionVisual.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,29 @@ describe('FilterOptionVisual component', () => {
});
});

describe('radio selection type', () => {
test('renders radio input with group name and visual swatch', () => {
render(
<FilterOptionVisual
id='visual-radio-1'
optionValue='red'
displayValue='Red'
visualType='color'
visualValue='#FF0000'
selectionType='radio'
groupName='color-facet'
onChange={() => {}}
/>,
);
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(
Expand Down
35 changes: 30 additions & 5 deletions src/components/filter-option.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important Issue: groupName is documented as required for radio inputs but its TypeScript type is string | undefined (optional). There is no runtime warning or validation when selectionType='radio' is used without groupName. Without a name attribute, radio buttons in different <li> elements will not form a group and will not mutually exclude each other, silently producing broken UX. Add a console.warn (or use a discriminated union type) so consumers are alerted: e.g.

if (selectionType === 'radio' && !groupName) {
  console.warn('[FilterOption] `groupName` is required when `selectionType` is "radio".');
}

groupName?: string;
Comment on lines +31 to +32
/** Optional content to render before the display value (e.g., color swatch) */
startContent?: ReactNode;
/** Optional children to render inside the component */
Expand All @@ -38,6 +47,8 @@ export default function FilterOption({
isChecked = false,
onChange,
checkboxPosition = 'left',
selectionType = 'checkbox',
groupName,
startContent,
componentOverrides,
children,
Expand All @@ -53,6 +64,8 @@ export default function FilterOption({
isChecked,
onChange,
checkboxPosition,
selectionType,
groupName,
startContent,
className,
}),
Expand All @@ -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 = (
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: Both checkboxEl and radioEl are always constructed unconditionally, even though only one of them (or neither, when checkboxPosition='none') is ever rendered. For a pure presentational component this is a negligible cost, but it also means the two JSX elements are recreated on every render regardless of selectionType. Consider moving the JSX inline into indicatorEl, or only constructing the element that matches the current selectionType:

const indicatorEl = indicatorVisible
  ? selectionType === 'radio'
    ? <div className='cio-radio ...'>...</div>
    : <div className='cio-checkbox ...'>...</div>
  : false;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The cost of constructing an unused JSX element (a plain div) is negligible as noted. The current separate variables are easier to read and maintain than nested ternaries.

<div className='cio-checkbox cio:flex cio:justify-center cio:items-center cio:cursor-pointer cio:mx-2 cio:bg-white cio:w-5 cio:h-5 cio:min-w-5 cio:min-h-5 cio:rounded cio:transition-all cio:duration-250 cio:border cio:border-black/20 cio:group-has-[input:checked]:shadow-[inset_0_0_0_32px_#000]'>
<svg
width='10'
Expand All @@ -85,21 +101,30 @@ export default function FilterOption({
</div>
);

const radioEl = (
<div className='cio-radio cio:flex cio:justify-center cio:items-center cio:cursor-pointer cio:mx-2 cio:bg-white cio:w-5 cio:h-5 cio:min-w-5 cio:min-h-5 cio:rounded-full cio:transition-all cio:duration-250 cio:border cio:border-black/20'>
<div className='cio-radio-dot cio:w-2.5 cio:h-2.5 cio:rounded-full cio:bg-black cio:opacity-0 cio:transition-opacity cio:duration-250 cio:group-has-[input:checked]:opacity-100' />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important Issue: The cio:group-has-[input:checked] selector targets any checked input inside the group (the <li>), regardless of input type. With the checkbox variant this was fine because there was only one input type, but with radio inputs the same Tailwind variant is reused unchanged. In a real facet list every <li> is its own group, so a checked radio in one item could theoretically affect a different item's indicator if the nesting/group boundary is incorrect. More importantly, group-has-[input[type=radio]:checked] would be semantically more precise and defensive — consider scoping the selector to the specific input type for both the checkbox and radio indicators to prevent any cross-type leakage.

@niizom niizom Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Each <li> is its own group boundary, so the selector is already scoped to the single input within that item. There's no scenario where both a checkbox and radio coexist in the same group, so cross-type leakage isn't possible here.

</div>
);

const indicatorEl = indicatorVisible && (selectionType === 'radio' ? radioEl : checkboxEl);

return (
<RenderPropsWrapper props={renderProps} override={componentOverrides?.reactNode}>
<li data-slot='filter-option' className={cn(baseClasses, className)} {...props}>
<label
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'>
<input
type='checkbox'
type={selectionType}
id={id}
name={groupName}
value={optionValue}
checked={isChecked}
onChange={() => onChange(optionValue)}
className='cio-filter-option-input cio:hidden'
/>
{checkboxPosition === 'left' && checkboxEl}
{checkboxPosition === 'left' && indicatorEl}
<div className='cio-filter-multiple-option-display cio:flex cio:flex-row cio:justify-between cio:w-full cio:items-center'>
{startContent}
<span className='cio-filter-option-name cio:grow cio:break-words'>{displayValue}</span>
Expand All @@ -109,7 +134,7 @@ export default function FilterOption({
</span>
)}
</div>
{checkboxPosition === 'right' && checkboxEl}
{checkboxPosition === 'right' && indicatorEl}
</label>
{children}
</li>
Expand Down
84 changes: 84 additions & 0 deletions src/stories/components/FilterOption/FilterOption.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
control: 'radio',
options: ['left', 'right', 'none'],
},
selectionType: {
control: 'radio',
options: ['checkbox', 'radio'],
},
},
decorators: [
(Story) => (
Expand Down Expand Up @@ -145,6 +149,86 @@
},
};

// --- Radio Selection Type ---

export const Radio: Story = {
args: {
id: 'radio-1',
optionValue: 'small',
displayValue: 'Small',
displayCountValue: '320',
selectionType: 'radio',
groupName: 'size',
},
name: 'Radio',

Check warning on line 163 in src/stories/components/FilterOption/FilterOption.stories.tsx

View workflow job for this annotation

GitHub Actions / lint

Named exports should not use the name annotation if it is redundant to the name that would be generated by the export name
};

export const RadioChecked: Story = {
args: {
id: 'radio-2',
optionValue: 'medium',
displayValue: 'Medium',
displayCountValue: '512',
selectionType: 'radio',
groupName: 'size',
isChecked: true,
},
name: 'Radio Checked',

Check warning on line 176 in src/stories/components/FilterOption/FilterOption.stories.tsx

View workflow job for this annotation

GitHub Actions / lint

Named exports should not use the name annotation if it is redundant to the name that would be generated by the export name
};

export const RadioList: Story = {
args: {
id: 'radio-list',
optionValue: 'small',
displayValue: 'Small',
},
render: () => (
<ul style={{ listStyle: 'none', padding: 0, margin: 0, minWidth: 300 }}>
<FilterOption
id='size-small'
optionValue='small'
displayValue='Small'
displayCountValue='320'
selectionType='radio'
groupName='size'
onChange={() => {}}
/>
<FilterOption
id='size-medium'
optionValue='medium'
displayValue='Medium'
displayCountValue='512'
selectionType='radio'
groupName='size'
isChecked={true}
onChange={() => {}}
/>
<FilterOption
id='size-large'
optionValue='large'
displayValue='Large'
displayCountValue='198'
selectionType='radio'
groupName='size'
onChange={() => {}}
/>
<FilterOption
id='size-xl'
optionValue='xl'
displayValue='XL'
displayCountValue='87'
selectionType='radio'
groupName='size'
onChange={() => {}}
/>
</ul>
),
parameters: {
controls: { disable: true },
},
name: 'Radio List (Single Select)',
};

// componentOverrides example
const componentOverrides = {
filterOption: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
control: 'radio',
options: ['color', 'image'],
},
selectionType: {
control: 'radio',
options: ['checkbox', 'radio'],
},
},
} satisfies Meta<typeof FilterOptionVisual>;

Expand Down Expand Up @@ -217,6 +221,75 @@
},
};

// --- 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',

Check warning on line 238 in src/stories/components/FilterOptionVisual/FilterOptionVisual.stories.tsx

View workflow job for this annotation

GitHub Actions / lint

Named exports should not use the name annotation if it is redundant to the name that would be generated by the export name
};

export const RadioColorList: Story = {
args: {
id: 'radio-color-list',
optionValue: 'red',
displayValue: 'Red',
visualType: 'color',
visualValue: '#EF4444',
},
render: () => (
<ul style={{ listStyle: 'none', padding: 0, margin: 0, minWidth: 300 }}>
<FilterOptionVisual
id='radio-red'
optionValue='red'
displayValue='Red'
displayCountValue='646'
visualType='color'
visualValue='#EF4444'
selectionType='radio'
groupName='color'
isChecked={true}
onChange={() => {}}
/>
<FilterOptionVisual
id='radio-blue'
optionValue='blue'
displayValue='Blue'
displayCountValue='394'
visualType='color'
visualValue='#3B82F6'
selectionType='radio'
groupName='color'
onChange={() => {}}
/>
<FilterOptionVisual
id='radio-green'
optionValue='green'
displayValue='Green'
displayCountValue='195'
visualType='color'
visualValue='#22C55E'
selectionType='radio'
groupName='color'
onChange={() => {}}
/>
</ul>
),
parameters: {
controls: { disable: true },
},
name: 'Radio Color List (Single Select)',
};

// --- componentOverrides ---

const componentOverrides = {
Expand Down
Loading