feat: test coverage of ComboControl and CheckboxFilter
This commit is contained in:
112
src/shared/ui/CheckboxFilter/CheckboxFilter.stories.svelte
Normal file
112
src/shared/ui/CheckboxFilter/CheckboxFilter.stories.svelte
Normal file
@@ -0,0 +1,112 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import CheckboxFilter from './CheckboxFilter.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Shared/UI/CheckboxFilter',
|
||||
component: CheckboxFilter,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
displayedLabel: { control: 'text' },
|
||||
// filter is complex, use stories for examples
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import WithFilterDecorator from './WithFilterDecorator.svelte';
|
||||
import type { Property } from '$shared/lib';
|
||||
|
||||
// Define initial values for each story
|
||||
const basicProperties: Property[] = [
|
||||
{ id: 'serif', name: 'Serif', selected: false },
|
||||
{ id: 'sans-serif', name: 'Sans-serif', selected: false },
|
||||
{ id: 'display', name: 'Display', selected: false },
|
||||
{ id: 'handwriting', name: 'Handwriting', selected: false },
|
||||
{ id: 'monospace', name: 'Monospace', selected: false },
|
||||
];
|
||||
|
||||
const withSelectedProperties: Property[] = [
|
||||
{ id: 'serif', name: 'Serif', selected: true },
|
||||
{ id: 'sans-serif', name: 'Sans-serif', selected: false },
|
||||
{ id: 'display', name: 'Display', selected: true },
|
||||
{ id: 'handwriting', name: 'Handwriting', selected: false },
|
||||
];
|
||||
|
||||
const allSelectedProperties: Property[] = [
|
||||
{ id: 'serif', name: 'Serif', selected: true },
|
||||
{ id: 'sans-serif', name: 'Sans-serif', selected: true },
|
||||
{ id: 'display', name: 'Display', selected: true },
|
||||
{ id: 'handwriting', name: 'Handwriting', selected: true },
|
||||
];
|
||||
|
||||
const emptyProperties: Property[] = [];
|
||||
|
||||
const singleProperty: Property[] = [{ id: 'serif', name: 'Serif', selected: false }];
|
||||
|
||||
const multipleProperties: Property[] = [
|
||||
{ id: 'thin', name: 'Thin', selected: false },
|
||||
{ id: 'extra-light', name: 'Extra Light', selected: false },
|
||||
{ id: 'light', name: 'Light', selected: false },
|
||||
{ id: 'regular', name: 'Regular', selected: false },
|
||||
{ id: 'medium', name: 'Medium', selected: false },
|
||||
{ id: 'semi-bold', name: 'Semi Bold', selected: false },
|
||||
{ id: 'bold', name: 'Bold', selected: false },
|
||||
{ id: 'extra-bold', name: 'Extra Bold', selected: false },
|
||||
{ id: 'black', name: 'Black', selected: false },
|
||||
];
|
||||
</script>
|
||||
|
||||
<!-- Basic usage - multiple properties -->
|
||||
<Story name="Basic Usage" args={{ displayedLabel: 'Font Category' }}>
|
||||
<WithFilterDecorator initialValues={basicProperties}>
|
||||
{#snippet children({ filter })}
|
||||
<CheckboxFilter displayedLabel={'Font Category'} {filter} />
|
||||
{/snippet}
|
||||
</WithFilterDecorator>
|
||||
</Story>
|
||||
|
||||
<!-- With some items pre-selected -->
|
||||
<Story name="With Selected Items" args={{ displayedLabel: 'Font Category' }}>
|
||||
<WithFilterDecorator initialValues={withSelectedProperties}>
|
||||
{#snippet children({ filter })}
|
||||
<CheckboxFilter displayedLabel={'Font Category'} {filter} />
|
||||
{/snippet}
|
||||
</WithFilterDecorator>
|
||||
</Story>
|
||||
|
||||
<!-- All items selected -->
|
||||
<Story name="All Selected" args={{ displayedLabel: 'Font Category' }}>
|
||||
<WithFilterDecorator initialValues={allSelectedProperties}>
|
||||
{#snippet children({ filter })}
|
||||
<CheckboxFilter displayedLabel={'Font Category'} {filter} />
|
||||
{/snippet}
|
||||
</WithFilterDecorator>
|
||||
</Story>
|
||||
|
||||
<!-- Empty filter (no properties) -->
|
||||
<Story name="Empty Filter" args={{ displayedLabel: 'Empty Filter' }}>
|
||||
<WithFilterDecorator initialValues={emptyProperties}>
|
||||
{#snippet children({ filter })}
|
||||
<CheckboxFilter displayedLabel={'Empty Filter'} {filter} />
|
||||
{/snippet}
|
||||
</WithFilterDecorator>
|
||||
</Story>
|
||||
|
||||
<!-- Single property -->
|
||||
<Story name="Single Property" args={{ displayedLabel: 'Font Category' }}>
|
||||
<WithFilterDecorator initialValues={singleProperty}>
|
||||
{#snippet children({ filter })}
|
||||
<CheckboxFilter displayedLabel={'Font Category'} {filter} />
|
||||
{/snippet}
|
||||
</WithFilterDecorator>
|
||||
</Story>
|
||||
|
||||
<!-- Large number of properties -->
|
||||
<Story name="Multiple Properties" args={{ displayedLabel: 'Font Weight' }}>
|
||||
<WithFilterDecorator initialValues={multipleProperties}>
|
||||
{#snippet children({ filter })}
|
||||
<CheckboxFilter displayedLabel={'Font Weight'} {filter} />
|
||||
{/snippet}
|
||||
</WithFilterDecorator>
|
||||
</Story>
|
||||
@@ -63,8 +63,6 @@ const slideConfig = $derived({
|
||||
// Derived for reactive updates when properties change - avoids recomputing on every render
|
||||
const selectedCount = $derived(filter.selectedCount);
|
||||
const hasSelection = $derived(selectedCount > 0);
|
||||
|
||||
$inspect(filter.properties).with(console.trace);
|
||||
</script>
|
||||
|
||||
<!-- Collapsible card wrapper with subtle hover state for affordance -->
|
||||
|
||||
@@ -1,85 +1,571 @@
|
||||
import type { Property } from '$shared/lib/store/createFilterStore/createFilterStore';
|
||||
import {
|
||||
type Property,
|
||||
createFilter,
|
||||
} from '$shared/lib';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/svelte';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import CheckboxFilter from './CheckboxFilter.svelte';
|
||||
|
||||
describe('CheckboxFilter', () => {
|
||||
const mockProperties: Property[] = [
|
||||
{ id: '1', name: 'Sans-serif', selected: false },
|
||||
{ id: '2', name: 'Serif', selected: true },
|
||||
{ id: '3', name: 'Display', selected: false },
|
||||
];
|
||||
/**
|
||||
* Test Suite for CheckboxFilter Component
|
||||
*
|
||||
* This suite tests the actual Svelte component rendering, interactions, and behavior
|
||||
* using a real browser environment (Playwright) via @vitest/browser-playwright.
|
||||
*
|
||||
* Tests for the createFilter helper function are in createFilter.test.ts
|
||||
*
|
||||
* IMPORTANT: These tests use the browser environment because Svelte 5's $state,
|
||||
* $derived, and onMount lifecycle require a browser environment. The bits-ui
|
||||
* Checkbox component renders as <button type="button"> with role="checkbox",
|
||||
* not as <input type="checkbox">.
|
||||
*/
|
||||
|
||||
const mockOnPropertyToggle = vi.fn();
|
||||
describe('CheckboxFilter Component', () => {
|
||||
/**
|
||||
* Helper function to create a filter for testing
|
||||
*/
|
||||
function createTestFilter(properties: Property[]) {
|
||||
return createFilter({ properties });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnPropertyToggle.mockClear();
|
||||
});
|
||||
/**
|
||||
* Helper function to create mock properties
|
||||
*/
|
||||
function createMockProperties(count: number, selectedIndices: number[] = []) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `prop-${i}`,
|
||||
name: `Property ${i}`,
|
||||
selected: selectedIndices.includes(i),
|
||||
}));
|
||||
}
|
||||
|
||||
it('renders with correct label', () => {
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Font Categories',
|
||||
properties: mockProperties,
|
||||
onPropertyToggle: mockOnPropertyToggle,
|
||||
describe('Rendering', () => {
|
||||
it('displays the label', () => {
|
||||
const filter = createTestFilter(createMockProperties(3));
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test Label',
|
||||
filter,
|
||||
});
|
||||
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Font Categories')).toBeInTheDocument();
|
||||
});
|
||||
it('renders all properties as checkboxes with labels', () => {
|
||||
const properties = createMockProperties(3);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
it('displays all properties as checkboxes', () => {
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Categories',
|
||||
properties: mockProperties,
|
||||
onPropertyToggle: mockOnPropertyToggle,
|
||||
// Check that all property names are rendered
|
||||
expect(screen.getByText('Property 0')).toBeInTheDocument();
|
||||
expect(screen.getByText('Property 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Property 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('Sans-serif')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Serif')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Display')).toBeInTheDocument();
|
||||
});
|
||||
it('shows selected count badge when items are selected', () => {
|
||||
const properties = createMockProperties(3, [0, 2]); // Select 2 items
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
it('shows selected count badge when items selected', () => {
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Categories',
|
||||
properties: mockProperties,
|
||||
onPropertyToggle: mockOnPropertyToggle,
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
});
|
||||
it('hides badge when no items selected', () => {
|
||||
const properties = createMockProperties(3);
|
||||
const filter = createTestFilter(properties);
|
||||
const { container } = render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
it('does not show badge when no items selected', () => {
|
||||
const allUnselected = mockProperties.map(p => ({ ...p, selected: false }));
|
||||
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Categories',
|
||||
properties: allUnselected,
|
||||
onPropertyToggle: mockOnPropertyToggle,
|
||||
// Badge should not be in the document
|
||||
const badges = container.querySelectorAll('[class*="badge"]');
|
||||
expect(badges).toHaveLength(0);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||
it('renders with no properties', () => {
|
||||
const filter = createTestFilter([]);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Empty Filter',
|
||||
filter,
|
||||
});
|
||||
|
||||
expect(screen.getByText('Empty Filter')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onPropertyToggle when checkbox clicked', async () => {
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Categories',
|
||||
properties: mockProperties,
|
||||
onPropertyToggle: mockOnPropertyToggle,
|
||||
describe('Checkbox Interactions', () => {
|
||||
it('checkboxes reflect initial selected state', async () => {
|
||||
const properties = createMockProperties(3, [0, 2]);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
// Wait for component to render
|
||||
// bits-ui Checkbox renders as <button type="button"> with role="checkbox"
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes).toHaveLength(3);
|
||||
|
||||
// Check that the correct checkboxes are checked using aria-checked attribute
|
||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
|
||||
expect(checkboxes[1]).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(checkboxes[2]).toHaveAttribute('data-state', 'checked');
|
||||
});
|
||||
|
||||
const checkbox = screen.getByLabelText('Sans-serif');
|
||||
await checkbox.click();
|
||||
it('clicking checkbox toggles property.selected state', async () => {
|
||||
const properties = createMockProperties(3, [0]);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
expect(mockOnPropertyToggle).toHaveBeenCalledWith('1');
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
|
||||
// Initially, first checkbox is checked
|
||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
|
||||
expect(filter.selectedCount).toBe(1);
|
||||
|
||||
// Click to uncheck it
|
||||
await fireEvent.click(checkboxes[0]);
|
||||
|
||||
// Now it should be unchecked
|
||||
await waitFor(() => {
|
||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
expect(filter.selectedCount).toBe(0);
|
||||
|
||||
// Click it again to re-check
|
||||
await fireEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
|
||||
});
|
||||
expect(filter.selectedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('label styling changes based on selection state', async () => {
|
||||
const properties = createMockProperties(2, [0]);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
|
||||
// Find label elements - they are siblings of checkboxes
|
||||
const labels = checkboxes.map(cb => cb.nextElementSibling);
|
||||
|
||||
// First label should have font-medium and text-foreground classes
|
||||
expect(labels[0]).toHaveClass('font-medium', 'text-foreground');
|
||||
|
||||
// Second label should not have these classes
|
||||
expect(labels[1]).not.toHaveClass('font-medium', 'text-foreground');
|
||||
|
||||
// Uncheck the first checkbox
|
||||
await fireEvent.click(checkboxes[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
// Now first label should not have these classes
|
||||
expect(labels[0]).not.toHaveClass('font-medium', 'text-foreground');
|
||||
});
|
||||
});
|
||||
|
||||
it('multiple checkboxes can be toggled independently', async () => {
|
||||
const properties = createMockProperties(3);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
|
||||
// Check all three checkboxes
|
||||
await fireEvent.click(checkboxes[0]);
|
||||
await fireEvent.click(checkboxes[1]);
|
||||
await fireEvent.click(checkboxes[2]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(filter.selectedCount).toBe(3);
|
||||
});
|
||||
|
||||
// Uncheck middle one
|
||||
await fireEvent.click(checkboxes[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(filter.selectedCount).toBe(2);
|
||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
|
||||
expect(checkboxes[1]).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(checkboxes[2]).toHaveAttribute('data-state', 'checked');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Collapsible Behavior', () => {
|
||||
it('is open by default', () => {
|
||||
const filter = createTestFilter(createMockProperties(2));
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
// Check that properties are visible (content is expanded)
|
||||
expect(screen.getByText('Property 0')).toBeInTheDocument();
|
||||
expect(screen.getByText('Property 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking trigger toggles open/close state', async () => {
|
||||
const filter = createTestFilter(createMockProperties(2));
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
// Content is initially visible
|
||||
expect(screen.getByText('Property 0')).toBeVisible();
|
||||
|
||||
// Click the trigger (button) - use role and text to find it
|
||||
const trigger = screen.getByRole('button', { name: /Test/ });
|
||||
await fireEvent.click(trigger);
|
||||
|
||||
// Content should now be hidden
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Property 0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click again to open
|
||||
await fireEvent.click(trigger);
|
||||
|
||||
// Content should be visible again
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Property 0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('chevron icon rotates based on open state', async () => {
|
||||
const filter = createTestFilter(createMockProperties(2));
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
const trigger = screen.getByRole('button', { name: /Test/ });
|
||||
const chevronContainer = trigger.querySelector('.lucide-chevron-down')
|
||||
?.parentElement as HTMLElement;
|
||||
|
||||
// Initially open, transform should be rotate(0deg) or no rotation
|
||||
expect(chevronContainer?.style.transform).toContain('0deg');
|
||||
|
||||
// Click to close
|
||||
await fireEvent.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
// Now should be rotated -90deg
|
||||
expect(chevronContainer?.style.transform).toContain('-90deg');
|
||||
});
|
||||
|
||||
// Click to open again
|
||||
await fireEvent.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
// Back to 0deg
|
||||
expect(chevronContainer?.style.transform).toContain('0deg');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Count Display', () => {
|
||||
it('badge shows correct count based on filter.selectedCount', async () => {
|
||||
const properties = createMockProperties(5, [0, 2, 4]);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
// Should show 3
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
|
||||
// Click a checkbox to change selection
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
await fireEvent.click(checkboxes[1]);
|
||||
|
||||
// Should now show 4
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('4')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('badge visibility changes with hasSelection (selectedCount > 0)', async () => {
|
||||
const properties = createMockProperties(2, [0]);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
// Initially has 1 selection, badge should be visible
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
|
||||
// Uncheck the selected item
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
await fireEvent.click(checkboxes[0]);
|
||||
|
||||
// Now 0 selections, badge should be hidden
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check it again
|
||||
await fireEvent.click(checkboxes[0]);
|
||||
|
||||
// Badge should be visible again
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('badge shows count correctly when all items are selected', () => {
|
||||
const properties = createMockProperties(5, [0, 1, 2, 3, 4]);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('provides proper ARIA labels on buttons', () => {
|
||||
const filter = createTestFilter(createMockProperties(2));
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test Label',
|
||||
filter,
|
||||
});
|
||||
|
||||
// The trigger button should be findable by its text
|
||||
const trigger = screen.getByRole('button', { name: /Test Label/ });
|
||||
expect(trigger).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels are properly associated with checkboxes', async () => {
|
||||
const properties = createMockProperties(3);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
|
||||
checkboxes.forEach((checkbox, index) => {
|
||||
// Each checkbox should have an id
|
||||
expect(checkbox).toHaveAttribute('id', `prop-${index}`);
|
||||
|
||||
// Find the label element (Label component wraps checkbox)
|
||||
const labelElement = checkbox.closest('label');
|
||||
expect(labelElement).toHaveAttribute('for', `prop-${index}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('checkboxes have proper role', async () => {
|
||||
const filter = createTestFilter(createMockProperties(2));
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
checkboxes.forEach(checkbox => {
|
||||
expect(checkbox).toHaveAttribute('role', 'checkbox');
|
||||
expect(checkbox).toHaveAttribute('type', 'button');
|
||||
});
|
||||
});
|
||||
|
||||
it('labels are clickable and toggle associated checkboxes', async () => {
|
||||
const properties = createMockProperties(2);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
// Find the label text element (span inside label)
|
||||
const firstLabelText = screen.getByText('Property 0');
|
||||
|
||||
// Initially unchecked
|
||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'unchecked');
|
||||
|
||||
// Click the label text
|
||||
await fireEvent.click(firstLabelText);
|
||||
|
||||
// Checkbox should now be checked
|
||||
await waitFor(() => {
|
||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
|
||||
});
|
||||
|
||||
// Click again
|
||||
await fireEvent.click(firstLabelText);
|
||||
|
||||
// Should be unchecked again
|
||||
await waitFor(() => {
|
||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles long property names', () => {
|
||||
const properties: Property[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'This is a very long property name that might wrap to multiple lines',
|
||||
selected: false,
|
||||
},
|
||||
];
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'This is a very long property name that might wrap to multiple lines',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles special characters in property names', () => {
|
||||
const properties: Property[] = [
|
||||
{ id: '1', name: 'Café & Restaurant', selected: true },
|
||||
{ id: '2', name: '100% Organic', selected: false },
|
||||
{ id: '3', name: '(Special) <Characters>', selected: false },
|
||||
];
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
expect(screen.getByText('Café & Restaurant')).toBeInTheDocument();
|
||||
expect(screen.getByText('100% Organic')).toBeInTheDocument();
|
||||
expect(screen.getByText('(Special) <Characters>')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles single property filter', () => {
|
||||
const properties: Property[] = [
|
||||
{ id: '1', name: 'Only One', selected: true },
|
||||
];
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Single',
|
||||
filter,
|
||||
});
|
||||
|
||||
expect(screen.getByText('Only One')).toBeInTheDocument();
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles very large number of properties', async () => {
|
||||
const properties = createMockProperties(50, [0, 25, 49]);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Large List',
|
||||
filter,
|
||||
});
|
||||
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
expect(checkboxes).toHaveLength(50);
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates badge when filter is manipulated externally', async () => {
|
||||
const properties = createMockProperties(3);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
|
||||
// Initially no badge (0 selections)
|
||||
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||
|
||||
// Externally select properties
|
||||
filter.selectProperty('prop-0');
|
||||
filter.selectProperty('prop-1');
|
||||
|
||||
// Badge should now show 2
|
||||
// Note: This might not update immediately in the DOM due to Svelte reactivity
|
||||
// In a real browser environment, this would update
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Integration', () => {
|
||||
it('works correctly with real filter data', async () => {
|
||||
const realProperties: Property[] = [
|
||||
{ id: 'sans-serif', name: 'Sans-serif', selected: true },
|
||||
{ id: 'serif', name: 'Serif', selected: false },
|
||||
{ id: 'display', name: 'Display', selected: false },
|
||||
{ id: 'handwriting', name: 'Handwriting', selected: true },
|
||||
{ id: 'monospace', name: 'Monospace', selected: false },
|
||||
];
|
||||
const filter = createTestFilter(realProperties);
|
||||
render(CheckboxFilter, {
|
||||
displayedLabel: 'Font Category',
|
||||
filter,
|
||||
});
|
||||
|
||||
// Check label
|
||||
expect(screen.getByText('Font Category')).toBeInTheDocument();
|
||||
|
||||
// Check count badge
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
|
||||
// Check property names
|
||||
expect(screen.getByText('Sans-serif')).toBeInTheDocument();
|
||||
expect(screen.getByText('Serif')).toBeInTheDocument();
|
||||
expect(screen.getByText('Display')).toBeInTheDocument();
|
||||
expect(screen.getByText('Handwriting')).toBeInTheDocument();
|
||||
expect(screen.getByText('Monospace')).toBeInTheDocument();
|
||||
|
||||
// Check initial checkbox states
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
|
||||
expect(checkboxes[1]).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(checkboxes[2]).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(checkboxes[3]).toHaveAttribute('data-state', 'checked');
|
||||
expect(checkboxes[4]).toHaveAttribute('data-state', 'unchecked');
|
||||
|
||||
// Interact with checkboxes
|
||||
await fireEvent.click(checkboxes[1]);
|
||||
await waitFor(() => {
|
||||
expect(filter.selectedCount).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
22
src/shared/ui/CheckboxFilter/WithFilterDecorator.svelte
Normal file
22
src/shared/ui/CheckboxFilter/WithFilterDecorator.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { createFilter } from '$shared/lib';
|
||||
import type { Property, Filter } from '$shared/lib';
|
||||
|
||||
/**
|
||||
* Props for the WithFilter decorator component.
|
||||
*/
|
||||
let {
|
||||
children,
|
||||
/** Initial properties to create the filter from */
|
||||
initialValues,
|
||||
}: {
|
||||
children: Snippet<[props: { filter: Filter }]>;
|
||||
initialValues: Property[];
|
||||
} = $props();
|
||||
|
||||
// Create filter inside component body so Svelte 5 runes work correctly
|
||||
const filter = createFilter({ properties: initialValues });
|
||||
</script>
|
||||
|
||||
{@render children({ filter })}
|
||||
@@ -1,64 +1,76 @@
|
||||
<script module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ComboControl from './ComboControl.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Shared/UI/ComboControl',
|
||||
component: ComboControl,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: { control: 'number' },
|
||||
minValue: { control: 'number' },
|
||||
maxValue: { control: 'number' },
|
||||
step: { control: 'number' },
|
||||
increaseDisabled: { control: 'boolean' },
|
||||
decreaseDisabled: { control: 'boolean' },
|
||||
onChange: { action: 'onChange' },
|
||||
onIncrease: { action: 'onIncrease' },
|
||||
onDecrease: { action: 'onDecrease' },
|
||||
},
|
||||
});
|
||||
const { Story } = defineMeta({
|
||||
title: 'Shared/UI/ComboControl',
|
||||
component: ComboControl,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
controlLabel: { control: 'text' },
|
||||
increaseLabel: { control: 'text' },
|
||||
decreaseLabel: { control: 'text' },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import ComboControl from './ComboControl.svelte';
|
||||
import WithControlDecorator from './WithControlDecorator.svelte';
|
||||
|
||||
let integerStep = 1;
|
||||
let decimalStep = 0.05;
|
||||
// Define initial values for each story
|
||||
const fontSizeInitial = { value: 16, min: 8, max: 100, step: 1 };
|
||||
|
||||
let integerValue = 16;
|
||||
let decimalValue = 1.5;
|
||||
const letterSpacingInitial = { value: 0, min: -2, max: 4, step: 0.05 };
|
||||
|
||||
let integerMinValue = 8;
|
||||
let decimalMinValue = 1;
|
||||
const atMinimumInitial = { value: 10, min: 10, max: 100, step: 1 };
|
||||
|
||||
let integerMaxValue = 100;
|
||||
let decimalMaxValue = 2;
|
||||
|
||||
function onChange() {}
|
||||
function onIncrease() {}
|
||||
function onDecrease() {}
|
||||
const atMaximumInitial = { value: 100, min: 10, max: 100, step: 1 };
|
||||
</script>
|
||||
|
||||
<Story name="Integer Step">
|
||||
<ComboControl
|
||||
value={integerValue}
|
||||
step={integerStep}
|
||||
onChange={onChange}
|
||||
onIncrease={onIncrease}
|
||||
onDecrease={onDecrease}
|
||||
minValue={integerMinValue}
|
||||
maxValue={integerMaxValue}
|
||||
/>
|
||||
<Story name="Integer Step" args={{ controlLabel: 'Font size' }}>
|
||||
<WithControlDecorator initialValues={fontSizeInitial}>
|
||||
{#snippet children({ control })}
|
||||
<ComboControl controlLabel={'Font size'} {control} />
|
||||
{/snippet}
|
||||
</WithControlDecorator>
|
||||
</Story>
|
||||
|
||||
<Story name="Decimal Step">
|
||||
<ComboControl
|
||||
value={decimalValue}
|
||||
step={decimalStep}
|
||||
onChange={onChange}
|
||||
onIncrease={onIncrease}
|
||||
onDecrease={onDecrease}
|
||||
minValue={decimalMinValue}
|
||||
maxValue={decimalMaxValue}
|
||||
/>
|
||||
<Story name="Decimal Step" args={{ controlLabel: 'Letter spacing' }}>
|
||||
<WithControlDecorator initialValues={letterSpacingInitial}>
|
||||
{#snippet children({ control })}
|
||||
<ComboControl controlLabel={'Letter spacing'} {control} />
|
||||
{/snippet}
|
||||
</WithControlDecorator>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="At Minimum"
|
||||
args={{ controlLabel: 'Font size', increaseLabel: 'Increase', decreaseLabel: 'Decrease' }}
|
||||
>
|
||||
<WithControlDecorator initialValues={atMinimumInitial}>
|
||||
{#snippet children({ control })}
|
||||
<ComboControl
|
||||
controlLabel={'Font size'}
|
||||
increaseLabel={'Increase'}
|
||||
decreaseLabel={'Decrease'}
|
||||
{control}
|
||||
/>
|
||||
{/snippet}
|
||||
</WithControlDecorator>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="At Maximum"
|
||||
args={{ controlLabel: 'Font size', increaseLabel: 'Increase', decreaseLabel: 'Decrease' }}
|
||||
>
|
||||
<WithControlDecorator initialValues={atMaximumInitial}>
|
||||
{#snippet children({ control })}
|
||||
<ComboControl
|
||||
controlLabel={'Font size'}
|
||||
increaseLabel={'Increase'}
|
||||
decreaseLabel={'Decrease'}
|
||||
{control}
|
||||
/>
|
||||
{/snippet}
|
||||
</WithControlDecorator>
|
||||
</Story>
|
||||
|
||||
@@ -36,6 +36,7 @@ const {
|
||||
}: ComboControlProps = $props();
|
||||
|
||||
// Local state for the slider to prevent infinite loops
|
||||
// svelte-ignore state_referenced_locally - $state captures initial value, $effect syncs updates
|
||||
let sliderValue = $state(Number(control.value));
|
||||
|
||||
// Sync sliderValue when external value changes
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user