feat(FilterGroup): refactor CheckboxFilter component to FilterGroup
This commit is contained in:
@@ -1,137 +0,0 @@
|
||||
<!--
|
||||
Component: CheckboxFilter
|
||||
A collapsible property filter with checkboxes. Displate selected count as a badge
|
||||
and supports reduced motion for accessibility.
|
||||
- Open by default for immediate visibility and interaction
|
||||
- Badge shown only when filters are active to reduce visual noise
|
||||
- Transitions use cubicOut for natural deceleration
|
||||
- Local transition prevents animation when component first renders
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
type Filter,
|
||||
springySlideFade,
|
||||
} from '$shared/lib';
|
||||
import { Badge } from '$shared/shadcn/ui/badge';
|
||||
import { buttonVariants } from '$shared/shadcn/ui/button';
|
||||
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
||||
import {
|
||||
Root as CollapsibleRoot,
|
||||
Trigger as CollapsibleTrigger,
|
||||
} from '$shared/shadcn/ui/collapsible';
|
||||
import { Label } from '$shared/shadcn/ui/label';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { prefersReducedMotion } from 'svelte/motion';
|
||||
|
||||
interface PropertyFilterProps {
|
||||
/** Label for this filter group (e.g., "Properties", "Tags") */
|
||||
displayedLabel: string;
|
||||
/** Filter entity */
|
||||
filter: Filter;
|
||||
}
|
||||
|
||||
const { displayedLabel, filter }: PropertyFilterProps = $props();
|
||||
|
||||
// Toggle state - defaults to open for better discoverability
|
||||
let isOpen = $state(true);
|
||||
|
||||
// Animation config respects user preferences - zero duration if reduced motion enabled
|
||||
// Local modifier prevents animation on initial render, only animates user interactions
|
||||
const slideConfig = $derived({
|
||||
duration: prefersReducedMotion.current ? 0 : 150,
|
||||
easing: cubicOut,
|
||||
});
|
||||
|
||||
// Derived for reactive updates when properties change - avoids recomputing on every render
|
||||
const selectedCount = $derived(filter.selectedCount);
|
||||
const hasSelection = $derived(selectedCount > 0);
|
||||
</script>
|
||||
|
||||
<!-- Collapsible card wrapper with subtle hover state for affordance -->
|
||||
<CollapsibleRoot
|
||||
bind:open={isOpen}
|
||||
class="w-full bg-card transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<!-- Trigger row: title, expand indicator, and optional count badge -->
|
||||
<div class="flex items-center justify-between px-3 sm:px-4 py-2">
|
||||
<CollapsibleTrigger
|
||||
class={buttonVariants({
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
class: 'flex-1 justify-between gap-2 hover:bg-transparent focus-visible:ring-1 focus-visible:ring-ring',
|
||||
})}
|
||||
>
|
||||
<h4 class="text-xs sm:text-sm font-semibold">{displayedLabel}</h4>
|
||||
|
||||
<!-- Badge only appears when items are selected to avoid clutter -->
|
||||
{#if hasSelection}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
data-testid="badge"
|
||||
class="mr-auto h-4 sm:h-5 min-w-4 sm:min-w-5 px-1 sm:px-1.5 text-[10px] sm:text-xs font-medium tabular-nums"
|
||||
>
|
||||
{selectedCount}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
<!-- Chevron rotates based on open state for visual feedback -->
|
||||
<div
|
||||
data-testid="chevron"
|
||||
class="shrink-0 transition-transform duration-200 ease-out"
|
||||
style:transform={isOpen ? 'rotate(0deg)' : 'rotate(-90deg)'}
|
||||
>
|
||||
<ChevronDownIcon class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
|
||||
<!-- Expandable content with slide animation -->
|
||||
{#if isOpen}
|
||||
<div
|
||||
transition:springySlideFade|local={slideConfig}
|
||||
class="will-change-[height,opacity]"
|
||||
>
|
||||
<div class="px-4 py-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<!-- Each item: checkbox + label with interactive hover/focus states -->
|
||||
<!-- Keyed by property.id for efficient DOM updates -->
|
||||
{#each filter.properties as property (property.id)}
|
||||
<Label
|
||||
for={property.id}
|
||||
class="
|
||||
group flex items-center gap-3 cursor-pointer rounded-md px-2 py-1.5 -mx-2
|
||||
transition-colors duration-150 ease-out
|
||||
hover:bg-accent/50 focus-within:bg-accent/50
|
||||
active:scale-[0.98] active:transition-transform active:duration-75
|
||||
"
|
||||
>
|
||||
<!-- Checkbox handles toggle, styled for accessibility with focus rings -->
|
||||
<Checkbox
|
||||
id={property.id}
|
||||
bind:checked={property.selected}
|
||||
class="
|
||||
shrink-0 cursor-pointer transition-all duration-150 ease-out
|
||||
data-[state=checked]:scale-100
|
||||
hover:border-primary/50
|
||||
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
"
|
||||
/>
|
||||
<!-- Label text changes weight/color based on selection state -->
|
||||
<span
|
||||
class="
|
||||
text-sm select-none transition-all duration-150 ease-out
|
||||
group-hover:text-foreground
|
||||
text-muted-foreground
|
||||
{property.selected ? 'font-medium text-foreground' : ''}
|
||||
"
|
||||
>
|
||||
{property.name}
|
||||
</span>
|
||||
</Label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</CollapsibleRoot>
|
||||
@@ -1,10 +1,10 @@
|
||||
<script module>
|
||||
import { createFilter } from '$shared/lib';
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import CheckboxFilter from './CheckboxFilter.svelte';
|
||||
import FilterGroup from './FilterGroup.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Shared/CheckboxFilter',
|
||||
title: 'Shared/FilterGroup',
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
@@ -66,12 +66,12 @@ const selectedFilter = createFilter({
|
||||
|
||||
<Story name="Default">
|
||||
{#snippet template(args)}
|
||||
<CheckboxFilter filter={defaultFilter} displayedLabel="Zoo" {...args} />
|
||||
<FilterGroup filter={defaultFilter} displayedLabel="Zoo" {...args} />
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="Selected">
|
||||
{#snippet template(args)}
|
||||
<CheckboxFilter filter={selectedFilter} displayedLabel="Shopping list" {...args} />
|
||||
<FilterGroup filter={selectedFilter} displayedLabel="Shopping list" {...args} />
|
||||
{/snippet}
|
||||
</Story>
|
||||
71
src/shared/ui/FilterGroup/FilterGroup.svelte
Normal file
71
src/shared/ui/FilterGroup/FilterGroup.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<!--
|
||||
Component: FilterGroup
|
||||
Property filter group. Flat list of toggle buttons — no collapsible.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Filter } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { Button } from '$shared/ui';
|
||||
import { Label } from '$shared/ui';
|
||||
import DotIcon from '@lucide/svelte/icons/dot';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { draw } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
/** Label for this filter group (e.g., "Provider", "Tags") */
|
||||
displayedLabel: string;
|
||||
/** Filter entity */
|
||||
filter: Filter;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { displayedLabel, filter, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#snippet icon()}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-dot-icon lucide-dot size-8 stroke-brand"
|
||||
>
|
||||
<circle
|
||||
transition:draw={{ duration: 200, easing: cubicOut }}
|
||||
cx="12.1"
|
||||
cy="12.1"
|
||||
r="1"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<div class={cn('flex flex-col', className)}>
|
||||
<Label
|
||||
variant="default"
|
||||
size="sm"
|
||||
bold
|
||||
class="pl-3 md:pl-4 mb-3 md:mb-4"
|
||||
>
|
||||
{displayedLabel}
|
||||
</Label>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each filter.properties as property (property.id)}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
active={property.selected}
|
||||
onclick={() => (property.selected = !property.selected)}
|
||||
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
|
||||
iconPosition="right"
|
||||
icon={property.selected ? icon : undefined}
|
||||
>
|
||||
<span>{property.name}</span>
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import CheckboxFilter from './CheckboxFilter.svelte';
|
||||
import FilterGroup from './FilterGroup.svelte';
|
||||
|
||||
/**
|
||||
* Test Suite for CheckboxFilter Component
|
||||
* Test Suite for FilterGroup Component
|
||||
*
|
||||
* This suite tests the actual Svelte component rendering, interactions, and behavior
|
||||
* using a real browser environment (Playwright) via @vitest/browser-playwright.
|
||||
@@ -29,7 +29,7 @@ import CheckboxFilter from './CheckboxFilter.svelte';
|
||||
* not as <input type="checkbox">.
|
||||
*/
|
||||
|
||||
describe('CheckboxFilter Component', () => {
|
||||
describe('FilterGroup Component', () => {
|
||||
/**
|
||||
* Helper function to create a filter for testing
|
||||
*/
|
||||
@@ -52,7 +52,7 @@ describe('CheckboxFilter Component', () => {
|
||||
describe('Rendering', () => {
|
||||
it('displays the label', () => {
|
||||
const filter = createTestFilter(createMockProperties(3));
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test Label',
|
||||
filter,
|
||||
});
|
||||
@@ -63,7 +63,7 @@ describe('CheckboxFilter Component', () => {
|
||||
it('renders all properties as checkboxes with labels', () => {
|
||||
const properties = createMockProperties(3);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -77,7 +77,7 @@ describe('CheckboxFilter Component', () => {
|
||||
it('shows selected count badge when items are selected', () => {
|
||||
const properties = createMockProperties(3, [0, 2]); // Select 2 items
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -88,7 +88,7 @@ describe('CheckboxFilter Component', () => {
|
||||
it('hides badge when no items selected', () => {
|
||||
const properties = createMockProperties(3);
|
||||
const filter = createTestFilter(properties);
|
||||
const { container } = render(CheckboxFilter, {
|
||||
const { container } = render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -100,7 +100,7 @@ describe('CheckboxFilter Component', () => {
|
||||
|
||||
it('renders with no properties', () => {
|
||||
const filter = createTestFilter([]);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Empty Filter',
|
||||
filter,
|
||||
});
|
||||
@@ -113,7 +113,7 @@ describe('CheckboxFilter Component', () => {
|
||||
it('checkboxes reflect initial selected state', async () => {
|
||||
const properties = createMockProperties(3, [0, 2]);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -132,7 +132,7 @@ describe('CheckboxFilter Component', () => {
|
||||
it('clicking checkbox toggles property.selected state', async () => {
|
||||
const properties = createMockProperties(3, [0]);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -164,7 +164,7 @@ describe('CheckboxFilter Component', () => {
|
||||
it('label styling changes based on selection state', async () => {
|
||||
const properties = createMockProperties(2, [0]);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -192,7 +192,7 @@ describe('CheckboxFilter Component', () => {
|
||||
it('multiple checkboxes can be toggled independently', async () => {
|
||||
const properties = createMockProperties(3);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -223,7 +223,7 @@ describe('CheckboxFilter Component', () => {
|
||||
describe('Collapsible Behavior', () => {
|
||||
it('is open by default', () => {
|
||||
const filter = createTestFilter(createMockProperties(2));
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -235,7 +235,7 @@ describe('CheckboxFilter Component', () => {
|
||||
|
||||
it('clicking trigger toggles open/close state', async () => {
|
||||
const filter = createTestFilter(createMockProperties(2));
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -263,7 +263,7 @@ describe('CheckboxFilter Component', () => {
|
||||
|
||||
it('chevron icon rotates based on open state', async () => {
|
||||
const filter = createTestFilter(createMockProperties(2));
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -297,7 +297,7 @@ describe('CheckboxFilter Component', () => {
|
||||
it('badge shows correct count based on filter.selectedCount', async () => {
|
||||
const properties = createMockProperties(5, [0, 2, 4]);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -318,7 +318,7 @@ describe('CheckboxFilter Component', () => {
|
||||
it('badge visibility changes with hasSelection (selectedCount > 0)', async () => {
|
||||
const properties = createMockProperties(2, [0]);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -347,7 +347,7 @@ describe('CheckboxFilter Component', () => {
|
||||
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, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -359,7 +359,7 @@ describe('CheckboxFilter Component', () => {
|
||||
describe('Accessibility', () => {
|
||||
it('provides proper ARIA labels on buttons', () => {
|
||||
const filter = createTestFilter(createMockProperties(2));
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test Label',
|
||||
filter,
|
||||
});
|
||||
@@ -372,7 +372,7 @@ describe('CheckboxFilter Component', () => {
|
||||
it('labels are properly associated with checkboxes', async () => {
|
||||
const properties = createMockProperties(3);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -391,7 +391,7 @@ describe('CheckboxFilter Component', () => {
|
||||
|
||||
it('checkboxes have proper role', async () => {
|
||||
const filter = createTestFilter(createMockProperties(2));
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -406,7 +406,7 @@ describe('CheckboxFilter Component', () => {
|
||||
it('labels are clickable and toggle associated checkboxes', async () => {
|
||||
const properties = createMockProperties(2);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -447,7 +447,7 @@ describe('CheckboxFilter Component', () => {
|
||||
},
|
||||
];
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -466,7 +466,7 @@ describe('CheckboxFilter Component', () => {
|
||||
{ id: '3', name: '(Special) <Characters>', value: '3', selected: false },
|
||||
];
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -481,7 +481,7 @@ describe('CheckboxFilter Component', () => {
|
||||
{ id: '1', name: 'Only One', value: '1', selected: true },
|
||||
];
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Single',
|
||||
filter,
|
||||
});
|
||||
@@ -493,7 +493,7 @@ describe('CheckboxFilter Component', () => {
|
||||
it('handles very large number of properties', async () => {
|
||||
const properties = createMockProperties(50, [0, 25, 49]);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Large List',
|
||||
filter,
|
||||
});
|
||||
@@ -506,7 +506,7 @@ describe('CheckboxFilter Component', () => {
|
||||
it('updates badge when filter is manipulated externally', async () => {
|
||||
const properties = createMockProperties(3);
|
||||
const filter = createTestFilter(properties);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
});
|
||||
@@ -537,7 +537,7 @@ describe('CheckboxFilter Component', () => {
|
||||
{ id: 'monospace', name: 'Monospace', value: 'monospace', selected: false },
|
||||
];
|
||||
const filter = createTestFilter(realProperties);
|
||||
render(CheckboxFilter, {
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Font Category',
|
||||
filter,
|
||||
});
|
||||
Reference in New Issue
Block a user