Merge pull request 'feature/searchbar-enhance' (#17) from feature/searchbar-enhance into main
All checks were successful
Workflow / build (push) Successful in 39s

Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
2026-01-18 14:04:52 +00:00
52 changed files with 1481 additions and 1220 deletions

View File

@@ -9,7 +9,7 @@
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-check --tsconfig ./tsconfig.json || echo ''",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check": "svelte-check",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"check:shadcn-excluded": "svelte-check --no-tsconfig --ignore \"src/shared/shadcn\"",
"lint": "oxlint",
@@ -23,7 +23,7 @@
"test:component": "vitest run --config vitest.config.component.ts",
"test:component:browser": "vitest run --config vitest.config.browser.ts",
"test:component:browser:watch": "vitest --config vitest.config.browser.ts",
"test": "npm run test:e2e && npm run test:unit",
"test": "yarn run test:unit",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},

View File

@@ -24,6 +24,8 @@ let { children } = $props();
<svelte:head>
<link rel="icon" href={favicon} />
<link rel="preconnect" href="https://api.fontshare.com" />
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
</svelte:head>
<div id="app-root">

View File

@@ -60,9 +60,11 @@ export type {
} from './model';
export {
appliedFontsManager,
createFontshareStore,
fetchFontshareFontsQuery,
fontshareStore,
selectedFontsStore,
} from './model';
// Stores
@@ -72,4 +74,8 @@ export {
} from './model/services/fetchGoogleFonts.svelte';
// UI elements
export { FontList } from './ui';
export {
FontApplicator,
FontListItem,
FontVirtualList,
} from './ui';

View File

