Merge pull request 'feature/searchbar-enhance' (#17) from feature/searchbar-enhance into main
All checks were successful
Workflow / build (push) Successful in 39s
All checks were successful
Workflow / build (push) Successful in 39s
Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-check --tsconfig ./tsconfig.json || echo ''",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"check": "svelte-check",
|
||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"check:shadcn-excluded": "svelte-check --no-tsconfig --ignore \"src/shared/shadcn\"",
|
||||
"lint": "oxlint",
|
||||
@@ -23,7 +23,7 @@
|
||||
"test:component": "vitest run --config vitest.config.component.ts",
|
||||
"test:component:browser": "vitest run --config vitest.config.browser.ts",
|
||||
"test:component:browser:watch": "vitest --config vitest.config.browser.ts",
|
||||
"test": "npm run test:e2e && npm run test:unit",
|
||||
"test": "yarn run test:unit",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
|
||||
@@ -24,6 +24,8 @@ let { children } = $props();
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
|
||||
</svelte:head>
|
||||
|
||||
<div id="app-root">
|
||||
|
||||
@@ -60,9 +60,11 @@ export type {
|
||||
} from './model';
|
||||
|
||||
export {
|
||||
appliedFontsManager,
|
||||
createFontshareStore,
|
||||
fetchFontshareFontsQuery,
|
||||
fontshareStore,
|
||||
selectedFontsStore,
|
||||
} from './model';
|
||||
|
||||
// Stores
|
||||
@@ -72,4 +74,8 @@ export {
|
||||
} from './model/services/fetchGoogleFonts.svelte';
|
||||
|
||||
// UI elements
|
||||
export { FontList } from './ui';
|
||||
export {
|
||||
FontApplicator,
|
||||
FontListItem,
|
||||
FontVirtualList,
|
||||
} from './ui';
|
||||
|
||||
@@ -37,7 +37,9 @@ export type {
|
||||
export { fetchFontshareFontsQuery } from './services';
|
||||
|
||||
export {
|
||||
appliedFontsManager,
|
||||
createFontshareStore,
|
||||
type FontshareStore,
|
||||
fontshareStore,
|
||||
selectedFontsStore,
|
||||
} from './store';
|
||||
|
||||
@@ -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();
|
||||
@@ -17,3 +17,7 @@ export {
|
||||
type FontshareStore,
|
||||
fontshareStore,
|
||||
} from './fontshareStore.svelte';
|
||||
|
||||
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
||||
|
||||
export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte';
|
||||
|
||||
@@ -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>([]);
|
||||
77
src/entities/Font/ui/FontApplicator/FontApplicator.svelte
Normal file
77
src/entities/Font/ui/FontApplicator/FontApplicator.svelte
Normal 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>
|
||||
@@ -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>
|
||||
84
src/entities/Font/ui/FontListItem/FontListItem.svelte
Normal file
84
src/entities/Font/ui/FontListItem/FontListItem.svelte
Normal 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>
|
||||
35
src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte
Normal file
35
src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte
Normal 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>
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
1
src/features/DisplayFont/index.ts
Normal file
1
src/features/DisplayFont/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { FontDisplay } from './ui';
|
||||
1
src/features/DisplayFont/model/index.ts
Normal file
1
src/features/DisplayFont/model/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { displayedFontsStore } from './store';
|
||||
@@ -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();
|
||||
1
src/features/DisplayFont/model/store/index.ts
Normal file
1
src/features/DisplayFont/model/store/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { displayedFontsStore } from './displayedFontsStore.svelte';
|
||||
14
src/features/DisplayFont/ui/FontDisplay/FontDisplay.svelte
Normal file
14
src/features/DisplayFont/ui/FontDisplay/FontDisplay.svelte
Normal 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>
|
||||
46
src/features/DisplayFont/ui/FontSampler/FontSampler.svelte
Normal file
46
src/features/DisplayFont/ui/FontSampler/FontSampler.svelte
Normal 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>
|
||||
3
src/features/DisplayFont/ui/index.ts
Normal file
3
src/features/DisplayFont/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import FontDisplay from './FontDisplay/FontDisplay.svelte';
|
||||
|
||||
export { FontDisplay };
|
||||
@@ -4,6 +4,11 @@ import type { FilterConfig } from '../../model';
|
||||
|
||||
/**
|
||||
* Create a filter manager instance.
|
||||
* - Uses debounce to update search query for better performance.
|
||||
* - Manages filter instances for each group.
|
||||
*
|
||||
* @param config - Configuration for the filter manager.
|
||||
* @returns - An instance of the filter manager.
|
||||
*/
|
||||
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
|
||||
const search = createDebouncedState(config.queryValue ?? '');
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { FontshareParams } from '$entities/Font';
|
||||
import type { FilterManager } from '../filterManager/filterManager.svelte';
|
||||
|
||||
/**
|
||||
* Maps filter manager to fontshare params.
|
||||
*
|
||||
* @param manager - Filter manager instance.
|
||||
* @returns - Partial fontshare params.
|
||||
*/
|
||||
export function mapManagerToParams(manager: FilterManager): Partial<FontshareParams> {
|
||||
return {
|
||||
q: manager.debouncedQueryValue,
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
<!--
|
||||
Component: Filters
|
||||
Renders a list of CheckboxFilter components for each filter group.
|
||||
-->
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Filters Component
|
||||
*
|
||||
* Orchestrates all filter properties for the sidebar. Connects filter stores
|
||||
* to CheckboxFilter components, organizing them by filter type:
|
||||
*
|
||||
* - Font provider: Google Fonts vs Fontshare
|
||||
* - Font subset: Character subsets available (Latin, Latin Extended, etc.)
|
||||
* - Font category: Serif, Sans-serif, Display, etc.
|
||||
*
|
||||
* This component handles reactive sync between filterManager selections
|
||||
* and the unifiedFontStore using an $effect block to ensure filters are
|
||||
* automatically synchronized whenever selections change.
|
||||
*/
|
||||
import { CheckboxFilter } from '$shared/ui';
|
||||
import { filterManager } from '../../model';
|
||||
</script>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<!--
|
||||
Component: FiltersControl
|
||||
Renders a group of action buttons for filter operations.
|
||||
- Reset: Clears all active filters (outline variant for secondary action)
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Button } from '$shared/shadcn/ui/button';
|
||||
import { filterManager } from '../../model';
|
||||
|
||||
/**
|
||||
* Controls Component
|
||||
*
|
||||
* Action button group for filter operations. Provides two buttons:
|
||||
*
|
||||
* - Reset: Clears all active filters (outline variant for secondary action)
|
||||
*/
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
<!--
|
||||
Component: FontSearch
|
||||
|
||||
Combines search input with font list display
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontList,
|
||||
fontshareStore,
|
||||
} from '$entities/Font';
|
||||
import { fontshareStore } from '$entities/Font';
|
||||
import { SearchBar } from '$shared/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { mapManagerToParams } from '../../lib';
|
||||
import { filterManager } from '../../model';
|
||||
import SuggestedFonts from '../SuggestedFonts/SuggestedFonts.svelte';
|
||||
|
||||
/**
|
||||
* FontSearch
|
||||
*
|
||||
* Font search component with search input and font list display.
|
||||
* Uses unifiedFontStore for all font operations and search state.
|
||||
*/
|
||||
onMount(() => {
|
||||
/**
|
||||
* The Pairing:
|
||||
@@ -24,8 +21,6 @@ onMount(() => {
|
||||
|
||||
return unbind;
|
||||
});
|
||||
|
||||
$inspect(filterManager.queryValue, filterManager.debouncedQueryValue);
|
||||
</script>
|
||||
|
||||
<SearchBar
|
||||
@@ -34,5 +29,5 @@ $inspect(filterManager.queryValue, filterManager.debouncedQueryValue);
|
||||
placeholder="Search fonts by name..."
|
||||
bind:value={filterManager.queryValue}
|
||||
>
|
||||
<FontList />
|
||||
<SuggestedFonts />
|
||||
</SearchBar>
|
||||
|
||||
@@ -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>
|
||||
@@ -3,6 +3,12 @@ import {
|
||||
createTypographyControl,
|
||||
} from '$shared/lib';
|
||||
|
||||
/**
|
||||
* Creates a typography control manager that handles a collection of typography controls.
|
||||
*
|
||||
* @param configs - Array of control configurations.
|
||||
* @returns - Typography control manager instance.
|
||||
*/
|
||||
export function createTypographyControlManager(configs: ControlModel[]) {
|
||||
const controls = $state(
|
||||
configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<!--
|
||||
Component: SetupFontMenu
|
||||
Contains controls for setting up font properties.
|
||||
-->
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Component containing controls for setting up font properties.
|
||||
*/
|
||||
import { Separator } from '$shared/shadcn/ui/separator/index';
|
||||
import { Trigger as SidebarTrigger } from '$shared/shadcn/ui/sidebar';
|
||||
import { ComboControl } from '$shared/ui';
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
<script lang="ts">
|
||||
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
|
||||
|
||||
/**
|
||||
* Page Component
|
||||
*
|
||||
* Main page route component. Displays the font list and allows testing
|
||||
* the unified font store functionality. Fetches fonts on mount and displays
|
||||
* them using the FontList component.
|
||||
*
|
||||
* Receives unifiedFontStore from context created in Layout.svelte.
|
||||
*/
|
||||
// import {
|
||||
// UNIFIED_FONT_STORE_KEY,
|
||||
// type UnifiedFontStore,
|
||||
// } from '$entities/Font/model/store/unifiedFontStore.svelte';
|
||||
import FontList from '$entities/Font/ui/FontList/FontList.svelte';
|
||||
// import { applyFilters } from '$features/FontManagement';
|
||||
import {
|
||||
getContext,
|
||||
onMount,
|
||||
} from 'svelte';
|
||||
|
||||
// Receive store from context (created in Layout.svelte)
|
||||
// const unifiedFontStore: UnifiedFontStore = getContext(UNIFIED_FONT_STORE_KEY);
|
||||
</script>
|
||||
|
||||
<!-- Font List -->
|
||||
<FontList />
|
||||
<div class="p-2">
|
||||
<FontDisplay />
|
||||
</div>
|
||||
|
||||
32
src/shared/lib/accessibility/motion.svelte.ts
Normal file
32
src/shared/lib/accessibility/motion.svelte.ts
Normal 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();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,5 +1,28 @@
|
||||
import { debounce } from '$shared/lib/utils';
|
||||
|
||||
/**
|
||||
* Creates reactive state with immediate and debounced values.
|
||||
*
|
||||
* Useful for UI inputs that need instant feedback but debounced logic
|
||||
* (e.g., search fields with API calls). The immediate value updates on
|
||||
* every change for UI binding, while debounced updates after a delay.
|
||||
*
|
||||
* @param initialValue - Initial value for both immediate and debounced state
|
||||
* @param wait - Delay in milliseconds before updating debounced value (default: 300)
|
||||
* @returns Object with immediate/debounced getters, immediate setter, and reset method
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* const search = createDebouncedState('', 300);
|
||||
* </script>
|
||||
*
|
||||
* <input bind:value={search.immediate} />
|
||||
*
|
||||
* <p>Typing: {search.immediate}</p>
|
||||
* <p>Searching: {search.debounced}</p>
|
||||
* ```
|
||||
*/
|
||||
export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
|
||||
let immediate = $state(initialValue);
|
||||
let debounced = $state(initialValue);
|
||||
@@ -9,16 +32,23 @@ export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
|
||||
}, wait);
|
||||
|
||||
return {
|
||||
/** Current value with immediate updates (for UI binding) */
|
||||
get immediate() {
|
||||
return immediate;
|
||||
},
|
||||
set immediate(value: T) {
|
||||
immediate = value;
|
||||
updateDebounced(value); // Manually trigger the debounce on write
|
||||
// Manually trigger the debounce on write
|
||||
updateDebounced(value);
|
||||
},
|
||||
/** Current value with debounced updates (for logic/operations) */
|
||||
get debounced() {
|
||||
return debounced;
|
||||
},
|
||||
/**
|
||||
* Resets both values to initial or specified value.
|
||||
* @param value - Optional value to reset to (defaults to initialValue)
|
||||
*/
|
||||
reset(value?: T) {
|
||||
const resetValue = value ?? initialValue;
|
||||
immediate = resetValue;
|
||||
@@ -26,33 +56,3 @@ export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
|
||||
// let immediate = $state(initialValue);
|
||||
// let debounced = $state(initialValue);
|
||||
|
||||
// const updateDebounced = debounce((value: T) => {
|
||||
// debounced = value;
|
||||
// }, wait);
|
||||
|
||||
// $effect(() => {
|
||||
// updateDebounced(immediate);
|
||||
// });
|
||||
|
||||
// return {
|
||||
// get immediate() {
|
||||
// return immediate;
|
||||
// },
|
||||
// set immediate(value: T) {
|
||||
// immediate = value;
|
||||
// },
|
||||
// get debounced() {
|
||||
// return debounced;
|
||||
// },
|
||||
// reset(value?: T) {
|
||||
// const resetValue = value ?? initialValue;
|
||||
// immediate = resetValue;
|
||||
// debounced = resetValue;
|
||||
// },
|
||||
// };
|
||||
// }
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -26,84 +26,54 @@ export interface FilterModel<TValue extends string> {
|
||||
|
||||
/**
|
||||
* Create a filter store.
|
||||
* @param initialState - Initial state of the filter store
|
||||
* @param initialState - Initial state of filter store
|
||||
*/
|
||||
export function createFilter<TValue extends string>(
|
||||
initialState: FilterModel<TValue>,
|
||||
) {
|
||||
let properties = $state(
|
||||
export function createFilter<TValue extends string>(initialState: FilterModel<TValue>) {
|
||||
// We map the initial properties into a reactive state array
|
||||
const properties = $state(
|
||||
initialState.properties.map(p => ({
|
||||
...p,
|
||||
selected: p.selected ?? false,
|
||||
})),
|
||||
);
|
||||
|
||||
const selectedProperties = $derived(properties.filter(p => p.selected));
|
||||
const selectedCount = $derived(selectedProperties.length);
|
||||
// Helper to find a property by ID
|
||||
const findProp = (id: string) => properties.find(p => p.id === id);
|
||||
|
||||
return {
|
||||
/**
|
||||
* Get all properties.
|
||||
*/
|
||||
get properties() {
|
||||
return properties;
|
||||
},
|
||||
/**
|
||||
* Get selected properties.
|
||||
*/
|
||||
get selectedProperties() {
|
||||
return selectedProperties;
|
||||
return properties.filter(p => p.selected);
|
||||
},
|
||||
/**
|
||||
* Get selected count.
|
||||
*/
|
||||
get selectedCount() {
|
||||
return selectedCount;
|
||||
return properties.filter(p => p.selected)?.length;
|
||||
},
|
||||
/**
|
||||
* Toggle property selection.
|
||||
*/
|
||||
toggleProperty: (id: string) => {
|
||||
properties = properties.map(p => ({
|
||||
...p,
|
||||
selected: p.id === id ? !p.selected : p.selected,
|
||||
}));
|
||||
|
||||
toggleProperty(id: string) {
|
||||
const property = findProp(id);
|
||||
if (property) {
|
||||
property.selected = !property.selected;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Select property.
|
||||
*/
|
||||
selectProperty(id: string) {
|
||||
properties = properties.map(p => ({
|
||||
...p,
|
||||
selected: p.id === id ? true : p.selected,
|
||||
}));
|
||||
const property = findProp(id);
|
||||
if (property) {
|
||||
property.selected = true;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Deselect property.
|
||||
*/
|
||||
deselectProperty(id: string) {
|
||||
properties = properties.map(p => ({
|
||||
...p,
|
||||
selected: p.id === id ? false : p.selected,
|
||||
}));
|
||||
const property = findProp(id);
|
||||
if (property) {
|
||||
property.selected = false;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Select all properties.
|
||||
*/
|
||||
selectAll: () => {
|
||||
properties = properties.map(p => ({
|
||||
...p,
|
||||
selected: true,
|
||||
}));
|
||||
selectAll() {
|
||||
properties.forEach(property => property.selected = true);
|
||||
},
|
||||
/**
|
||||
* Deselect all properties.
|
||||
*/
|
||||
deselectAll: () => {
|
||||
properties = properties.map(p => ({
|
||||
...p,
|
||||
selected: false,
|
||||
}));
|
||||
deselectAll() {
|
||||
properties.forEach(property => property.selected = false);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,24 +1,105 @@
|
||||
export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
|
||||
// Reactive State
|
||||
/**
|
||||
* Represents a virtualized list item with layout information.
|
||||
*
|
||||
* Used to render visible items with absolute positioning based on computed offsets.
|
||||
*/
|
||||
export interface VirtualItem {
|
||||
/** Index of the item in the data array */
|
||||
index: number;
|
||||
/** Offset from the top of the list in pixels */
|
||||
start: number;
|
||||
/** Height/size of the item in pixels */
|
||||
size: number;
|
||||
/** End position in pixels (start + size) */
|
||||
end: number;
|
||||
/** Unique key for the item (for Svelte's {#each} keying) */
|
||||
key: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for {@link createVirtualizer}.
|
||||
*
|
||||
* Options are reactive - pass them through a function getter to enable updates.
|
||||
*/
|
||||
export interface VirtualizerOptions {
|
||||
/** Total number of items in the data array */
|
||||
count: number;
|
||||
/**
|
||||
* Function to estimate the size of an item at a given index.
|
||||
* Used for initial layout before actual measurements are available.
|
||||
*/
|
||||
estimateSize: (index: number) => number;
|
||||
/** Number of extra items to render outside viewport for smoother scrolling (default: 5) */
|
||||
overscan?: number;
|
||||
/**
|
||||
* Function to get the key of an item at a given index.
|
||||
* Defaults to using the index directly. Useful for stable keys when items reorder.
|
||||
*/
|
||||
getItemKey?: (index: number) => string | number;
|
||||
/**
|
||||
* Optional margin in pixels for scroll calculations.
|
||||
* Can be useful for handling sticky headers or other UI elements.
|
||||
*/
|
||||
scrollMargin?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reactive virtualizer for efficiently rendering large lists by only rendering visible items.
|
||||
*
|
||||
* Uses Svelte 5 runes ($state, $derived) for reactive state management and optimizes rendering
|
||||
* through scroll position tracking and item height measurement. Supports dynamic item heights
|
||||
* and programmatic scrolling.
|
||||
*
|
||||
* @param optionsGetter - Function that returns reactive virtualizer options
|
||||
* @returns Virtualizer instance with computed properties and action functions
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* const virtualizer = createVirtualizer(() => ({
|
||||
* count: 1000,
|
||||
* estimateSize: (i) => i % 3 === 0 ? 100 : 50,
|
||||
* overscan: 5,
|
||||
* getItemKey: (i) => `item-${i}`
|
||||
* }));
|
||||
* </script>
|
||||
*
|
||||
* <div use:virtualizer.container style="height: 500px; overflow: auto;">
|
||||
* <div style="height: {virtualizer.totalSize}px;">
|
||||
* {#each virtualizer.items as item (item.key)}
|
||||
* <div
|
||||
* use:virtualizer.measureElement
|
||||
* data-index={item.index}
|
||||
* style="position: absolute; top: {item.start}px; height: {item.size}px;"
|
||||
* >
|
||||
* Item {item.index}
|
||||
* </div>
|
||||
* {/each}
|
||||
* </div>
|
||||
* </div>
|
||||
* ```
|
||||
*/
|
||||
export function createVirtualizer<T>(
|
||||
optionsGetter: () => VirtualizerOptions & {
|
||||
data: T[];
|
||||
},
|
||||
) {
|
||||
let scrollOffset = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
let measuredSizes = $state<Record<number, number>>({});
|
||||
|
||||
// Non-reactive ref for DOM manipulation (avoiding unnecessary state tracking)
|
||||
let elementRef: HTMLElement | null = null;
|
||||
|
||||
// Reactive Options
|
||||
// By wrapping the getter in $derived, we track everything inside it
|
||||
const options = $derived(optionsGetter());
|
||||
|
||||
// Optimized Memoization (The Cache Layer)
|
||||
// Only recalculates when item count or measured sizes change.
|
||||
// This derivation now tracks: count, measuredSizes, AND the data array itself
|
||||
const offsets = $derived.by(() => {
|
||||
const count = options.count;
|
||||
const result = Array.from<number>({ length: count });
|
||||
const result = new Float64Array(count);
|
||||
let accumulated = 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
result[i] = accumulated;
|
||||
// Accessing measuredSizes here creates the subscription
|
||||
accumulated += measuredSizes[i] ?? options.estimateSize(i);
|
||||
}
|
||||
return result;
|
||||
@@ -31,24 +112,30 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
|
||||
: 0,
|
||||
);
|
||||
|
||||
// Visible Range Calculation
|
||||
// Svelte tracks dependencies automatically here.
|
||||
const items = $derived.by((): VirtualItem[] => {
|
||||
const count = options.count;
|
||||
if (count === 0 || containerHeight === 0) return [];
|
||||
// We MUST read options.data here so Svelte knows to re-run
|
||||
// this derivation when the items array is replaced!
|
||||
const { count, data } = options;
|
||||
if (count === 0 || containerHeight === 0 || !data) return [];
|
||||
|
||||
const overscan = options.overscan ?? 5;
|
||||
const viewportStart = scrollOffset;
|
||||
const viewportEnd = scrollOffset + containerHeight;
|
||||
|
||||
// Find Start (Linear Scan)
|
||||
// Binary search for efficiency
|
||||
let low = 0;
|
||||
let high = count - 1;
|
||||
let startIdx = 0;
|
||||
while (startIdx < count && offsets[startIdx + 1] < viewportStart) {
|
||||
startIdx++;
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if (offsets[mid] <= scrollOffset) {
|
||||
startIdx = mid;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Find End
|
||||
let endIdx = startIdx;
|
||||
const viewportEnd = scrollOffset + containerHeight;
|
||||
while (endIdx < count && offsets[endIdx] < viewportEnd) {
|
||||
endIdx++;
|
||||
}
|
||||
@@ -58,19 +145,27 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
|
||||
|
||||
const result: VirtualItem[] = [];
|
||||
for (let i = start; i < end; i++) {
|
||||
const size = measuredSizes[i] ?? options.estimateSize(i);
|
||||
result.push({
|
||||
index: i,
|
||||
start: offsets[i],
|
||||
size,
|
||||
end: offsets[i] + size,
|
||||
size: measuredSizes[i] ?? options.estimateSize(i),
|
||||
end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)),
|
||||
key: options.getItemKey?.(i) ?? i,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Svelte Actions (The DOM Interface)
|
||||
|
||||
/**
|
||||
* Svelte action to attach to the scrollable container element.
|
||||
*
|
||||
* Sets up scroll tracking, container height monitoring, and cleanup on destroy.
|
||||
*
|
||||
* @param node - The DOM element to attach to (should be the scrollable container)
|
||||
* @returns Object with destroy method for cleanup
|
||||
*/
|
||||
function container(node: HTMLElement) {
|
||||
elementRef = node;
|
||||
containerHeight = node.offsetHeight;
|
||||
@@ -95,27 +190,59 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
|
||||
};
|
||||
}
|
||||
|
||||
let measurementBuffer: Record<number, number> = {};
|
||||
let frameId: number | null = null;
|
||||
/**
|
||||
* Svelte action to measure individual item elements for dynamic height support.
|
||||
*
|
||||
* Attaches a ResizeObserver to track actual element height and updates
|
||||
* measured sizes when dimensions change. Requires `data-index` attribute on the element.
|
||||
*
|
||||
* @param node - The DOM element to measure (should have `data-index` attribute)
|
||||
* @returns Object with destroy method for cleanup
|
||||
*/
|
||||
function measureElement(node: HTMLElement) {
|
||||
// Use a ResizeObserver on individual items for dynamic height support
|
||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||
if (entry) {
|
||||
if (!entry) return;
|
||||
const index = parseInt(node.dataset.index || '', 10);
|
||||
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
||||
|
||||
// Only update if height actually changed to prevent loops
|
||||
if (!isNaN(index) && measuredSizes[index] !== height) {
|
||||
measuredSizes[index] = height;
|
||||
// 1. Stuff the measurement into a temporary buffer
|
||||
measurementBuffer[index] = height;
|
||||
|
||||
// 2. Schedule a single update for the next animation frame
|
||||
if (frameId === null) {
|
||||
frameId = requestAnimationFrame(() => {
|
||||
// 3. Update the state once for all collected measurements
|
||||
// We use spread to trigger a single fine-grained update
|
||||
measuredSizes = { ...measuredSizes, ...measurementBuffer };
|
||||
|
||||
// 4. Reset the buffer
|
||||
measurementBuffer = {};
|
||||
frameId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(node);
|
||||
return {
|
||||
destroy: () => resizeObserver.disconnect(),
|
||||
};
|
||||
return { destroy: () => resizeObserver.disconnect() };
|
||||
}
|
||||
|
||||
// Programmatic Scroll
|
||||
|
||||
/**
|
||||
* Scrolls the container to bring the specified item into view.
|
||||
*
|
||||
* @param index - Index of the item to scroll to
|
||||
* @param align - Scroll alignment: 'start', 'center', 'end', or 'auto' (default)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* virtualizer.scrollToIndex(50, 'center'); // Scroll to item 50 and center it
|
||||
* ```
|
||||
*/
|
||||
function scrollToIndex(index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') {
|
||||
if (!elementRef || index < 0 || index >= options.count) return;
|
||||
|
||||
@@ -130,42 +257,27 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
|
||||
}
|
||||
|
||||
return {
|
||||
/** Computed array of visible items to render (reactive) */
|
||||
get items() {
|
||||
return items;
|
||||
},
|
||||
/** Total height of all items in pixels (reactive) */
|
||||
get totalSize() {
|
||||
return totalSize;
|
||||
},
|
||||
/** Svelte action for the scrollable container element */
|
||||
container,
|
||||
/** Svelte action for measuring individual item elements */
|
||||
measureElement,
|
||||
/** Programmatic scroll method to scroll to a specific item */
|
||||
scrollToIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export interface VirtualItem {
|
||||
/** Index of the item in the data array */
|
||||
index: number;
|
||||
/** Offset from the top of the list */
|
||||
start: number;
|
||||
/** Height of the item */
|
||||
size: number;
|
||||
/** End position (start + size) */
|
||||
end: number;
|
||||
/** Unique key for the item (for Svelte's {#each} keying) */
|
||||
key: string | number;
|
||||
}
|
||||
|
||||
export interface VirtualizerOptions {
|
||||
/** Total number of items in the data array */
|
||||
count: number;
|
||||
/** Function to estimate the size of an item at a given index */
|
||||
estimateSize: (index: number) => number;
|
||||
/** Number of extra items to render outside viewport (default: 5) */
|
||||
overscan?: number;
|
||||
/** Function to get the key of an item at a given index (defaults to index) */
|
||||
getItemKey?: (index: number) => string | number;
|
||||
/** Optional margin in pixels for scroll calculations */
|
||||
scrollMargin?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtualizer instance returned by {@link createVirtualizer}.
|
||||
*
|
||||
* Provides reactive computed properties for visible items and total size,
|
||||
* along with action functions for DOM integration and element measurement.
|
||||
*/
|
||||
export type Virtualizer = ReturnType<typeof createVirtualizer>;
|
||||
|
||||
@@ -20,3 +20,9 @@ export {
|
||||
} from './createVirtualizer/createVirtualizer.svelte';
|
||||
|
||||
export { createDebouncedState } from './createDebouncedState/createDebouncedState.svelte';
|
||||
|
||||
export {
|
||||
createEntityStore,
|
||||
type Entity,
|
||||
type EntityStore,
|
||||
} from './createEntityStore/createEntityStore.svelte';
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
export {
|
||||
type ControlDataModel,
|
||||
type ControlModel,
|
||||
createDebouncedState,
|
||||
createEntityStore,
|
||||
createFilter,
|
||||
createTypographyControl,
|
||||
createVirtualizer,
|
||||
type Entity,
|
||||
type EntityStore,
|
||||
type Filter,
|
||||
type FilterModel,
|
||||
type Property,
|
||||
@@ -12,3 +16,6 @@ export {
|
||||
type Virtualizer,
|
||||
type VirtualizerOptions,
|
||||
} from './helpers';
|
||||
|
||||
export { motion } from './accessibility/motion.svelte';
|
||||
export { splitArray } from './utils';
|
||||
|
||||
@@ -11,3 +11,4 @@ export { clampNumber } from './clampNumber/clampNumber';
|
||||
export { debounce } from './debounce/debounce';
|
||||
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
||||
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
|
||||
export { splitArray } from './splitArray/splitArray';
|
||||
|
||||
14
src/shared/lib/utils/splitArray/splitArray.ts
Normal file
14
src/shared/lib/utils/splitArray/splitArray.ts
Normal 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]
|
||||
),
|
||||
[[], []],
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
<!--
|
||||
Component: CheckboxFilter
|
||||
A collapsible property filter with checkboxes. Displate selected count as a badge
|
||||
and supports reduced motion for accessibility.
|
||||
- Open by default for immediate visibility and interaction
|
||||
- Badge shown only when filters are active to reduce visual noise
|
||||
- Transitions use cubicOut for natural deceleration
|
||||
- Local transition prevents animation when component first renders
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Filter } from '$shared/lib';
|
||||
import { motion } from '$shared/lib';
|
||||
import { Badge } from '$shared/shadcn/ui/badge';
|
||||
import { buttonVariants } from '$shared/shadcn/ui/button';
|
||||
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
||||
@@ -9,23 +19,9 @@ import {
|
||||
} from '$shared/shadcn/ui/collapsible';
|
||||
import { Label } from '$shared/shadcn/ui/label';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import { onMount } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
/**
|
||||
* CheckboxFilter Component
|
||||
*
|
||||
* A collapsible property filter with checkboxes. Displays selected count as a badge
|
||||
* and supports reduced motion for accessibility. Used in sidebar filtering UIs.
|
||||
*
|
||||
* Design choices:
|
||||
* - Open by default for immediate visibility and interaction
|
||||
* - Badge shown only when filters are active to reduce visual noise
|
||||
* - Transitions use cubicOut for natural deceleration
|
||||
* - Local transition prevents animation when component first renders
|
||||
*/
|
||||
|
||||
interface PropertyFilterProps {
|
||||
/** Label for this filter group (e.g., "Properties", "Tags") */
|
||||
displayedLabel: string;
|
||||
@@ -37,29 +33,11 @@ const { displayedLabel, filter }: PropertyFilterProps = $props();
|
||||
|
||||
// Toggle state - defaults to open for better discoverability
|
||||
let isOpen = $state(true);
|
||||
// Accessibility preference to disable animations
|
||||
let prefersReducedMotion = $state(false);
|
||||
|
||||
// Check reduced motion preference on mount (window access required)
|
||||
// Event listener allows responding to system preference changes
|
||||
onMount(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
prefersReducedMotion = mediaQuery.matches;
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
prefersReducedMotion = e.matches;
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}
|
||||
});
|
||||
|
||||
// Animation config respects user preferences - zero duration if reduced motion enabled
|
||||
// Local modifier prevents animation on initial render, only animates user interactions
|
||||
const slideConfig = $derived({
|
||||
duration: prefersReducedMotion ? 0 : 250,
|
||||
duration: motion.reduced ? 0 : 250,
|
||||
easing: cubicOut,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
<!--
|
||||
Component: ComboControl
|
||||
Provides multiple ways to change certain value
|
||||
- via Increase/Decrease buttons
|
||||
- via Slider
|
||||
- via Input
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { TypographyControl } from '$shared/lib';
|
||||
import { Button } from '$shared/shadcn/ui/button';
|
||||
|
||||
62
src/shared/ui/ContentEditable/ContentEditable.svelte
Normal file
62
src/shared/ui/ContentEditable/ContentEditable.svelte
Normal 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>
|
||||
@@ -1,3 +1,10 @@
|
||||
<!--
|
||||
Component: SearchBar
|
||||
|
||||
Search input with popover dropdown for results/suggestions
|
||||
- Features keyboard navigation (ArrowDown/Up/Enter) and auto-focus prevention on popover open.
|
||||
- The input field serves as the popover trigger.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Input } from '$shared/shadcn/ui/input';
|
||||
import { Label } from '$shared/shadcn/ui/label';
|
||||
@@ -7,17 +14,20 @@ import {
|
||||
Trigger as PopoverTrigger,
|
||||
} from '$shared/shadcn/ui/popover';
|
||||
import { useId } from 'bits-ui';
|
||||
import {
|
||||
type Snippet,
|
||||
tick,
|
||||
} from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/** Unique identifier for the input element */
|
||||
id: string;
|
||||
/** Current search value (bindable) */
|
||||
value: string;
|
||||
/** Additional CSS classes for the container */
|
||||
class?: string;
|
||||
/** Placeholder text for the input */
|
||||
placeholder?: string;
|
||||
/** Optional label displayed above the input */
|
||||
label?: string;
|
||||
/** Content to render inside the popover (receives unique content ID) */
|
||||
children: Snippet<[{ id: string }]> | undefined;
|
||||
}
|
||||
|
||||
@@ -35,13 +45,6 @@ let triggerRef = $state<HTMLInputElement>(null!);
|
||||
// svelte-ignore state_referenced_locally
|
||||
const contentId = useId(id);
|
||||
|
||||
function closeAndFocusTrigger() {
|
||||
open = false;
|
||||
tick().then(() => {
|
||||
triggerRef?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
@@ -50,16 +53,14 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
|
||||
function handleInputClick() {
|
||||
open = true;
|
||||
tick().then(() => {
|
||||
triggerRef?.focus();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<PopoverRoot>
|
||||
<PopoverRoot bind:open>
|
||||
<PopoverTrigger bind:ref={triggerRef}>
|
||||
{#snippet child({ props })}
|
||||
<div {...props} class="flex flex-row flex-1 w-full">
|
||||
{@const { onclick, ...rest } = props}
|
||||
<div {...rest} class="flex flex-row flex-1 w-full">
|
||||
{#if label}
|
||||
<Label for={id}>{label}</Label>
|
||||
{/if}
|
||||
@@ -68,6 +69,7 @@ function handleInputClick() {
|
||||
placeholder={placeholder}
|
||||
bind:value={value}
|
||||
onkeydown={handleKeyDown}
|
||||
onclick={handleInputClick}
|
||||
class="flex flex-row flex-1"
|
||||
/>
|
||||
</div>
|
||||
@@ -76,7 +78,12 @@ function handleInputClick() {
|
||||
|
||||
<PopoverContent
|
||||
onOpenAutoFocus={e => e.preventDefault()}
|
||||
class="w-max"
|
||||
onInteractOutside={(e => {
|
||||
if (e.target === triggerRef) {
|
||||
e.preventDefault();
|
||||
}
|
||||
})}
|
||||
class="w-(--bits-popover-anchor-width) min-w-(--bits-popover-anchor-width)"
|
||||
>
|
||||
{@render children?.({ id: contentId })}
|
||||
</PopoverContent>
|
||||
|
||||
@@ -10,10 +10,7 @@
|
||||
<script lang="ts" generics="T">
|
||||
import { createVirtualizer } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
type Snippet,
|
||||
tick,
|
||||
} from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -38,6 +35,11 @@ interface Props {
|
||||
* (follows shadcn convention for className prop)
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* An optional callback that will be called for each new set of loaded items
|
||||
* @param items - Loaded items
|
||||
*/
|
||||
onVisibleItemsChange?: (items: T[]) => void;
|
||||
/**
|
||||
* Snippet for rendering individual list items.
|
||||
*
|
||||
@@ -53,32 +55,42 @@ interface Props {
|
||||
children: Snippet<[{ item: T; index: number }]>;
|
||||
}
|
||||
|
||||
let { items, itemHeight = 80, overscan = 5, class: className, children }: Props = $props();
|
||||
let { items, itemHeight = 80, overscan = 5, class: className, onVisibleItemsChange, children }:
|
||||
Props = $props();
|
||||
|
||||
const virtualizer = createVirtualizer(() => ({
|
||||
count: items.length,
|
||||
data: items,
|
||||
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
||||
overscan,
|
||||
}));
|
||||
|
||||
$effect(() => {
|
||||
const visibleItems = virtualizer.items.map(item => items[item.index]);
|
||||
onVisibleItemsChange?.(visibleItems);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
use:virtualizer.container
|
||||
class={cn(
|
||||
'relative overflow-auto border rounded-md bg-background',
|
||||
'outline-none focus-visible:ring-2 ring-ring ring-offset-2',
|
||||
'h-full w-full',
|
||||
'relative overflow-auto rounded-md bg-background',
|
||||
'h-150 w-full',
|
||||
className,
|
||||
)}
|
||||
role="listbox"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
style:height="{virtualizer.totalSize}px"
|
||||
class="w-full pointer-events-none"
|
||||
>
|
||||
</div>
|
||||
|
||||
{#each virtualizer.items as item (item.key)}
|
||||
<div
|
||||
use:virtualizer.measureElement
|
||||
data-index={item.index}
|
||||
class="absolute top-0 left-0 w-full translate-y-[var(--offset)] will-change-transform"
|
||||
style:--offset="{item.start}px"
|
||||
class="absolute top-0 left-0 w-full"
|
||||
style:transform="translateY({item.start}px)"
|
||||
>
|
||||
{@render children({ item: items[item.index], index: item.index })}
|
||||
</div>
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
|
||||
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
|
||||
import ComboControl from './ComboControl/ComboControl.svelte';
|
||||
import ContentEditable from './ContentEditable/ContentEditable.svelte';
|
||||
import SearchBar from './SearchBar/SearchBar.svelte';
|
||||
import VirtualList from './VirtualList/VirtualList.svelte';
|
||||
|
||||
export {
|
||||
CheckboxFilter,
|
||||
ComboControl,
|
||||
ContentEditable,
|
||||
SearchBar,
|
||||
VirtualList,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
/* Strictness & Safety */
|
||||
"strict": true,
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -53,6 +53,7 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
resolve: {
|
||||
conditions: process.env.VITEST ? ['browser'] : undefined,
|
||||
alias: {
|
||||
$lib: path.resolve(__dirname, './src/lib'),
|
||||
$app: path.resolve(__dirname, './src/app'),
|
||||
|
||||
Reference in New Issue
Block a user