Compare commits

...

49 Commits

Author SHA1 Message Date
993c63a39d 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
2026-01-18 14:04:52 +00:00
Ilia Mashkov
8591985f62 feat(FontApplicator): implement an appearance animation based on existed intersection observer logic and add a reduced motion check
All checks were successful
Workflow / build (pull_request) Successful in 50s
2026-01-18 16:56:53 +03:00
Ilia Mashkov
9cbf4fdc48 doc: comments for codebase and updated documentation 2026-01-18 15:55:07 +03:00
Ilia Mashkov
8356e99382 chore: add import shortcuts 2026-01-18 15:53:44 +03:00
Ilia Mashkov
7ca45c2e63 chore: add import shortcuts 2026-01-18 15:53:16 +03:00
Ilia Mashkov
20f6e193f2 chore: minor changes 2026-01-18 15:01:19 +03:00
Ilia Mashkov
c04518300b chore: remove unused code 2026-01-18 15:00:54 +03:00
Ilia Mashkov
ee074036f6 chore: add import shortcuts 2026-01-18 15:00:26 +03:00
Ilia Mashkov
ba883ef9a8 fix(motion): edit MotionPreference to avoid errors 2026-01-18 15:00:07 +03:00
Ilia Mashkov
28a71452d1 fix(FontListItem): edit FontListItem to work with selectedFontsStore 2026-01-18 14:59:00 +03:00
Ilia Mashkov
b7ce100407 fix(FontSearch): edit component to render suggested fonts 2026-01-18 14:58:05 +03:00
Ilia Mashkov
96b26fb055 feat(FontDisplay): create a FontDisplay component to show selected font samples 2026-01-18 14:57:15 +03:00
Ilia Mashkov
5ef8d609ab feat(SuggestedFonts): create a component for Suggested Virtualized Font List 2026-01-18 14:56:25 +03:00
Ilia Mashkov
f457e5116f feat(displayedFontsStore): create store to manage displayed fonts sample and its content 2026-01-18 14:55:00 +03:00
Ilia Mashkov
e0e0d929bb chore: add import shortcuts 2026-01-18 14:53:14 +03:00
Ilia Mashkov
37ab7f795e feat(selectedFontsStore): create selectedFontsStore to manage selected fonts collection 2026-01-18 14:52:12 +03:00
Ilia Mashkov
af2ef77c30 feat(FontSampler): edit FontSampler to applt font-family through FontApplicator component 2026-01-18 14:48:36 +03:00
Ilia Mashkov
ad18a19c4b chore(FontSampler): delete unused prop 2026-01-18 14:47:31 +03:00
Ilia Mashkov
ef259c6fce chore: add import shortcuts 2026-01-18 14:39:38 +03:00
Ilia Mashkov
5d23a2af55 feat(EntityStore): create a helper for creation of an Entity Store to store and operate over values that have ids 2026-01-18 14:38:58 +03:00
Ilia Mashkov
df8eca6ef2 feat(splitArray): create a util to split an array based on a boolean resulting callback 2026-01-18 14:37:23 +03:00
Ilia Mashkov
7e62acce49 fix(ContentEditable): change logic to support controlled state 2026-01-18 14:35:35 +03:00
Ilia Mashkov
86e7b2c1ec feat(FontListItem): create FontListItem component that visualize selection of a certain font 2026-01-18 12:59:12 +03:00
Ilia Mashkov
da0612942c feat(FontApplicator): create FontApplicator component that register certain font and applies it to the children 2026-01-18 12:57:56 +03:00
Ilia Mashkov
0444f8c114 chore(FontVirtualList): transform FontList into reusable FontVirtualList component with appliedFontsManager support 2026-01-18 12:55:25 +03:00
Ilia Mashkov
6b4e0dbbd0 feat(ContentEditable): create ContentEditable shared component that displays text and allows editing 2026-01-18 12:51:55 +03:00
Ilia Mashkov
7389ec779d feat:(VirtualList) add onVisibleItemsChange prop that triggers when visibleItems list changes 2026-01-18 12:50:17 +03:00
Ilia Mashkov
4d04761d88 feat(appliedFontsStore): create Applied Fonts Manager to manage fonts download 2026-01-18 12:46:11 +03:00
Ilia Mashkov
32da012b26 feat(MotionPreference): Create common logic to store information about prefers-reduced-motion 2026-01-17 14:29:10 +03:00
Ilia Mashkov
71d320535e feat(FontView): integrate FontView into FontList 2026-01-17 09:21:34 +03:00
Ilia Mashkov
71c068bad2 feat(FontView): create a FontView component that adds a link to the head tag and applies font-family to the children 2026-01-17 09:20:58 +03:00
Ilia Mashkov
247b683c87 chore(FontSearch): documentation change 2026-01-17 09:19:47 +03:00
Ilia Mashkov
8c0c91deb7 feat(createVirtualizer): enhance logic with binary search and requestAnimationFrame 2026-01-16 17:48:33 +03:00
Ilia Mashkov
261c19db69 fix(SearchBar): change input behavior to turn off popover toggle on click on trigger and keep it open. Add doc 2026-01-16 17:47:05 +03:00
Ilia Mashkov
a85b3cf217 fix(VirtualList): change styles to show the correct scroll instantly 2026-01-16 17:46:06 +03:00
Ilia Mashkov
f02b19eff5 chore(createFilter): change format 2026-01-16 17:45:11 +03:00
Ilia Mashkov
4dbf91f600 chore(FontList): Move documentation and remove default height 2026-01-16 17:44:07 +03:00
Ilia Mashkov
0daf0bf3bf chore: minor vitest adjustment 2026-01-16 14:00:35 +03:00
Ilia Mashkov
14f9b87680 test(createDebouncedState): create test coverage for createDebouncedState 2026-01-16 14:00:20 +03:00
Ilia Mashkov
3cd9b36411 fix(createFilter): remove dirived from selectedProperties compute 2026-01-16 13:59:39 +03:00
Ilia Mashkov
4c8b5764b3 chore: delete unused code 2026-01-16 13:58:50 +03:00
Ilia Mashkov
62ae0799cc chore(lib): add export 2026-01-16 13:15:10 +03:00
Ilia Mashkov
53c71a437f chore: simplify scripts 2026-01-16 13:14:54 +03:00
Ilia Mashkov
1976affdff fix(tsconfig): add noEmit param to awoid errors 2026-01-16 13:14:33 +03:00
Ilia Mashkov
f3de6c49a3 chore: delete unused code 2026-01-16 12:41:30 +03:00
Ilia Mashkov
42e941083a doc(createDeboucnedState): add JSDoc for createDebouncedState 2026-01-16 12:38:57 +03:00
Ilia Mashkov
86adec01a0 doc(createVirtualizer): add JSDoc for createVirtualizer 2026-01-16 12:27:14 +03:00
Ilia Mashkov
b0812ff606 chore: delete unused code 2026-01-16 12:24:30 +03:00
Ilia Mashkov
deaf38f8ec fix(Page): remove unused code and misleading comments 2026-01-16 10:24:06 +03:00
52 changed files with 1481 additions and 1220 deletions