@@ -37,7 +37,9 @@ export type {
export { fetchFontshareFontsQuery } from './services';
export {
appliedFontsManager,
createFontshareStore,
type FontshareStore,
fontshareStore,
selectedFontsStore,
} from './store';

View File

@@ -0,0 +1,150 @@
import { SvelteMap } from 'svelte/reactivity';
export type FontStatus = 'loading' | 'loaded' | 'error';
/**
* Manager that handles loading of the fonts
* Adds <link /> tags to <head />
* - Uses batch loading to reduce the number of requests
* - Uses a queue to prevent too many requests at once
* - Purges unused fonts after a certain time
*/
class AppliedFontsManager {
// Stores: slug -> timestamp of last visibility
#usageTracker = new Map<string, number>();
// Stores: slug -> batchId
#slugToBatch = new Map<string, string>();
// Stores: batchId -> HTMLLinkElement (for physical cleanup)
#batchElements = new Map<string, HTMLLinkElement>();
#queue = new Set<string>();
#timeoutId: ReturnType<typeof setTimeout> | null = null;
#PURGE_INTERVAL = 60000; // Check every minute
#TTL = 5 * 60 * 1000; // 5 minutes
#CHUNK_SIZE = 3;
statuses = new SvelteMap<string, FontStatus>();
constructor() {
if (typeof window !== 'undefined') {
// Start the "Janitor" loop
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
}
}
/**
* Updates the 'last seen' timestamp for fonts.
* Prevents them from being purged while they are on screen.
*/
touch(slugs: string[]) {
const now = Date.now();
const toRegister: string[] = [];
slugs.forEach(slug => {
this.#usageTracker.set(slug, now);
if (!this.#slugToBatch.has(slug)) {
toRegister.push(slug);
}
});
if (toRegister.length > 0) this.registerFonts(toRegister);
}
registerFonts(slugs: string[]) {
const newSlugs = slugs.filter(s => !this.#slugToBatch.has(s) && !this.#queue.has(s));
if (newSlugs.length === 0) return;
newSlugs.forEach(s => this.#queue.add(s));
if (this.#timeoutId) clearTimeout(this.#timeoutId);
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
}
getFontStatus(slug: string) {
return this.statuses.get(slug);
}
#processQueue() {
const fullQueue = Array.from(this.#queue);
if (fullQueue.length === 0) return;
for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) {
this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE));
}
this.#queue.clear();
this.#timeoutId = null;
}
#createBatch(slugs: string[]) {
if (typeof document === 'undefined') return;
const batchId = crypto.randomUUID();
// font-display=swap included for better UX
const query = slugs.map(s => `f[]=${s.toLowerCase()}@400`).join('&');
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`;
// Mark all as loading immediately
slugs.forEach(slug => this.statuses.set(slug, 'loading'));
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
link.dataset.batchId = batchId;
document.head.appendChild(link);
this.#batchElements.set(batchId, link);
slugs.forEach(slug => {
this.#slugToBatch.set(slug, batchId);
// Use the Native Font Loading API
// format: "font-size font-family"
document.fonts.load(`1em "${slug}"`)
.then(loadedFonts => {
if (loadedFonts.length > 0) {
this.statuses.set(slug, 'loaded');
} else {
this.statuses.set(slug, 'error');
}
})
.catch(() => {
this.statuses.set(slug, 'error');
});
});
}
#purgeUnused() {
const now = Date.now();
const batchesToPotentialDelete = new Set<string>();
const slugsToDelete: string[] = [];
// Identify expired slugs
for (const [slug, lastUsed] of this.#usageTracker.entries()) {
if (now - lastUsed > this.#TTL) {
const batchId = this.#slugToBatch.get(slug);
if (batchId) batchesToPotentialDelete.add(batchId);
slugsToDelete.push(slug);
}
}
// Only remove a batch if ALL fonts in that batch are expired
batchesToPotentialDelete.forEach(batchId => {
const batchSlugs = Array.from(this.#slugToBatch.entries())
.filter(([_, bId]) => bId === batchId)
.map(([slug]) => slug);
const allExpired = batchSlugs.every(s => slugsToDelete.includes(s));
if (allExpired) {
this.#batchElements.get(batchId)?.remove();
this.#batchElements.delete(batchId);
batchSlugs.forEach(s => {
this.#slugToBatch.delete(s);
this.#usageTracker.delete(s);
});
}
});
}
}
export const appliedFontsManager = new AppliedFontsManager();

View File

@@ -17,3 +17,7 @@ export {
type FontshareStore,
fontshareStore,
} from './fontshareStore.svelte';
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte';

View File

@@ -0,0 +1,7 @@
import { createEntityStore } from '$shared/lib';
import type { UnifiedFont } from '../../types';
/**
* Store that handles collection of selected fonts
*/
export const selectedFontsStore = createEntityStore<UnifiedFont>([]);

View File

@@ -0,0 +1,77 @@
<!--
Component: FontApplicator
Loads fonts from fontshare with link tag
- Loads font only if it's not already applied
- Uses IntersectionObserver to detect when font is visible
- Adds smooth transition when font appears
-->
<script lang="ts">
import { motion } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import { appliedFontsManager } from '../../model';
interface Props {
/**
* Font name to set
*/
name: string;
/**
* Font id to load
*/
id: string;
/**
* Additional classes
*/
className?: string;
/**
* Children
*/
children?: Snippet;
}
let { name, id, className, children }: Props = $props();
let element: Element;
// Track if the user has actually scrolled this into view
let hasEnteredViewport = $state(false);
$effect(() => {
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
hasEnteredViewport = true;
appliedFontsManager.touch([id]);
// Once it has entered, we can stop observing to save CPU
observer.unobserve(element);
}
});
observer.observe(element);
return () => observer.disconnect();
});
const status = $derived(appliedFontsManager.getFontStatus(id));
// The "Show" condition: Element is in view AND (Font is ready OR it errored out)
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error'));
const transitionClasses = $derived(
motion.reduced
? 'transition-none' // Disable CSS transitions if motion is reduced
: 'transition-all duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]',
);
</script>
<div
bind:this={element}
style:font-family={name}
class={cn(
transitionClasses,
// If reduced motion is on, we skip the transform/blur entirely
!shouldReveal && !motion.reduced && 'opacity-0 translate-y-8 scale-[0.98] blur-sm',
!shouldReveal && motion.reduced && 'opacity-0', // Still hide until font is ready, but no movement
shouldReveal && 'opacity-100 translate-y-0 scale-100 blur-0',
className,
)}
>
{@render children?.()}
</div>

View File

@@ -1,28 +0,0 @@
<script lang="ts">
import { fontshareStore } from '$entities/Font/model/store/fontshareStore.svelte';
import {
Content as ItemContent,
Root as ItemRoot,
Title as ItemTitle,
} from '$shared/shadcn/ui/item';
import { VirtualList } from '$shared/ui';
/**
* FontList
*
* Displays a virtualized list of fonts with loading, empty, and error states.
* Uses unifiedFontStore from context for data, but can accept explicit fonts via props.
*/
</script>
<VirtualList items={fontshareStore.fonts} itemHeight={30}>
{#snippet children({ item: font })}
<ItemRoot>
<ItemContent>
<ItemTitle>{font.name}</ItemTitle>
<span class="text-xs text-muted-foreground">
{font.category}{font.provider}
</span>
</ItemContent>
</ItemRoot>
{/snippet}
</VirtualList>

View File

@@ -0,0 +1,84 @@
<!--
Component: FontListItem
Displays a font item with a checkbox and its characteristics in badges.
-->
<script lang="ts">
import { Badge } from '$shared/shadcn/ui/badge';
import { Checkbox } from '$shared/shadcn/ui/checkbox';
import { Label } from '$shared/shadcn/ui/label';
import {
type UnifiedFont,
selectedFontsStore,
} from '../../model';
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
interface Props {
/**
* Object with information about font
*/
font: UnifiedFont;
}
const { font }: Props = $props();
const handleChange = (checked: boolean) => {
if (checked) {
selectedFontsStore.addOne(font);
} else {
selectedFontsStore.removeOne(font.id);
}
};
const selected = $derived(selectedFontsStore.has(font.id));
</script>
<div class="pb-1">
<Label
for={font.id}
class="
w-full hover:bg-accent/50 flex items-start gap-3 rounded-lg border border-transparent p-3
active:scale-[0.98] active:transition-transform active:duration-75
has-aria-checked:border-blue-600
has-aria-checked:bg-blue-50
dark:has-aria-checked:border-blue-900
dark:has-aria-checked:bg-blue-950
"
>
<div class="w-full">
<div class="flex flex-row gap-1 w-full items-center justify-between">
<div class="flex flex-col gap-1 transition-all duration-150 ease-out">
<div class="flex flex-row gap-1">
<Badge variant="outline" class="text-[0.5rem]">
{font.provider}
</Badge>
<Badge variant="outline" class="text-[0.5rem]">
{font.category}
</Badge>
</div>
<FontApplicator
id={font.id}
className="text-2xl"
name={font.name}
>
{font.name}
</FontApplicator>
</div>
<Checkbox
id={font.id}
checked={selected}
onCheckedChange={handleChange}
class="
transition-all duration-150 ease-out
data-[state=checked]:scale-100
data-[state=checked]:border-blue-600
data-[state=checked]:bg-blue-600
data-[state=checked]:text-white
dark:data-[state=checked]:border-blue-700
dark:data-[state=checked]:bg-blue-700
"
/>
</div>
</div>
</Label>
</div>

View File

@@ -0,0 +1,35 @@
<!--
Component: FontVirtualList
- Renders a virtualized list of fonts
- Handles font registration with the manager
-->
<script lang="ts" generics="T extends { id: string }">
import { VirtualList } from '$shared/ui';
import type { ComponentProps } from 'svelte';
import { appliedFontsManager } from '../../model';
interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> {
onVisibleItemsChange?: (items: T[]) => void;
}
let { items, children, onVisibleItemsChange, ...rest }: Props = $props();
function handleInternalVisibleChange(visibleItems: T[]) {
// Auto-register fonts with the manager
const slugs = visibleItems.map(item => item.id);
appliedFontsManager.registerFonts(slugs);
// Forward the call to any external listener
onVisibleItemsChange?.(visibleItems);
}
</script>
<VirtualList
{items}
{...rest}
onVisibleItemsChange={handleInternalVisibleChange}
>
{#snippet children(scope)}
{@render children(scope)}
{/snippet}
</VirtualList>

View File

@@ -1,3 +1,9 @@
import FontList from './FontList/FontList.svelte';
import FontApplicator from './FontApplicator/FontApplicator.svelte';
import FontListItem from './FontListItem/FontListItem.svelte';
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
export { FontList };
export {
FontApplicator,
FontListItem,
FontVirtualList,
};

View File

@@ -0,0 +1 @@
export { FontDisplay } from './ui';

View File

@@ -0,0 +1 @@
export { displayedFontsStore } from './store';

View File

@@ -0,0 +1,28 @@
import { selectedFontsStore } from '$entities/Font';
/**
* Store for displayed font samples
* - Handles shown text
* - Stores selected fonts for display
*/
export class DisplayedFontsStore {
#sampleText = $state('The quick brown fox jumps over the lazy dog');
#displayedFonts = $derived.by(() => {
return selectedFontsStore.all;
});
get fonts() {
return this.#displayedFonts;
}
get text() {
return this.#sampleText;
}
set text(text: string) {
this.#sampleText = text;
}
}
export const displayedFontsStore = new DisplayedFontsStore();

View File

@@ -0,0 +1 @@
export { displayedFontsStore } from './displayedFontsStore.svelte';

View File

@@ -0,0 +1,14 @@
<!--
Component: FontDisplay
Displays a grid of FontSampler components for each displayed font.
-->
<script>
import { displayedFontsStore } from '../../model';
import FontSampler from '../FontSampler/FontSampler.svelte';
</script>
<div class="grid gap-2 grid-cols-[repeat(auto-fit,minmax(500px,1fr))]">
{#each displayedFontsStore.fonts as font (font.id)}
<FontSampler font={font} bind:text={displayedFontsStore.text} />
{/each}
</div>

View File

@@ -0,0 +1,46 @@
<!--
Component: FontSampler
Displays a sample text with a given font in a contenteditable element.
-->
<script lang="ts">
import {
FontApplicator,
type UnifiedFont,
} from '$entities/Font';
import { ContentEditable } from '$shared/ui';
interface Props {
/**
* Font info
*/
font: UnifiedFont;
/**
* Text to display
*/
text: string;
/**
* Font settings
*/
fontSize?: number;
lineHeight?: number;
letterSpacing?: number;
}
let {
font,
text = $bindable(),
...restProps
}: Props = $props();
</script>
<div
class="
w-full rounded-xl
bg-white p-6 border border-slate-200
shadow-sm dark:border-slate-800 dark:bg-slate-950
"
>
<FontApplicator id={font.id} name={font.name}>
<ContentEditable bind:text={text} {...restProps} />
</FontApplicator>
</div>

View File

@@ -0,0 +1,3 @@
import FontDisplay from './FontDisplay/FontDisplay.svelte';
export { FontDisplay };

View File

@@ -4,6 +4,11 @@ import type { FilterConfig } from '../../model';
/**
* Create a filter manager instance.
* - Uses debounce to update search query for better performance.
* - Manages filter instances for each group.
*
* @param config - Configuration for the filter manager.
* @returns - An instance of the filter manager.
*/
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
const search = createDebouncedState(config.queryValue ?? '');

View File

@@ -1,6 +1,12 @@
import type { FontshareParams } from '$entities/Font';
import type { FilterManager } from '../filterManager/filterManager.svelte';
/**
* Maps filter manager to fontshare params.
*
* @param manager - Filter manager instance.
* @returns - Partial fontshare params.
*/
export function mapManagerToParams(manager: FilterManager): Partial<FontshareParams> {
return {
q: manager.debouncedQueryValue,

View File

@@ -1,18 +1,8 @@
<!--
Component: Filters
Renders a list of CheckboxFilter components for each filter group.
-->
<script lang="ts">
/**
* Filters Component
*
* Orchestrates all filter properties for the sidebar. Connects filter stores
* to CheckboxFilter components, organizing them by filter type:
*
* - Font provider: Google Fonts vs Fontshare
* - Font subset: Character subsets available (Latin, Latin Extended, etc.)
* - Font category: Serif, Sans-serif, Display, etc.
*
* This component handles reactive sync between filterManager selections
* and the unifiedFontStore using an $effect block to ensure filters are
* automatically synchronized whenever selections change.
*/
import { CheckboxFilter } from '$shared/ui';
import { filterManager } from '../../model';
</script>

View File

@@ -1,14 +1,11 @@
<!--
Component: FiltersControl
Renders a group of action buttons for filter operations.
- Reset: Clears all active filters (outline variant for secondary action)
-->
<script lang="ts">
import { Button } from '$shared/shadcn/ui/button';
import { filterManager } from '../../model';
/**
* Controls Component
*
* Action button group for filter operations. Provides two buttons:
*
* - Reset: Clears all active filters (outline variant for secondary action)
*/
</script>
<div class="flex flex-row gap-2">

View File

@@ -1,19 +1,16 @@
<!--
Component: FontSearch
Combines search input with font list display
-->
<script lang="ts">
import {
FontList,
fontshareStore,
} from '$entities/Font';
import { fontshareStore } from '$entities/Font';
import { SearchBar } from '$shared/ui';
import { onMount } from 'svelte';
import { mapManagerToParams } from '../../lib';
import { filterManager } from '../../model';
import SuggestedFonts from '../SuggestedFonts/SuggestedFonts.svelte';
/**
* FontSearch
*
* Font search component with search input and font list display.
* Uses unifiedFontStore for all font operations and search state.
*/
onMount(() => {
/**
* The Pairing:
@@ -24,8 +21,6 @@ onMount(() => {
return unbind;
});
$inspect(filterManager.queryValue, filterManager.debouncedQueryValue);
</script>
<SearchBar
@@ -34,5 +29,5 @@ $inspect(filterManager.queryValue, filterManager.debouncedQueryValue);
placeholder="Search fonts by name..."
bind:value={filterManager.queryValue}
>
<FontList />
<SuggestedFonts />
</SearchBar>

View File

@@ -0,0 +1,17 @@
<!--
Component: SuggestedFonts
Renders a list of suggested fonts in a virtualized list to improve performance.
-->
<script lang="ts">
import {
FontListItem,
FontVirtualList,
fontshareStore,
} from '$entities/Font';
</script>
<FontVirtualList items={fontshareStore.fonts}>
{#snippet children({ item: font })}
<FontListItem {font} />
{/snippet}
</FontVirtualList>

View File

@@ -3,6 +3,12 @@ import {
createTypographyControl,
} from '$shared/lib';
/**
* Creates a typography control manager that handles a collection of typography controls.
*
* @param configs - Array of control configurations.
* @returns - Typography control manager instance.
*/
export function createTypographyControlManager(configs: ControlModel[]) {
const controls = $state(
configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({

View File

@@ -1,7 +1,8 @@
<!--
Component: SetupFontMenu
Contains controls for setting up font properties.
-->
<script lang="ts">
/**
* Component containing controls for setting up font properties.
*/
import { Separator } from '$shared/shadcn/ui/separator/index';
import { Trigger as SidebarTrigger } from '$shared/shadcn/ui/sidebar';
import { ComboControl } from '$shared/ui';

View File

@@ -1,27 +1,12 @@
<script lang="ts">
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
/**
* Page Component
*
* Main page route component. Displays the font list and allows testing
* the unified font store functionality. Fetches fonts on mount and displays
* them using the FontList component.
*
* Receives unifiedFontStore from context created in Layout.svelte.
*/
// import {
// UNIFIED_FONT_STORE_KEY,
// type UnifiedFontStore,
// } from '$entities/Font/model/store/unifiedFontStore.svelte';
import FontList from '$entities/Font/ui/FontList/FontList.svelte';
// import { applyFilters } from '$features/FontManagement';
import {
getContext,
onMount,
} from 'svelte';
// Receive store from context (created in Layout.svelte)
// const unifiedFontStore: UnifiedFontStore = getContext(UNIFIED_FONT_STORE_KEY);
</script>
<!-- Font List -->
<FontList />
<div class="p-2">
<FontDisplay />
</div>

View File

@@ -0,0 +1,32 @@
// Check if we are in a browser environment
const isBrowser = typeof window !== 'undefined';
// A class to manage motion preference and provide a single instance for use everywhere
class MotionPreference {
// Reactive state
#reduced = $state(false);
constructor() {
if (isBrowser) {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// Set initial value immediately
this.#reduced = mediaQuery.matches;
// Simple listener that updates the reactive state
const handleChange = (e: MediaQueryListEvent) => {
this.#reduced = e.matches;
};
mediaQuery.addEventListener('change', handleChange);
}
}
// Getter allows us to use 'motion.reduced' reactively in components
get reduced() {
return this.#reduced;
}
}
// Export a single instance to be used everywhere
export const motion = new MotionPreference();

View File

@@ -1,444 +0,0 @@
import { get } from 'svelte/store';
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import {
type CacheOptions,
createCollectionCache,
} from './collectionCache';
describe('createCollectionCache', () => {
let cache: ReturnType<typeof createCollectionCache<number>>;
beforeEach(() => {
cache = createCollectionCache<number>();
});
describe('initialization', () => {
it('initializes with empty cache', () => {
const data = get(cache.data);
expect(data).toEqual({});
});
it('initializes with default options', () => {
const stats = cache.getStats();
expect(stats.total).toBe(0);
expect(stats.cached).toBe(0);
expect(stats.fetching).toBe(0);
expect(stats.errors).toBe(0);
expect(stats.hits).toBe(0);
expect(stats.misses).toBe(0);
});
it('accepts custom cache options', () => {
const options: CacheOptions = {
defaultTTL: 10 * 60 * 1000, // 10 minutes
maxSize: 500,
};
const customCache = createCollectionCache<number>(options);
expect(customCache).toBeDefined();
});
});
describe('set and get', () => {
it('sets a value in cache', () => {
cache.set('key1', 100);
const value = cache.get('key1');
expect(value).toBe(100);
});
it('sets multiple values in cache', () => {
cache.set('key1', 100);
cache.set('key2', 200);
cache.set('key3', 300);
expect(cache.get('key1')).toBe(100);
expect(cache.get('key2')).toBe(200);
expect(cache.get('key3')).toBe(300);
});
it('updates existing value', () => {
cache.set('key1', 100);
cache.set('key1', 150);
expect(cache.get('key1')).toBe(150);
});
it('returns undefined for non-existent key', () => {
const value = cache.get('non-existent');
expect(value).toBeUndefined();
});
it('marks item as ready after set', () => {
cache.set('key1', 100);
const internalState = cache.getInternalState('key1');
expect(internalState?.ready).toBe(true);
expect(internalState?.fetching).toBe(false);
});
});
describe('has and hasFresh', () => {
it('returns false for non-existent key', () => {
expect(cache.has('non-existent')).toBe(false);
expect(cache.hasFresh('non-existent')).toBe(false);
});
it('returns true after setting value', () => {
cache.set('key1', 100);
expect(cache.has('key1')).toBe(true);
expect(cache.hasFresh('key1')).toBe(true);
});
it('returns false for fetching items', () => {
cache.markFetching('key1');
expect(cache.has('key1')).toBe(false);
expect(cache.hasFresh('key1')).toBe(false);
});
it('returns false for failed items', () => {
cache.markFailed('key1', 'Network error');
expect(cache.has('key1')).toBe(false);
expect(cache.hasFresh('key1')).toBe(false);
});
});
describe('remove', () => {
it('removes a value from cache', () => {
cache.set('key1', 100);
cache.set('key2', 200);
cache.remove('key1');
expect(cache.get('key1')).toBeUndefined();
expect(cache.get('key2')).toBe(200);
});
it('removes internal state', () => {
cache.set('key1', 100);
cache.remove('key1');
const state = cache.getInternalState('key1');
expect(state).toBeUndefined();
});
it('does nothing for non-existent key', () => {
expect(() => cache.remove('non-existent')).not.toThrow();
});
});
describe('clear', () => {
it('clears all values from cache', () => {
cache.set('key1', 100);
cache.set('key2', 200);
cache.set('key3', 300);
cache.clear();
expect(cache.get('key1')).toBeUndefined();
expect(cache.get('key2')).toBeUndefined();
expect(cache.get('key3')).toBeUndefined();
});
it('clears internal state', () => {
cache.set('key1', 100);
cache.clear();
const state = cache.getInternalState('key1');
expect(state).toBeUndefined();
});
it('resets cache statistics', () => {
cache.set('key1', 100); // This increments hits
const _statsBefore = cache.getStats();
cache.clear();
const statsAfter = cache.getStats();
expect(statsAfter.hits).toBe(0);
expect(statsAfter.misses).toBe(0);
});
});
describe('markFetching', () => {
it('marks item as fetching', () => {
cache.markFetching('key1');
expect(cache.isFetching('key1')).toBe(true);
const state = cache.getInternalState('key1');
expect(state?.fetching).toBe(true);
expect(state?.ready).toBe(false);
expect(state?.startTime).toBeDefined();
});
it('updates existing state when called again', () => {
cache.markFetching('key1');
const startTime1 = cache.getInternalState('key1')?.startTime;
// Wait a bit to ensure different timestamp
vi.useFakeTimers();
vi.advanceTimersByTime(100);
cache.markFetching('key1');
const startTime2 = cache.getInternalState('key1')?.startTime;
expect(startTime2).toBeGreaterThan(startTime1!);
vi.useRealTimers();
});
it('sets endTime to undefined', () => {
cache.markFetching('key1');
const state = cache.getInternalState('key1');
expect(state?.endTime).toBeUndefined();
});
});
describe('markFailed', () => {
it('marks item as failed with error message', () => {
cache.markFailed('key1', 'Network error');
expect(cache.isFetching('key1')).toBe(false);
const error = cache.getError('key1');
expect(error).toBe('Network error');
const state = cache.getInternalState('key1');
expect(state?.fetching).toBe(false);
expect(state?.ready).toBe(false);
expect(state?.error).toBe('Network error');
});
it('preserves start time from fetching state', () => {
cache.markFetching('key1');
const startTime = cache.getInternalState('key1')?.startTime;
cache.markFailed('key1', 'Error');
const state = cache.getInternalState('key1');
expect(state?.startTime).toBe(startTime);
});
it('sets end time', () => {
cache.markFailed('key1', 'Error');
const state = cache.getInternalState('key1');
expect(state?.endTime).toBeDefined();
});
it('increments error counter', () => {
const statsBefore = cache.getStats();
cache.markFailed('key1', 'Error1');
const statsAfter1 = cache.getStats();
expect(statsAfter1.errors).toBe(statsBefore.errors + 1);
cache.markFailed('key2', 'Error2');
const statsAfter2 = cache.getStats();
expect(statsAfter2.errors).toBe(statsAfter1.errors + 1);
});
});
describe('markMiss', () => {
it('increments miss counter', () => {
const statsBefore = cache.getStats();
cache.markMiss();
const statsAfter = cache.getStats();
expect(statsAfter.misses).toBe(statsBefore.misses + 1);
});
it('increments miss counter multiple times', () => {
const statsBefore = cache.getStats();
cache.markMiss();
cache.markMiss();
cache.markMiss();
const statsAfter = cache.getStats();
expect(statsAfter.misses).toBe(statsBefore.misses + 3);
});
});
describe('statistics', () => {
it('tracks total number of items', () => {
expect(cache.getStats().total).toBe(0);
cache.set('key1', 100);
expect(cache.getStats().total).toBe(1);
cache.set('key2', 200);
expect(cache.getStats().total).toBe(2);
cache.remove('key1');
expect(cache.getStats().total).toBe(1);
});
it('tracks number of cached (ready) items', () => {
expect(cache.getStats().cached).toBe(0);
cache.set('key1', 100);
expect(cache.getStats().cached).toBe(1);
cache.set('key2', 200);
expect(cache.getStats().cached).toBe(2);
cache.markFetching('key3');
expect(cache.getStats().cached).toBe(2);
});
it('tracks number of fetching items', () => {
expect(cache.getStats().fetching).toBe(0);
cache.markFetching('key1');
expect(cache.getStats().fetching).toBe(1);
cache.markFetching('key2');
expect(cache.getStats().fetching).toBe(2);
cache.set('key1', 100);
expect(cache.getStats().fetching).toBe(1);
});
it('tracks cache hits', () => {
const statsBefore = cache.getStats();
cache.set('key1', 100);
const statsAfter1 = cache.getStats();
expect(statsAfter1.hits).toBe(statsBefore.hits + 1);
cache.set('key2', 200);
const statsAfter2 = cache.getStats();
expect(statsAfter2.hits).toBe(statsAfter1.hits + 1);
});
it('provides derived stats store', () => {
cache.set('key1', 100);
cache.markFetching('key2');
const stats = get(cache.stats);
expect(stats.total).toBe(1);
expect(stats.cached).toBe(1);
expect(stats.fetching).toBe(1);
});
});
describe('store reactivity', () => {
it('updates data store reactively', () => {
let dataUpdates = 0;
const unsubscribe = cache.data.subscribe(() => {
dataUpdates++;
});
cache.set('key1', 100);
cache.set('key2', 200);
expect(dataUpdates).toBeGreaterThan(0);
unsubscribe();
});
it('updates internal state store reactively', () => {
let internalUpdates = 0;
const unsubscribe = cache.internal.subscribe(() => {
internalUpdates++;
});
cache.markFetching('key1');
cache.set('key1', 100);
cache.markFailed('key2', 'Error');
expect(internalUpdates).toBeGreaterThan(0);
unsubscribe();
});
it('updates stats store reactively', () => {
let statsUpdates = 0;
const unsubscribe = cache.stats.subscribe(() => {
statsUpdates++;
});
cache.set('key1', 100);
cache.markMiss();
expect(statsUpdates).toBeGreaterThan(0);
unsubscribe();
});
});
describe('edge cases', () => {
it('handles complex types', () => {
interface ComplexType {
id: string;
value: number;
tags: string[];
}
const complexCache = createCollectionCache<ComplexType>();
const item: ComplexType = {
id: '1',
value: 42,
tags: ['a', 'b', 'c'],
};
complexCache.set('item1', item);
const retrieved = complexCache.get('item1');
expect(retrieved).toEqual(item);
expect(retrieved?.tags).toEqual(['a', 'b', 'c']);
});
it('handles special characters in keys', () => {
cache.set('key with spaces', 1);
cache.set('key/with/slashes', 2);
cache.set('key-with-dashes', 3);
expect(cache.get('key with spaces')).toBe(1);
expect(cache.get('key/with/slashes')).toBe(2);
expect(cache.get('key-with-dashes')).toBe(3);
});
it('handles rapid set and remove operations', () => {
for (let i = 0; i < 100; i++) {
cache.set(`key${i}`, i);
}
for (let i = 0; i < 100; i += 2) {
cache.remove(`key${i}`);
}
expect(cache.getStats().total).toBe(50);
expect(cache.get('key0')).toBeUndefined();
expect(cache.get('key1')).toBe(1);
});
});
describe('error handling', () => {
it('handles concurrent markFetching for same key', () => {
cache.markFetching('key1');
cache.markFetching('key1');
const state = cache.getInternalState('key1');
expect(state?.fetching).toBe(true);
expect(state?.startTime).toBeDefined();
});
it('handles marking failed without prior fetching', () => {
cache.markFailed('key1', 'Error');
const state = cache.getInternalState('key1');
expect(state?.fetching).toBe(false);
expect(state?.ready).toBe(false);
expect(state?.error).toBe('Error');
});
it('handles operations on removed keys', () => {
cache.set('key1', 100);
cache.remove('key1');
expect(() => cache.set('key1', 200)).not.toThrow();
expect(() => cache.remove('key1')).not.toThrow();
expect(() => cache.getError('key1')).not.toThrow();
});
});
});

View File

@@ -1,334 +0,0 @@
/**
* Collection cache manager
*
* Provides key-based caching, deduplication, and request tracking
* for any collection type. Integrates with Svelte stores for reactive updates.
*
* Key features:
* - Key-based caching (any ID, query hash)
* - Request deduplication (prevents concurrent requests for same key)
* - Request state tracking (fetching, ready, error)
* - TTL/staleness management
* - Performance timing tracking
*/
import type {
Readable,
Writable,
} from 'svelte/store';
import {
derived,
get,
writable,
} from 'svelte/store';
/**
* Internal state for a cached item
* Tracks request lifecycle (fetching → ready/error)
*/
export interface CacheItemInternalState {
/** Whether a fetch is currently in progress */
fetching: boolean;
/** Whether data is ready and cached */
ready: boolean;
/** Error message if fetch failed */
error?: string;
/** Request start timestamp (performance tracking) */
startTime?: number;
/** Request end timestamp (performance tracking) */
endTime?: number;
}
/**
* Cache configuration options
*/
export interface CacheOptions {
/** Default time-to-live for cached items (in milliseconds) */
defaultTTL?: number;
/** Maximum number of items to cache (LRU eviction) */
maxSize?: number;
}
/**
* Statistics about cache performance
*/
export interface CacheStats {
/** Total number of items in cache */
total: number;
/** Number of items marked as ready */
cached: number;
/** Number of items currently fetching */
fetching: number;
/** Number of items with errors */
errors: number;
/** Total cache hits (data returned from cache) */
hits: number;
/** Total cache misses (data fetched from API) */
misses: number;
}
/**
* Cache manager interface
* Type-safe interface for collection caching operations
*/
export interface CollectionCacheManager<T> {
/** Get an item from cache by key */
get: (key: string) => T | undefined;
/** Check if item exists in cache and is ready */
has: (key: string) => boolean;
/** Check if item exists and is not stale */
hasFresh: (key: string) => boolean;
/** Set an item in cache (manual cache write) */
set: (key: string, value: T, ttl?: number) => void;
/** Remove item from cache */
remove: (key: string) => void;
/** Clear all items from cache */
clear: () => void;
/** Check if key is currently being fetched */
isFetching: (key: string) => boolean;
/** Get error for a key */
getError: (key: string) => string | undefined;
/** Get internal state for a key (for debugging) */
getInternalState: (key: string) => CacheItemInternalState | undefined;
/** Get cache statistics */
getStats: () => CacheStats;
/** Mark item as fetching (used when starting API request) */
markFetching: (key: string) => void;
/** Mark item as failed (used when API request fails) */
markFailed: (key: string, error: string) => void;
/** Increment cache miss counter */
markMiss: () => void;
/** Store containing cached data */
data: Writable<Record<string, T>>;
/** Store containing internal state (fetching, ready, error) */
internal: Writable<Record<string, CacheItemInternalState>>;
/** Derived store containing cache statistics */
stats: Readable<CacheStats>;
}
/**
* Creates a collection cache manager
*
* @typeParam T - Type of data being cached (e.g., UnifiedFont, Product, User)
* @param options - Cache configuration options
* @returns Cache manager instance
*
* @example
* ```ts
* const fontCache = createCollectionCache<UnifiedFont>({
* defaultTTL: 5 * 60 * 1000, // 5 minutes
* maxSize: 1000
* });
*
* // Set font in cache
* fontCache.set('Roboto', robotoFont);
*
* // Get font from cache
* const font = fontCache.get('Roboto');
* if (fontCache.hasFresh('Roboto')) {
* // Use cached font
* }
* ```
*/
export function createCollectionCache<T>(_options: CacheOptions = {}): CollectionCacheManager<T> {
// const { defaultTTL = 5 * 60 * 1000, maxSize = 1000 } = options;
// Stores for reactive data
const data: Writable<Record<string, T>> = writable({});
const internal: Writable<Record<string, CacheItemInternalState>> = writable({});
// Cache statistics store
const statsState = writable<CacheStats>({
total: 0,
cached: 0,
fetching: 0,
errors: 0,
hits: 0,
misses: 0,
});
// Derived stats store for reactive updates
const stats = derived([data, internal, statsState], ([$data, $internal, $statsState]) => ({
...$statsState,
total: Object.keys($data).length,
cached: Object.values($internal).filter(s => s.ready).length,
fetching: Object.values($internal).filter(s => s.fetching).length,
errors: Object.values($internal).filter(s => s.error).length,
}));
return {
/**
* Get cached data by key
* Returns undefined if not found
*/
get: (key: string) => {
const currentData = get(data);
return currentData[key];
},
/**
* Check if key exists in cache and is ready
*/
has: (key: string) => {
const currentInternal = get(internal);
const state = currentInternal[key];
return state?.ready === true;
},
/**
* Check if key exists and is not stale (still within TTL)
*/
hasFresh: (key: string) => {
const currentInternal = get(internal);
const currentData = get(data);
const state = currentInternal[key];
if (!state?.ready) {
return false;
}
// Check if item exists in data store
if (!currentData[key]) {
return false;
}
// TODO: Implement TTL check with cachedAt timestamps
// For now, just check ready state
return true;
},
/**
* Set data in cache
* Marks entry as ready and stops fetching state
*/
set: (key: string, value: T, _ttl?: number) => {
data.update(d => ({
...d,
[key]: value,
}));
internal.update(i => {
const existingState = i[key];
return {
...i,
[key]: {
fetching: false,
ready: true,
error: undefined,
startTime: existingState?.startTime,
endTime: Date.now(),
},
};
});
// Update statistics (cache hit)
statsState.update(s => ({ ...s, hits: s.hits + 1 }));
},
/**
* Remove item from cache
*/
remove: (key: string) => {
data.update(d => {
const { [key]: _, ...rest } = d;
return rest;
});
internal.update(i => {
const { [key]: _, ...rest } = i;
return rest;
});
},
/**
* Clear all items from cache
*/
clear: () => {
data.set({});
internal.set({});
statsState.update(s => ({ ...s, hits: 0, misses: 0 }));
},
/**
* Check if key is currently being fetched
*/
isFetching: (key: string) => {
const currentInternal = get(internal);
return currentInternal[key]?.fetching === true;
},
/**
* Get error for a key
*/
getError: (key: string) => {
const currentInternal = get(internal);
return currentInternal[key]?.error;
},
/**
* Get internal state for debugging
*/
getInternalState: (key: string) => {
const currentInternal = get(internal);
return currentInternal[key];
},
/**
* Get current cache statistics
*/
getStats: () => {
return get(stats);
},
/**
* Mark item as fetching (used when starting API request)
*/
markFetching: (key: string) => {
internal.update(internal => ({
...internal,
[key]: {
fetching: true,
ready: false,
error: undefined,
startTime: Date.now(),
endTime: undefined,
},
}));
},
/**
* Mark item as failed (used when API request fails)
*/
markFailed: (key: string, error: string) => {
internal.update(internal => {
const existingState = internal[key];
return {
...internal,
[key]: {
fetching: false,
ready: false,
error,
startTime: existingState?.startTime,
endTime: Date.now(),
},
};
});
// Update statistics
const currentStats = get(stats);
statsState.update(s => ({ ...s, errors: currentStats.errors + 1 }));
},
/**
* Increment cache miss counter
*/
markMiss: () => {
statsState.update(s => ({ ...s, misses: s.misses + 1 }));
},
// Expose stores for reactive binding
data,
internal,
stats,
};
}

View File

@@ -1,14 +0,0 @@
/**
* Shared fetch layer exports
*
* Exports collection caching utilities and reactive patterns for Svelte 5
*/
export { createCollectionCache } from './collectionCache';
export type {
CacheItemInternalState,
CacheOptions,
CacheStats,
CollectionCacheManager,
} from './collectionCache';
export { reactiveQueryArgs } from './reactiveQueryArgs';

View File

@@ -1,37 +0,0 @@
import type { Readable } from 'svelte/store';
import { writable } from 'svelte/store';
/**
* Creates a reactive store that maintains stable references for query arguments
*
* This function wraps a callback in a Svelte store that updates via `$effect.pre()`,
* ensuring that the callback is called before DOM updates while maintaining object
* reference stability.
*
* @typeParam T - Type of query arguments (e.g., CreateQueryOptions)
* @param cb - Callback function that computes query arguments
* @returns Readable store containing current query arguments
*
* @example
* ```ts
* const queryArgsStore = reactiveQueryArgs(() => ({
* queryKey: ['fonts', search],
* queryFn: fetchFonts,
* staleTime: 5000
* }));
*
* // Use in component with TanStack Query
* const query = createQuery(queryArgsStore);
* ```
*/
export const reactiveQueryArgs = <T>(cb: () => T): Readable<T> => {
const store = writable<T>();
// Use $effect.pre() to run before DOM updates
// This ensures stable references while staying reactive
$effect.pre(() => {
store.set(cb());
});
return store;
};

View File

@@ -1,5 +1,28 @@
import { debounce } from '$shared/lib/utils';
/**
* Creates reactive state with immediate and debounced values.
*
* Useful for UI inputs that need instant feedback but debounced logic
* (e.g., search fields with API calls). The immediate value updates on
* every change for UI binding, while debounced updates after a delay.
*
* @param initialValue - Initial value for both immediate and debounced state
* @param wait - Delay in milliseconds before updating debounced value (default: 300)
* @returns Object with immediate/debounced getters, immediate setter, and reset method
*
* @example
* ```svelte
* <script lang="ts">
* const search = createDebouncedState('', 300);
* </script>
*
* <input bind:value={search.immediate} />
*
* <p>Typing: {search.immediate}</p>
* <p>Searching: {search.debounced}</p>
* ```
*/
export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
let immediate = $state(initialValue);
let debounced = $state(initialValue);
@@ -9,16 +32,23 @@ export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
}, wait);
return {
/** Current value with immediate updates (for UI binding) */
get immediate() {
return immediate;
},
set immediate(value: T) {
immediate = value;
updateDebounced(value); // Manually trigger the debounce on write
// Manually trigger the debounce on write
updateDebounced(value);
},
/** Current value with debounced updates (for logic/operations) */
get debounced() {
return debounced;
},
/**
* Resets both values to initial or specified value.
* @param value - Optional value to reset to (defaults to initialValue)
*/
reset(value?: T) {
const resetValue = value ?? initialValue;
immediate = resetValue;
@@ -26,33 +56,3 @@ export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
},
};
}
// export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
// let immediate = $state(initialValue);
// let debounced = $state(initialValue);
// const updateDebounced = debounce((value: T) => {
// debounced = value;
// }, wait);
// $effect(() => {
// updateDebounced(immediate);
// });
// return {
// get immediate() {
// return immediate;
// },
// set immediate(value: T) {
// immediate = value;
// },
// get debounced() {
// return debounced;
// },
// reset(value?: T) {
// const resetValue = value ?? initialValue;
// immediate = resetValue;
// debounced = resetValue;
// },
// };
// }

View File

@@ -0,0 +1,444 @@
import { createDebouncedState } from '$shared/lib';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
/**
* Test Suite for createDebouncedState Helper Function
*
* This suite tests the debounced state management logic,
* including immediate vs debounced updates, timing behavior,
* and reset functionality.
*/
describe('createDebouncedState - Basic Logic', () => {
it('creates state with initial value', () => {
const state = createDebouncedState('initial');
expect(state.immediate).toBe('initial');
expect(state.debounced).toBe('initial');
});
it('supports custom debounce delay', () => {
const state = createDebouncedState('test', 100);
expect(state.immediate).toBe('test');
expect(state.debounced).toBe('test');
});
it('uses default delay of 300ms when not specified', () => {
const state = createDebouncedState('test');
expect(state.immediate).toBe('test');
expect(state.debounced).toBe('test');
});
it('allows updating immediate value', () => {
const state = createDebouncedState('initial');
state.immediate = 'updated';
expect(state.immediate).toBe('updated');
});
});
describe('createDebouncedState - Debounce Timing', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('immediate value updates instantly', () => {
const state = createDebouncedState('initial', 100);
state.immediate = 'updated';
expect(state.immediate).toBe('updated');
expect(state.debounced).toBe('initial');
});
it('debounced value updates after delay', () => {
const state = createDebouncedState('initial', 100);
state.immediate = 'updated';
expect(state.debounced).toBe('initial');
vi.advanceTimersByTime(99);
expect(state.debounced).toBe('initial');
vi.advanceTimersByTime(1);
expect(state.debounced).toBe('updated');
});
it('rapid changes reset the debounce timer', () => {
const state = createDebouncedState('initial', 100);
state.immediate = 'change1';
vi.advanceTimersByTime(50);
state.immediate = 'change2';
vi.advanceTimersByTime(50);
state.immediate = 'change3';
vi.advanceTimersByTime(50);
expect(state.debounced).toBe('initial');
expect(state.immediate).toBe('change3');
vi.advanceTimersByTime(50);
expect(state.debounced).toBe('change3');
});
it('debounced value remains unchanged during rapid updates', () => {
const state = createDebouncedState('initial', 100);
for (let i = 0; i < 5; i++) {
state.immediate = `update${i}`;
vi.advanceTimersByTime(25);
}
expect(state.immediate).toBe('update4');
expect(state.debounced).toBe('initial');
});
});
describe('createDebouncedState - Reset Functionality', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('resets to initial value when called without argument', () => {
const state = createDebouncedState('initial', 100);
state.immediate = 'changed';
vi.advanceTimersByTime(100);
expect(state.immediate).toBe('changed');
expect(state.debounced).toBe('changed');
state.reset();
expect(state.immediate).toBe('initial');
expect(state.debounced).toBe('initial');
});
it('resets to custom value when argument provided', () => {
const state = createDebouncedState('initial', 100);
state.immediate = 'changed';
state.reset('custom');
expect(state.immediate).toBe('custom');
expect(state.debounced).toBe('custom');
});
it('resets immediately without debounce delay', () => {
const state = createDebouncedState('initial', 100);
state.immediate = 'changed';
vi.advanceTimersByTime(50);
state.reset();
expect(state.immediate).toBe('initial');
expect(state.debounced).toBe('initial');
// Pending debounce from 'changed' will still fire after the delay
vi.advanceTimersByTime(50);
expect(state.debounced).toBe('changed');
});
it('resets sets both values immediately', () => {
const state = createDebouncedState('initial', 100);
state.immediate = 'changed';
vi.advanceTimersByTime(50);
state.reset('new');
expect(state.immediate).toBe('new');
expect(state.debounced).toBe('new');
// Pending debounce from 'changed' will fire after remaining delay
vi.advanceTimersByTime(50);
expect(state.debounced).toBe('changed');
});
});
describe('createDebouncedState - Type Support', () => {
it('works with string type', () => {
const state = createDebouncedState<string>('hello', 100);
state.immediate = 'world';
expect(state.immediate).toBe('world');
});
it('works with number type', () => {
const state = createDebouncedState<number>(0, 100);
state.immediate = 42;
expect(state.immediate).toBe(42);
});
it('works with boolean type', () => {
const state = createDebouncedState<boolean>(false, 100);
state.immediate = true;
expect(state.immediate).toBe(true);
});
it('works with object type', () => {
interface TestObject {
value: number;
label: string;
}
const initial: TestObject = { value: 0, label: 'initial' };
const state = createDebouncedState<TestObject>(initial, 100);
const updated: TestObject = { value: 1, label: 'updated' };
state.immediate = updated;
expect(state.immediate).toBe(updated);
expect(state.immediate.value).toBe(1);
});
it('works with array type', () => {
const initial = [1, 2, 3];
const state = createDebouncedState<number[]>(initial, 100);
const updated = [4, 5, 6];
state.immediate = updated;
expect(state.immediate).toEqual(updated);
});
it('works with null type', () => {
const state = createDebouncedState<string | null>(null, 100);
state.immediate = 'not null';
expect(state.immediate).toBe('not null');
});
it('works with undefined type', () => {
const state = createDebouncedState<number | undefined>(undefined, 100);
state.immediate = 42;
expect(state.immediate).toBe(42);
});
});
describe('createDebouncedState - Corner Cases', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('handles empty string', () => {
const state = createDebouncedState('', 100);
state.immediate = '';
vi.advanceTimersByTime(100);
expect(state.immediate).toBe('');
expect(state.debounced).toBe('');
});
it('handles zero value', () => {
const state = createDebouncedState(0, 100);
expect(state.immediate).toBe(0);
expect(state.debounced).toBe(0);
state.immediate = 0;
vi.advanceTimersByTime(100);
expect(state.immediate).toBe(0);
expect(state.debounced).toBe(0);
});
it('handles very short debounce delay (1ms)', () => {
const state = createDebouncedState('initial', 1);
state.immediate = 'changed';
expect(state.debounced).toBe('initial');
vi.advanceTimersByTime(1);
expect(state.debounced).toBe('changed');
});
it('handles very long debounce delay (5000ms)', () => {
const state = createDebouncedState('initial', 5000);
state.immediate = 'changed';
vi.advanceTimersByTime(4999);
expect(state.debounced).toBe('initial');
vi.advanceTimersByTime(1);
expect(state.debounced).toBe('changed');
});
it('handles setting to same value multiple times', () => {
const state = createDebouncedState('initial', 100);
state.immediate = 'same';
vi.advanceTimersByTime(50);
state.immediate = 'same';
vi.advanceTimersByTime(50);
expect(state.immediate).toBe('same');
vi.advanceTimersByTime(100);
expect(state.debounced).toBe('same');
});
it('handles alternating between two values rapidly', () => {
const state = createDebouncedState('initial', 50);
for (let i = 0; i < 5; i++) {
state.immediate = 'value1';
vi.advanceTimersByTime(25);
state.immediate = 'value2';
vi.advanceTimersByTime(25);
}
expect(state.immediate).toBe('value2');
expect(state.debounced).toBe('initial');
vi.advanceTimersByTime(50);
expect(state.debounced).toBe('value2');
});
it('handles reset during pending debounce', () => {
const state = createDebouncedState('initial', 100);
state.immediate = 'changed';
vi.advanceTimersByTime(50);
state.reset();
expect(state.immediate).toBe('initial');
expect(state.debounced).toBe('initial');
// Pending debounce from 'changed' will fire after remaining delay
vi.advanceTimersByTime(50);
expect(state.debounced).toBe('changed');
});
it('handles immediate value changes after reset', () => {
const state = createDebouncedState('initial', 100);
state.reset('new');
expect(state.immediate).toBe('new');
state.immediate = 'newer';
vi.advanceTimersByTime(100);
expect(state.immediate).toBe('newer');
expect(state.debounced).toBe('newer');
});
});
describe('createDebouncedState - Multiple Instances', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('handles multiple independent instances', () => {
const state1 = createDebouncedState('one', 100);
const state2 = createDebouncedState('two', 100);
state1.immediate = 'changed1';
state2.immediate = 'changed2';
expect(state1.immediate).toBe('changed1');
expect(state2.immediate).toBe('changed2');
vi.advanceTimersByTime(100);
expect(state1.debounced).toBe('changed1');
expect(state2.debounced).toBe('changed2');
});
it('independent timers for each instance', () => {
const state1 = createDebouncedState('one', 100);
const state2 = createDebouncedState('two', 200);
state1.immediate = 'changed1';
state2.immediate = 'changed2';
vi.advanceTimersByTime(100);
expect(state1.debounced).toBe('changed1');
expect(state2.debounced).toBe('two');
vi.advanceTimersByTime(100);
expect(state2.debounced).toBe('changed2');
});
});
describe('createDebouncedState - Interface Compliance', () => {
it('exposes immediate getter', () => {
const state = createDebouncedState('test');
expect(() => {
const _ = state.immediate;
}).not.toThrow();
});
it('exposes immediate setter', () => {
const state = createDebouncedState('test');
expect(() => {
state.immediate = 'new';
}).not.toThrow();
});
it('exposes debounced getter', () => {
const state = createDebouncedState('test');
expect(() => {
const _ = state.debounced;
}).not.toThrow();
});
it('exposes reset method', () => {
const state = createDebouncedState('test');
expect(typeof state.reset).toBe('function');
});
it('does not expose debounced setter', () => {
const state = createDebouncedState('test');
// TypeScript should prevent this, but we can check the runtime behavior
expect(state).not.toHaveProperty('set debounced');
});
});

View File

@@ -0,0 +1,85 @@
import { SvelteMap } from 'svelte/reactivity';
export interface Entity {
id: string;
}
/**
* Svelte 5 Entity Store
* Uses SvelteMap for O(1) lookups and granular reactivity.
*/
export class EntityStore<T extends Entity> {
// SvelteMap is a reactive version of the native Map
#entities = new SvelteMap<string, T>();
constructor(initialEntities: T[] = []) {
this.setAll(initialEntities);
}
// --- Selectors (Equivalent to Selectors) ---
/** Get all entities as an array */
get all() {
return Array.from(this.#entities.values());
}
/** Select a single entity by ID */
getById(id: string) {
return this.#entities.get(id);
}
/** Select multiple entities by IDs */
getByIds(ids: string[]) {
return ids.map(id => this.#entities.get(id)).filter((e): e is T => !!e);
}
// --- Actions (CRUD) ---
addOne(entity: T) {
this.#entities.set(entity.id, entity);
}
addMany(entities: T[]) {
entities.forEach(e => this.addOne(e));
}
updateOne(id: string, changes: Partial<T>) {
const entity = this.#entities.get(id);
if (entity) {
// In Svelte 5, updating the object property directly is reactive
// if the object itself was made reactive, but here we replace
// the reference to ensure top-level map triggers.
this.#entities.set(id, { ...entity, ...changes });
}
}
removeOne(id: string) {
this.#entities.delete(id);
}
removeMany(ids: string[]) {
ids.forEach(id => this.#entities.delete(id));
}
setAll(entities: T[]) {
this.#entities.clear();
this.addMany(entities);
}
has(id: string) {
return this.#entities.has(id);
}
clear() {
this.#entities.clear();
}
}
/**
* Creates a new EntityStore instance with the given initial entities.
* @param initialEntities The initial entities to populate the store with.
* @returns - A new EntityStore instance.
*/
export function createEntityStore<T extends Entity>(initialEntities: T[] = []) {
return new EntityStore<T>(initialEntities);
}

View File

@@ -26,84 +26,54 @@ export interface FilterModel<TValue extends string> {
/**
* Create a filter store.
* @param initialState - Initial state of the filter store
* @param initialState - Initial state of filter store
*/
export function createFilter<TValue extends string>(
initialState: FilterModel<TValue>,
) {
let properties = $state(
export function createFilter<TValue extends string>(initialState: FilterModel<TValue>) {
// We map the initial properties into a reactive state array
const properties = $state(
initialState.properties.map(p => ({
...p,
selected: p.selected ?? false,
})),
);
const selectedProperties = $derived(properties.filter(p => p.selected));
const selectedCount = $derived(selectedProperties.length);
// Helper to find a property by ID
const findProp = (id: string) => properties.find(p => p.id === id);
return {
/**
* Get all properties.
*/
get properties() {
return properties;
},
/**
* Get selected properties.
*/
get selectedProperties() {
return selectedProperties;
return properties.filter(p => p.selected);
},
/**
* Get selected count.
*/
get selectedCount() {
return selectedCount;
return properties.filter(p => p.selected)?.length;
},
/**
* Toggle property selection.
*/
toggleProperty: (id: string) => {
properties = properties.map(p => ({
...p,
selected: p.id === id ? !p.selected : p.selected,
}));
toggleProperty(id: string) {
const property = findProp(id);
if (property) {
property.selected = !property.selected;
}
},
/**
* Select property.
*/
selectProperty(id: string) {
properties = properties.map(p => ({
...p,
selected: p.id === id ? true : p.selected,
}));
const property = findProp(id);
if (property) {
property.selected = true;
}
},
/**
* Deselect property.
*/
deselectProperty(id: string) {
properties = properties.map(p => ({
...p,
selected: p.id === id ? false : p.selected,
}));
const property = findProp(id);
if (property) {
property.selected = false;
}
},
/**
* Select all properties.
*/
selectAll: () => {
properties = properties.map(p => ({
...p,
selected: true,
}));
selectAll() {
properties.forEach(property => property.selected = true);
},
/**
* Deselect all properties.
*/
deselectAll: () => {
properties = properties.map(p => ({
...p,
selected: false,
}));
deselectAll() {
properties.forEach(property => property.selected = false);
},
};
}

View File

@@ -1,24 +1,105 @@
export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
// Reactive State
/**
* Represents a virtualized list item with layout information.
*
* Used to render visible items with absolute positioning based on computed offsets.
*/
export interface VirtualItem {
/** Index of the item in the data array */
index: number;
/** Offset from the top of the list in pixels */
start: number;
/** Height/size of the item in pixels */
size: number;
/** End position in pixels (start + size) */
end: number;
/** Unique key for the item (for Svelte's {#each} keying) */
key: string | number;
}
/**
* Configuration options for {@link createVirtualizer}.
*
* Options are reactive - pass them through a function getter to enable updates.
*/
export interface VirtualizerOptions {
/** Total number of items in the data array */
count: number;
/**
* Function to estimate the size of an item at a given index.
* Used for initial layout before actual measurements are available.
*/
estimateSize: (index: number) => number;
/** Number of extra items to render outside viewport for smoother scrolling (default: 5) */
overscan?: number;
/**
* Function to get the key of an item at a given index.
* Defaults to using the index directly. Useful for stable keys when items reorder.
*/
getItemKey?: (index: number) => string | number;
/**
* Optional margin in pixels for scroll calculations.
* Can be useful for handling sticky headers or other UI elements.
*/
scrollMargin?: number;
}
/**
* Creates a reactive virtualizer for efficiently rendering large lists by only rendering visible items.
*
* Uses Svelte 5 runes ($state, $derived) for reactive state management and optimizes rendering
* through scroll position tracking and item height measurement. Supports dynamic item heights
* and programmatic scrolling.
*
* @param optionsGetter - Function that returns reactive virtualizer options
* @returns Virtualizer instance with computed properties and action functions
*
* @example
* ```svelte
* <script lang="ts">
* const virtualizer = createVirtualizer(() => ({
* count: 1000,
* estimateSize: (i) => i % 3 === 0 ? 100 : 50,
* overscan: 5,
* getItemKey: (i) => `item-${i}`
* }));
* </script>
*
* <div use:virtualizer.container style="height: 500px; overflow: auto;">
* <div style="height: {virtualizer.totalSize}px;">
* {#each virtualizer.items as item (item.key)}
* <div
* use:virtualizer.measureElement
* data-index={item.index}
* style="position: absolute; top: {item.start}px; height: {item.size}px;"
* >
* Item {item.index}
* </div>
* {/each}
* </div>
* </div>
* ```
*/
export function createVirtualizer<T>(
optionsGetter: () => VirtualizerOptions & {
data: T[];
},
) {
let scrollOffset = $state(0);
let containerHeight = $state(0);
let measuredSizes = $state<Record<number, number>>({});
// Non-reactive ref for DOM manipulation (avoiding unnecessary state tracking)
let elementRef: HTMLElement | null = null;
// Reactive Options
// By wrapping the getter in $derived, we track everything inside it
const options = $derived(optionsGetter());
// Optimized Memoization (The Cache Layer)
// Only recalculates when item count or measured sizes change.
// This derivation now tracks: count, measuredSizes, AND the data array itself
const offsets = $derived.by(() => {
const count = options.count;
const result = Array.from<number>({ length: count });
const result = new Float64Array(count);
let accumulated = 0;
for (let i = 0; i < count; i++) {
result[i] = accumulated;
// Accessing measuredSizes here creates the subscription
accumulated += measuredSizes[i] ?? options.estimateSize(i);
}
return result;
@@ -31,24 +112,30 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
: 0,
);
// Visible Range Calculation
// Svelte tracks dependencies automatically here.
const items = $derived.by((): VirtualItem[] => {
const count = options.count;
if (count === 0 || containerHeight === 0) return [];
// We MUST read options.data here so Svelte knows to re-run
// this derivation when the items array is replaced!
const { count, data } = options;
if (count === 0 || containerHeight === 0 || !data) return [];
const overscan = options.overscan ?? 5;
const viewportStart = scrollOffset;
const viewportEnd = scrollOffset + containerHeight;
// Find Start (Linear Scan)
// Binary search for efficiency
let low = 0;
let high = count - 1;
let startIdx = 0;
while (startIdx < count && offsets[startIdx + 1] < viewportStart) {
startIdx++;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (offsets[mid] <= scrollOffset) {
startIdx = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
// Find End
let endIdx = startIdx;
const viewportEnd = scrollOffset + containerHeight;
while (endIdx < count && offsets[endIdx] < viewportEnd) {
endIdx++;
}
@@ -58,19 +145,27 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
const result: VirtualItem[] = [];
for (let i = start; i < end; i++) {
const size = measuredSizes[i] ?? options.estimateSize(i);
result.push({
index: i,
start: offsets[i],
size,
end: offsets[i] + size,
size: measuredSizes[i] ?? options.estimateSize(i),
end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)),
key: options.getItemKey?.(i) ?? i,
});
}
return result;
});
// Svelte Actions (The DOM Interface)
/**
* Svelte action to attach to the scrollable container element.
*
* Sets up scroll tracking, container height monitoring, and cleanup on destroy.
*
* @param node - The DOM element to attach to (should be the scrollable container)
* @returns Object with destroy method for cleanup
*/
function container(node: HTMLElement) {
elementRef = node;
containerHeight = node.offsetHeight;
@@ -95,27 +190,59 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
};
}
let measurementBuffer: Record<number, number> = {};
let frameId: number | null = null;
/**
* Svelte action to measure individual item elements for dynamic height support.
*
* Attaches a ResizeObserver to track actual element height and updates
* measured sizes when dimensions change. Requires `data-index` attribute on the element.
*
* @param node - The DOM element to measure (should have `data-index` attribute)
* @returns Object with destroy method for cleanup
*/
function measureElement(node: HTMLElement) {
// Use a ResizeObserver on individual items for dynamic height support
const resizeObserver = new ResizeObserver(([entry]) => {
if (entry) {
const index = parseInt(node.dataset.index || '', 10);
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
if (!entry) return;
const index = parseInt(node.dataset.index || '', 10);
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
// Only update if height actually changed to prevent loops
if (!isNaN(index) && measuredSizes[index] !== height) {
measuredSizes[index] = height;
if (!isNaN(index) && measuredSizes[index] !== height) {
// 1. Stuff the measurement into a temporary buffer
measurementBuffer[index] = height;
// 2. Schedule a single update for the next animation frame
if (frameId === null) {
frameId = requestAnimationFrame(() => {
// 3. Update the state once for all collected measurements
// We use spread to trigger a single fine-grained update
measuredSizes = { ...measuredSizes, ...measurementBuffer };
// 4. Reset the buffer
measurementBuffer = {};
frameId = null;
});
}
}
});
resizeObserver.observe(node);
return {
destroy: () => resizeObserver.disconnect(),
};
return { destroy: () => resizeObserver.disconnect() };
}
// Programmatic Scroll
/**
* Scrolls the container to bring the specified item into view.
*
* @param index - Index of the item to scroll to
* @param align - Scroll alignment: 'start', 'center', 'end', or 'auto' (default)
*
* @example
* ```ts
* virtualizer.scrollToIndex(50, 'center'); // Scroll to item 50 and center it
* ```
*/
function scrollToIndex(index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') {
if (!elementRef || index < 0 || index >= options.count) return;
@@ -130,42 +257,27 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
}
return {
/** Computed array of visible items to render (reactive) */
get items() {
return items;
},
/** Total height of all items in pixels (reactive) */
get totalSize() {
return totalSize;
},
/** Svelte action for the scrollable container element */
container,
/** Svelte action for measuring individual item elements */
measureElement,
/** Programmatic scroll method to scroll to a specific item */
scrollToIndex,
};
}
export interface VirtualItem {
/** Index of the item in the data array */
index: number;
/** Offset from the top of the list */
start: number;
/** Height of the item */
size: number;
/** End position (start + size) */
end: number;
/** Unique key for the item (for Svelte's {#each} keying) */
key: string | number;
}
export interface VirtualizerOptions {
/** Total number of items in the data array */
count: number;
/** Function to estimate the size of an item at a given index */
estimateSize: (index: number) => number;
/** Number of extra items to render outside viewport (default: 5) */
overscan?: number;
/** Function to get the key of an item at a given index (defaults to index) */
getItemKey?: (index: number) => string | number;
/** Optional margin in pixels for scroll calculations */
scrollMargin?: number;
}
/**
* Virtualizer instance returned by {@link createVirtualizer}.
*
* Provides reactive computed properties for visible items and total size,
* along with action functions for DOM integration and element measurement.
*/
export type Virtualizer = ReturnType<typeof createVirtualizer>;

View File

@@ -20,3 +20,9 @@ export {
} from './createVirtualizer/createVirtualizer.svelte';
export { createDebouncedState } from './createDebouncedState/createDebouncedState.svelte';
export {
createEntityStore,
type Entity,
type EntityStore,
} from './createEntityStore/createEntityStore.svelte';

View File

@@ -1,9 +1,13 @@
export {
type ControlDataModel,
type ControlModel,
createDebouncedState,
createEntityStore,
createFilter,
createTypographyControl,
createVirtualizer,
type Entity,
type EntityStore,
type Filter,
type FilterModel,
type Property,
@@ -12,3 +16,6 @@ export {
type Virtualizer,
type VirtualizerOptions,
} from './helpers';
export { motion } from './accessibility/motion.svelte';
export { splitArray } from './utils';

View File

@@ -11,3 +11,4 @@ export { clampNumber } from './clampNumber/clampNumber';
export { debounce } from './debounce/debounce';
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
export { splitArray } from './splitArray/splitArray';

View File

@@ -0,0 +1,14 @@
/**
* Splits an array into two arrays based on a callback function.
* @param array The array to split.
* @param callback The callback function to determine which array to push each item to.
* @returns - An array containing two arrays, the first array contains items that passed the callback, the second array contains items that failed the callback.
*/
export function splitArray<T>(array: T[], callback: (item: T) => boolean) {
return array.reduce<[T[], T[]]>(
([pass, fail], item) => (
callback(item) ? pass.push(item) : fail.push(item), [pass, fail]
),
[[], []],
);
}

View File

@@ -1,5 +1,15 @@
<!--
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 } from '$shared/lib';
import { motion } from '$shared/lib';
import { Badge } from '$shared/shadcn/ui/badge';
import { buttonVariants } from '$shared/shadcn/ui/button';
import { Checkbox } from '$shared/shadcn/ui/checkbox';
@@ -9,23 +19,9 @@ import {
} from '$shared/shadcn/ui/collapsible';
import { Label } from '$shared/shadcn/ui/label';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
/**
* CheckboxFilter Component
*
* A collapsible property filter with checkboxes. Displays selected count as a badge
* and supports reduced motion for accessibility. Used in sidebar filtering UIs.
*
* Design choices:
* - 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
*/
interface PropertyFilterProps {
/** Label for this filter group (e.g., "Properties", "Tags") */
displayedLabel: string;
@@ -37,29 +33,11 @@ const { displayedLabel, filter }: PropertyFilterProps = $props();
// Toggle state - defaults to open for better discoverability
let isOpen = $state(true);
// Accessibility preference to disable animations
let prefersReducedMotion = $state(false);
// Check reduced motion preference on mount (window access required)
// Event listener allows responding to system preference changes
onMount(() => {
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
prefersReducedMotion = mediaQuery.matches;
const handleChange = (e: MediaQueryListEvent) => {
prefersReducedMotion = e.matches;
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}
});
// 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 ? 0 : 250,
duration: motion.reduced ? 0 : 250,
easing: cubicOut,
});

View File

@@ -1,3 +1,10 @@
<!--
Component: ComboControl
Provides multiple ways to change certain value
- via Increase/Decrease buttons
- via Slider
- via Input
-->
<script lang="ts">
import type { TypographyControl } from '$shared/lib';
import { Button } from '$shared/shadcn/ui/button';

View File

@@ -0,0 +1,62 @@
<!--
Component: ContentEditable
Provides a contenteditable div with custom font and text properties.
-->
<script lang="ts">
interface Props {
/**
* Visible text
*/
text: string;
/**
* Font settings
*/
fontSize?: number;
lineHeight?: number;
letterSpacing?: number;
}
let {
text = $bindable('The quick brown fox jumps over the lazy dog.'),
fontSize = 48,
lineHeight = 1.2,
letterSpacing = 0,
}: Props = $props();
let element: HTMLDivElement | undefined = $state();
// Initial Sync: Set the text ONLY ONCE when the element is created.
// This prevents Svelte from "owning" the innerHTML/innerText.
$effect(() => {
if (element && element.innerText !== text) {
element.innerText = text;
}
});
// Handle changes: Update the outer state without re-rendering the div.
function handleInput(e: Event) {
const target = e.target as HTMLDivElement;
// Update the bindable prop directly
text = target.innerText;
}
</script>
<div
bind:this={element}
contenteditable="plaintext-only"
spellcheck="false"
role="textbox"
tabindex="0"
data-placeholder="Type something to test..."
oninput={handleInput}
class="
w-full min-h-[1.2em] outline-none transition-all duration-200
empty:before:content-[attr(data-placeholder)] empty:before:text-slate-400
selection:bg-indigo-100 selection:text-indigo-900
caret-indigo-500
"
style:font-size="{fontSize}px"
style:line-height={lineHeight}
style:letter-spacing="{letterSpacing}em"
>
</div>

View File

@@ -1,3 +1,10 @@
<!--
Component: SearchBar
Search input with popover dropdown for results/suggestions
- Features keyboard navigation (ArrowDown/Up/Enter) and auto-focus prevention on popover open.
- The input field serves as the popover trigger.
-->
<script lang="ts">
import { Input } from '$shared/shadcn/ui/input';
import { Label } from '$shared/shadcn/ui/label';
@@ -7,17 +14,20 @@ import {
Trigger as PopoverTrigger,
} from '$shared/shadcn/ui/popover';
import { useId } from 'bits-ui';
import {
type Snippet,
tick,
} from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
/** Unique identifier for the input element */
id: string;
/** Current search value (bindable) */
value: string;
/** Additional CSS classes for the container */
class?: string;
/** Placeholder text for the input */
placeholder?: string;
/** Optional label displayed above the input */
label?: string;
/** Content to render inside the popover (receives unique content ID) */
children: Snippet<[{ id: string }]> | undefined;
}
@@ -35,13 +45,6 @@ let triggerRef = $state<HTMLInputElement>(null!);
// svelte-ignore state_referenced_locally
const contentId = useId(id);
function closeAndFocusTrigger() {
open = false;
tick().then(() => {
triggerRef?.focus();
});
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
event.preventDefault();
@@ -50,16 +53,14 @@ function handleKeyDown(event: KeyboardEvent) {
function handleInputClick() {
open = true;
tick().then(() => {
triggerRef?.focus();
});
}
</script>
<PopoverRoot>
<PopoverRoot bind:open>
<PopoverTrigger bind:ref={triggerRef}>
{#snippet child({ props })}
<div {...props} class="flex flex-row flex-1 w-full">
{@const { onclick, ...rest } = props}
<div {...rest} class="flex flex-row flex-1 w-full">
{#if label}
<Label for={id}>{label}</Label>
{/if}
@@ -68,6 +69,7 @@ function handleInputClick() {
placeholder={placeholder}
bind:value={value}
onkeydown={handleKeyDown}
onclick={handleInputClick}
class="flex flex-row flex-1"
/>
</div>
@@ -76,7 +78,12 @@ function handleInputClick() {
<PopoverContent
onOpenAutoFocus={e => e.preventDefault()}
class="w-max"
onInteractOutside={(e => {
if (e.target === triggerRef) {
e.preventDefault();
}
})}
class="w-(--bits-popover-anchor-width) min-w-(--bits-popover-anchor-width)"
>
{@render children?.({ id: contentId })}
</PopoverContent>

View File

@@ -10,10 +10,7 @@
<script lang="ts" generics="T">
import { createVirtualizer } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
type Snippet,
tick,
} from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
/**
@@ -38,6 +35,11 @@ interface Props {
* (follows shadcn convention for className prop)
*/
class?: string;
/**
* An optional callback that will be called for each new set of loaded items
* @param items - Loaded items
*/
onVisibleItemsChange?: (items: T[]) => void;
/**
* Snippet for rendering individual list items.
*
@@ -53,32 +55,42 @@ interface Props {
children: Snippet<[{ item: T; index: number }]>;
}
let { items, itemHeight = 80, overscan = 5, class: className, children }: Props = $props();
let { items, itemHeight = 80, overscan = 5, class: className, onVisibleItemsChange, children }:
Props = $props();
const virtualizer = createVirtualizer(() => ({
count: items.length,
data: items,
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
overscan,
}));
$effect(() => {
const visibleItems = virtualizer.items.map(item => items[item.index]);
onVisibleItemsChange?.(visibleItems);
});
</script>
<div
use:virtualizer.container
class={cn(
'relative overflow-auto border rounded-md bg-background',
'outline-none focus-visible:ring-2 ring-ring ring-offset-2',
'h-full w-full',
'relative overflow-auto rounded-md bg-background',
'h-150 w-full',
className,
)}
role="listbox"
tabindex="0"
>
<div
style:height="{virtualizer.totalSize}px"
class="w-full pointer-events-none"
>
</div>
{#each virtualizer.items as item (item.key)}
<div
use:virtualizer.measureElement
data-index={item.index}
class="absolute top-0 left-0 w-full translate-y-[var(--offset)] will-change-transform"
style:--offset="{item.start}px"
class="absolute top-0 left-0 w-full"
style:transform="translateY({item.start}px)"
>
{@render children({ item: items[item.index], index: item.index })}
</div>

View File

@@ -6,12 +6,14 @@
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
import ComboControl from './ComboControl/ComboControl.svelte';
import ContentEditable from './ContentEditable/ContentEditable.svelte';
import SearchBar from './SearchBar/SearchBar.svelte';
import VirtualList from './VirtualList/VirtualList.svelte';
export {
CheckboxFilter,
ComboControl,
ContentEditable,
SearchBar,
VirtualList,
};

View File

@@ -10,6 +10,7 @@
/* Strictness & Safety */
"strict": true,
"allowJs": true,
"noEmit": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,

View File

@@ -1,46 +0,0 @@
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { playwright } from '@vitest/browser-playwright';
import path from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [svelte()],
test: {
name: 'component-browser',
include: ['src/**/*.svelte.test.ts', 'src/**/*.svelte.test.js'],
exclude: [
'node_modules',
'dist',
'e2e',
'.storybook',
'src/shared/shadcn/**/*',
],
testTimeout: 10000,
hookTimeout: 10000,
restoreMocks: true,
setupFiles: ['./vitest.setup.component.ts'],
globals: false,
// Use browser environment with Playwright (Vitest 4 format)
browser: {
enabled: true,
headless: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
screenshotFailures: true,
screenshotDirectory: '.playwright/screenshots',
},
},
resolve: {
alias: {
$lib: path.resolve(__dirname, './src/lib'),
$app: path.resolve(__dirname, './src/app'),
$shared: path.resolve(__dirname, './src/shared'),
$entities: path.resolve(__dirname, './src/entities'),
$features: path.resolve(__dirname, './src/features'),
$routes: path.resolve(__dirname, './src/routes'),
$widgets: path.resolve(__dirname, './src/widgets'),
},
},
});

View File

@@ -1,46 +0,0 @@
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { playwright } from '@vitest/browser-playwright';
import path from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [svelte()],
test: {
name: 'component-browser',
include: ['src/**/*.svelte.test.ts', 'src/**/*.svelte.test.js'],
exclude: [
'node_modules',
'dist',
'e2e',
'.storybook',
'src/shared/shadcn/**/*',
],
testTimeout: 10000,
hookTimeout: 10000,
restoreMocks: true,
setupFiles: ['./vitest.setup.component.ts'],
globals: false,
// Use browser environment with Playwright for Svelte 5 support
browser: {
enabled: true,
headless: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
screenshotFailures: true,
screenshotDirectory: '.playwright/screenshots',
},
},
resolve: {
alias: {
$lib: path.resolve(__dirname, './src/lib'),
$app: path.resolve(__dirname, './src/app'),
$shared: path.resolve(__dirname, './src/shared'),
$entities: path.resolve(__dirname, './src/entities'),
$features: path.resolve(__dirname, './src/features'),
$routes: path.resolve(__dirname, './src/routes'),
$widgets: path.resolve(__dirname, './src/widgets'),
},
},
});

View File

@@ -53,6 +53,7 @@ export default defineConfig({
},
resolve: {
conditions: process.env.VITEST ? ['browser'] : undefined,
alias: {
$lib: path.resolve(__dirname, './src/lib'),
$app: path.resolve(__dirname, './src/app'),