View File

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

View File

@@ -24,6 +24,8 @@ let { children } = $props();
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <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> </svelte:head>
<div id="app-root"> <div id="app-root">

View File

@@ -60,9 +60,11 @@ export type {
} from './model'; } from './model';
export { export {
appliedFontsManager,
createFontshareStore, createFontshareStore,
fetchFontshareFontsQuery, fetchFontshareFontsQuery,
fontshareStore, fontshareStore,
selectedFontsStore,
} from './model'; } from './model';
// Stores // Stores
@@ -72,4 +74,8 @@ export {
} from './model/services/fetchGoogleFonts.svelte'; } from './model/services/fetchGoogleFonts.svelte';
// UI elements // 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 { fetchFontshareFontsQuery } from './services';
export { export {
appliedFontsManager,
createFontshareStore, createFontshareStore,
type FontshareStore, type FontshareStore,
fontshareStore, fontshareStore,
selectedFontsStore,
} from './store'; } 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, type FontshareStore,
fontshareStore, fontshareStore,
} from './fontshareStore.svelte'; } 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. * 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>) { export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
const search = createDebouncedState(config.queryValue ?? ''); const search = createDebouncedState(config.queryValue ?? '');

View File

@@ -1,6 +1,12 @@
import type { FontshareParams } from '$entities/Font'; import type { FontshareParams } from '$entities/Font';
import type { FilterManager } from '../filterManager/filterManager.svelte'; 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> { export function mapManagerToParams(manager: FilterManager): Partial<FontshareParams> {
return { return {
q: manager.debouncedQueryValue, q: manager.debouncedQueryValue,

View File

@@ -1,18 +1,8 @@
<!--
Component: Filters
Renders a list of CheckboxFilter components for each filter group.
-->
<script lang="ts"> <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 { CheckboxFilter } from '$shared/ui';
import { filterManager } from '../../model'; import { filterManager } from '../../model';
</script> </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"> <script lang="ts">
import { Button } from '$shared/shadcn/ui/button'; import { Button } from '$shared/shadcn/ui/button';
import { filterManager } from '../../model'; 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> </script>
<div class="flex flex-row gap-2"> <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"> <script lang="ts">
import { import { fontshareStore } from '$entities/Font';
FontList,
fontshareStore,
} from '$entities/Font';
import { SearchBar } from '$shared/ui'; import { SearchBar } from '$shared/ui';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { mapManagerToParams } from '../../lib'; import { mapManagerToParams } from '../../lib';
import { filterManager } from '../../model'; 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(() => { onMount(() => {
/** /**
* The Pairing: * The Pairing:
@@ -24,8 +21,6 @@ onMount(() => {
return unbind; return unbind;
}); });
$inspect(filterManager.queryValue, filterManager.debouncedQueryValue);
</script> </script>
<SearchBar <SearchBar
@@ -34,5 +29,5 @@ $inspect(filterManager.queryValue, filterManager.debouncedQueryValue);
placeholder="Search fonts by name..." placeholder="Search fonts by name..."
bind:value={filterManager.queryValue} bind:value={filterManager.queryValue}
> >
<FontList /> <SuggestedFonts />
</SearchBar> </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, createTypographyControl,
} from '$shared/lib'; } 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[]) { export function createTypographyControlManager(configs: ControlModel[]) {
const controls = $state( const controls = $state(
configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({ 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"> <script lang="ts">
/**
* Component containing controls for setting up font properties.
*/
import { Separator } from '$shared/shadcn/ui/separator/index'; import { Separator } from '$shared/shadcn/ui/separator/index';
import { Trigger as SidebarTrigger } from '$shared/shadcn/ui/sidebar'; import { Trigger as SidebarTrigger } from '$shared/shadcn/ui/sidebar';
import { ComboControl } from '$shared/ui'; import { ComboControl } from '$shared/ui';

View File

@@ -1,27 +1,12 @@
<script lang="ts"> <script lang="ts">
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
/** /**
* Page Component * 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> </script>
<!-- Font List --> <!-- 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'; 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) { export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
let immediate = $state(initialValue); let immediate = $state(initialValue);
let debounced = $state(initialValue); let debounced = $state(initialValue);
@@ -9,16 +32,23 @@ export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
}, wait); }, wait);
return { return {
/** Current value with immediate updates (for UI binding) */
get immediate() { get immediate() {
return immediate; return immediate;
}, },
set immediate(value: T) { set immediate(value: T) {
immediate = value; 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() { get debounced() {
return debounced; return debounced;
}, },
/**
* Resets both values to initial or specified value.
* @param value - Optional value to reset to (defaults to initialValue)
*/
reset(value?: T) { reset(value?: T) {
const resetValue = value ?? initialValue; const resetValue = value ?? initialValue;
immediate = resetValue; 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. * 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>( export function createFilter<TValue extends string>(initialState: FilterModel<TValue>) {
initialState: FilterModel<TValue>, // We map the initial properties into a reactive state array
) { const properties = $state(
let properties = $state(
initialState.properties.map(p => ({ initialState.properties.map(p => ({
...p, ...p,
selected: p.selected ?? false, selected: p.selected ?? false,
})), })),
); );
const selectedProperties = $derived(properties.filter(p => p.selected)); // Helper to find a property by ID
const selectedCount = $derived(selectedProperties.length); const findProp = (id: string) => properties.find(p => p.id === id);
return { return {
/**
* Get all properties.
*/
get properties() { get properties() {
return properties; return properties;
}, },
/**
* Get selected properties.
*/
get selectedProperties() { get selectedProperties() {
return selectedProperties; return properties.filter(p => p.selected);
}, },
/**
* Get selected count.
*/
get selectedCount() { get selectedCount() {
return selectedCount; return properties.filter(p => p.selected)?.length;
}, },
/**
* Toggle property selection. toggleProperty(id: string) {
*/ const property = findProp(id);
toggleProperty: (id: string) => { if (property) {
properties = properties.map(p => ({ property.selected = !property.selected;
...p, }
selected: p.id === id ? !p.selected : p.selected,
}));
}, },
/**
* Select property.
*/
selectProperty(id: string) { selectProperty(id: string) {
properties = properties.map(p => ({ const property = findProp(id);
...p, if (property) {
selected: p.id === id ? true : p.selected, property.selected = true;
})); }
}, },
/**
* Deselect property.
*/
deselectProperty(id: string) { deselectProperty(id: string) {
properties = properties.map(p => ({ const property = findProp(id);
...p, if (property) {
selected: p.id === id ? false : p.selected, property.selected = false;
})); }
}, },
/** selectAll() {
* Select all properties. properties.forEach(property => property.selected = true);
*/
selectAll: () => {
properties = properties.map(p => ({
...p,
selected: true,
}));
}, },
/** deselectAll() {
* Deselect all properties. properties.forEach(property => property.selected = false);
*/
deselectAll: () => {
properties = properties.map(p => ({
...p,
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 scrollOffset = $state(0);
let containerHeight = $state(0); let containerHeight = $state(0);
let measuredSizes = $state<Record<number, number>>({}); let measuredSizes = $state<Record<number, number>>({});
// Non-reactive ref for DOM manipulation (avoiding unnecessary state tracking)
let elementRef: HTMLElement | null = null; let elementRef: HTMLElement | null = null;
// Reactive Options // By wrapping the getter in $derived, we track everything inside it
const options = $derived(optionsGetter()); const options = $derived(optionsGetter());
// Optimized Memoization (The Cache Layer) // This derivation now tracks: count, measuredSizes, AND the data array itself
// Only recalculates when item count or measured sizes change.
const offsets = $derived.by(() => { const offsets = $derived.by(() => {
const count = options.count; const count = options.count;
const result = Array.from<number>({ length: count }); const result = new Float64Array(count);
let accumulated = 0; let accumulated = 0;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
result[i] = accumulated; result[i] = accumulated;
// Accessing measuredSizes here creates the subscription
accumulated += measuredSizes[i] ?? options.estimateSize(i); accumulated += measuredSizes[i] ?? options.estimateSize(i);
} }
return result; return result;
@@ -31,24 +112,30 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
: 0, : 0,
); );
// Visible Range Calculation
// Svelte tracks dependencies automatically here.
const items = $derived.by((): VirtualItem[] => { const items = $derived.by((): VirtualItem[] => {
const count = options.count; // We MUST read options.data here so Svelte knows to re-run
if (count === 0 || containerHeight === 0) return []; // 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 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; let startIdx = 0;
while (startIdx < count && offsets[startIdx + 1] < viewportStart) { while (low <= high) {
startIdx++; 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; let endIdx = startIdx;
const viewportEnd = scrollOffset + containerHeight;
while (endIdx < count && offsets[endIdx] < viewportEnd) { while (endIdx < count && offsets[endIdx] < viewportEnd) {
endIdx++; endIdx++;
} }
@@ -58,19 +145,27 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
const result: VirtualItem[] = []; const result: VirtualItem[] = [];
for (let i = start; i < end; i++) { for (let i = start; i < end; i++) {
const size = measuredSizes[i] ?? options.estimateSize(i);
result.push({ result.push({
index: i, index: i,
start: offsets[i], start: offsets[i],
size, size: measuredSizes[i] ?? options.estimateSize(i),
end: offsets[i] + size, end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)),
key: options.getItemKey?.(i) ?? i, key: options.getItemKey?.(i) ?? i,
}); });
} }
return result; return result;
}); });
// Svelte Actions (The DOM Interface) // 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) { function container(node: HTMLElement) {
elementRef = node; elementRef = node;
containerHeight = node.offsetHeight; 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) { function measureElement(node: HTMLElement) {
// Use a ResizeObserver on individual items for dynamic height support
const resizeObserver = new ResizeObserver(([entry]) => { const resizeObserver = new ResizeObserver(([entry]) => {
if (entry) { if (!entry) return;
const index = parseInt(node.dataset.index || '', 10); const index = parseInt(node.dataset.index || '', 10);
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
// Only update if height actually changed to prevent loops if (!isNaN(index) && measuredSizes[index] !== height) {
if (!isNaN(index) && measuredSizes[index] !== height) { // 1. Stuff the measurement into a temporary buffer
measuredSizes[index] = height; 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); resizeObserver.observe(node);
return { return { destroy: () => resizeObserver.disconnect() };
destroy: () => resizeObserver.disconnect(),
};
} }
// Programmatic Scroll // 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') { function scrollToIndex(index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') {
if (!elementRef || index < 0 || index >= options.count) return; if (!elementRef || index < 0 || index >= options.count) return;
@@ -130,42 +257,27 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
} }
return { return {
/** Computed array of visible items to render (reactive) */
get items() { get items() {
return items; return items;
}, },
/** Total height of all items in pixels (reactive) */
get totalSize() { get totalSize() {
return totalSize; return totalSize;
}, },
/** Svelte action for the scrollable container element */
container, container,
/** Svelte action for measuring individual item elements */
measureElement, measureElement,
/** Programmatic scroll method to scroll to a specific item */
scrollToIndex, scrollToIndex,
}; };
} }
export interface VirtualItem { /**
/** Index of the item in the data array */ * Virtualizer instance returned by {@link createVirtualizer}.
index: number; *
/** Offset from the top of the list */ * Provides reactive computed properties for visible items and total size,
start: number; * along with action functions for DOM integration and element measurement.
/** 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;
}
export type Virtualizer = ReturnType<typeof createVirtualizer>; export type Virtualizer = ReturnType<typeof createVirtualizer>;

View File

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

View File

@@ -1,9 +1,13 @@
export { export {
type ControlDataModel, type ControlDataModel,
type ControlModel, type ControlModel,
createDebouncedState,
createEntityStore,
createFilter, createFilter,
createTypographyControl, createTypographyControl,
createVirtualizer, createVirtualizer,
type Entity,
type EntityStore,
type Filter, type Filter,
type FilterModel, type FilterModel,
type Property, type Property,
@@ -12,3 +16,6 @@ export {
type Virtualizer, type Virtualizer,
type VirtualizerOptions, type VirtualizerOptions,
} from './helpers'; } 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 { debounce } from './debounce/debounce';
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces'; export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision'; 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"> <script lang="ts">
import type { Filter } from '$shared/lib'; import type { Filter } from '$shared/lib';
import { motion } from '$shared/lib';
import { Badge } from '$shared/shadcn/ui/badge'; import { Badge } from '$shared/shadcn/ui/badge';
import { buttonVariants } from '$shared/shadcn/ui/button'; import { buttonVariants } from '$shared/shadcn/ui/button';
import { Checkbox } from '$shared/shadcn/ui/checkbox'; import { Checkbox } from '$shared/shadcn/ui/checkbox';
@@ -9,23 +19,9 @@ import {
} from '$shared/shadcn/ui/collapsible'; } from '$shared/shadcn/ui/collapsible';
import { Label } from '$shared/shadcn/ui/label'; import { Label } from '$shared/shadcn/ui/label';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { slide } from 'svelte/transition'; 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 { interface PropertyFilterProps {
/** Label for this filter group (e.g., "Properties", "Tags") */ /** Label for this filter group (e.g., "Properties", "Tags") */
displayedLabel: string; displayedLabel: string;
@@ -37,29 +33,11 @@ const { displayedLabel, filter }: PropertyFilterProps = $props();
// Toggle state - defaults to open for better discoverability // Toggle state - defaults to open for better discoverability
let isOpen = $state(true); 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 // Animation config respects user preferences - zero duration if reduced motion enabled
// Local modifier prevents animation on initial render, only animates user interactions // Local modifier prevents animation on initial render, only animates user interactions
const slideConfig = $derived({ const slideConfig = $derived({
duration: prefersReducedMotion ? 0 : 250, duration: motion.reduced ? 0 : 250,
easing: cubicOut, 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"> <script lang="ts">
import type { TypographyControl } from '$shared/lib'; import type { TypographyControl } from '$shared/lib';
import { Button } from '$shared/shadcn/ui/button'; 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"> <script lang="ts">
import { Input } from '$shared/shadcn/ui/input'; import { Input } from '$shared/shadcn/ui/input';
import { Label } from '$shared/shadcn/ui/label'; import { Label } from '$shared/shadcn/ui/label';
@@ -7,17 +14,20 @@ import {
Trigger as PopoverTrigger, Trigger as PopoverTrigger,
} from '$shared/shadcn/ui/popover'; } from '$shared/shadcn/ui/popover';
import { useId } from 'bits-ui'; import { useId } from 'bits-ui';
import { import type { Snippet } from 'svelte';
type Snippet,
tick,
} from 'svelte';
interface Props { interface Props {
/** Unique identifier for the input element */
id: string; id: string;
/** Current search value (bindable) */
value: string; value: string;
/** Additional CSS classes for the container */
class?: string; class?: string;
/** Placeholder text for the input */
placeholder?: string; placeholder?: string;
/** Optional label displayed above the input */
label?: string; label?: string;
/** Content to render inside the popover (receives unique content ID) */
children: Snippet<[{ id: string }]> | undefined; children: Snippet<[{ id: string }]> | undefined;
} }
@@ -35,13 +45,6 @@ let triggerRef = $state<HTMLInputElement>(null!);
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
const contentId = useId(id); const contentId = useId(id);
function closeAndFocusTrigger() {
open = false;
tick().then(() => {
triggerRef?.focus();
});
}
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') { if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
@@ -50,16 +53,14 @@ function handleKeyDown(event: KeyboardEvent) {
function handleInputClick() { function handleInputClick() {
open = true; open = true;
tick().then(() => {
triggerRef?.focus();
});
} }
</script> </script>
<PopoverRoot> <PopoverRoot bind:open>
<PopoverTrigger bind:ref={triggerRef}> <PopoverTrigger bind:ref={triggerRef}>
{#snippet child({ props })} {#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} {#if label}
<Label for={id}>{label}</Label> <Label for={id}>{label}</Label>
{/if} {/if}
@@ -68,6 +69,7 @@ function handleInputClick() {
placeholder={placeholder} placeholder={placeholder}
bind:value={value} bind:value={value}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
onclick={handleInputClick}
class="flex flex-row flex-1" class="flex flex-row flex-1"
/> />
</div> </div>
@@ -76,7 +78,12 @@ function handleInputClick() {
<PopoverContent <PopoverContent
onOpenAutoFocus={e => e.preventDefault()} 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 })} {@render children?.({ id: contentId })}
</PopoverContent> </PopoverContent>

View File

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

View File

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

View File

@@ -10,6 +10,7 @@
/* Strictness & Safety */ /* Strictness & Safety */
"strict": true, "strict": true,
"allowJs": true, "allowJs": true,
"noEmit": true,
"checkJs": true, "checkJs": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": 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: { resolve: {
conditions: process.env.VITEST ? ['browser'] : undefined,
alias: { alias: {
$lib: path.resolve(__dirname, './src/lib'), $lib: path.resolve(__dirname, './src/lib'),
$app: path.resolve(__dirname, './src/app'), $app: path.resolve(__dirname, './src/app'),