refactor(helpers): modernize reactive helpers and add tests

This commit is contained in:
Ilia Mashkov
2026-03-02 22:18:59 +03:00
parent 594af924c7
commit ac73fd5044
12 changed files with 1117 additions and 185 deletions

View File

@@ -1,25 +1,83 @@
/** /**
* Interface representing a line of text with its measured width. * Character-by-character font comparison helper
*
* Creates utilities for comparing two fonts character by character.
* Used by the ComparisonView widget to render morphing text effects
* where characters transition between font A and font B based on
* slider position.
*
* Features:
* - Responsive text measurement using canvas
* - Binary search for optimal line breaking
* - Character proximity calculation for morphing effects
* - Handles CSS transforms correctly (uses offsetWidth)
*
* @example
* ```svelte
* <script lang="ts">
* import { createCharacterComparison } from '$shared/lib/helpers';
*
* const comparison = createCharacterComparison(
* () => text,
* () => fontA,
* () => fontB,
* () => weight,
* () => size
* );
*
* $: lines = comparison.lines;
* </script>
*
* <canvas bind:this={measureCanvas} hidden></canvas>
* <div bind:this={container}>
* {#each lines as line}
* <span>{line.text}</span>
* {/each}
* </div>
* ```
*/
/**
* Represents a single line of text with its measured width
*/ */
export interface LineData { export interface LineData {
/** /** The text content of the line */
* Line's text
*/
text: string; text: string;
/** /** Maximum width between both fonts in pixels */
* It's width
*/
width: number; width: number;
} }
/** /**
* Creates a helper for splitting text into lines and calculating character proximity. * Creates a character comparison helper for morphing text effects
* This is used by the ComparisonSlider (TestTen) to render morphing text.
* *
* @param text - The text to split and measure * Measures text in both fonts to determine line breaks and calculates
* @param fontA - The first font definition * character-level proximity for morphing animations.
* @param fontB - The second font definition *
* @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState) * @param text - Getter for the text to compare
* @param fontA - Getter for the first font (left/top side)
* @param fontB - Getter for the second font (right/bottom side)
* @param weight - Getter for the current font weight
* @param size - Getter for the controlled font size
* @returns Character comparison instance with lines and proximity calculations
*
* @example
* ```ts
* const comparison = createCharacterComparison(
* () => $sampleText,
* () => $selectedFontA,
* () => $selectedFontB,
* () => $fontWeight,
* () => $fontSize
* );
*
* // Call when DOM is ready
* comparison.breakIntoLines(container, canvas);
*
* // Get character state for morphing
* const state = comparison.getCharState(5, sliderPosition, lineEl, container);
* // state.proximity: 0-1 value for opacity/interpolation
* // state.isPast: true if slider is past this character
* ```
*/ */
export function createCharacterComparison< export function createCharacterComparison<
T extends { name: string; id: string } | undefined = undefined, T extends { name: string; id: string } | undefined = undefined,
@@ -33,17 +91,22 @@ export function createCharacterComparison<
let lines = $state<LineData[]>([]); let lines = $state<LineData[]>([]);
let containerWidth = $state(0); let containerWidth = $state(0);
/**
* Type guard to check if a font is defined
*/
function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T { function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T {
return font !== undefined; return font !== undefined;
} }
/** /**
* Measures text width using a canvas context. * Measures text width using canvas 2D context
*
* @param ctx - Canvas rendering context * @param ctx - Canvas rendering context
* @param text - Text string to measure * @param text - Text string to measure
* @param fontFamily - Font family name
* @param fontSize - Font size in pixels * @param fontSize - Font size in pixels
* @param fontWeight - Font weight * @param fontWeight - Font weight (100-900)
* @param fontFamily - Font family name (optional, returns 0 if missing)
* @returns Width of text in pixels
*/ */
function measureText( function measureText(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
@@ -58,8 +121,13 @@ export function createCharacterComparison<
} }
/** /**
* Determines the appropriate font size based on window width. * Gets responsive font size based on viewport width
* Matches the Tailwind breakpoints used in the component. *
* Matches Tailwind breakpoints used in the component:
* - < 640px: 64px
* - 640-767px: 80px
* - 768-1023px: 96px
* - >= 1024px: 112px
*/ */
function getFontSize() { function getFontSize() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -75,13 +143,14 @@ export function createCharacterComparison<
} }
/** /**
* Breaks the text into lines based on the container width and measure canvas. * Breaks text into lines based on container width
* Populates the `lines` state.
* *
* @param container - The container element to measure width from * Measures text in BOTH fonts and uses the wider width to prevent
* @param measureCanvas - The canvas element used for text measurement * layout shifts. Uses binary search for efficient word breaking.
*
* @param container - Container element to measure width from
* @param measureCanvas - Hidden canvas element for text measurement
*/ */
function breakIntoLines( function breakIntoLines(
container: HTMLElement | undefined, container: HTMLElement | undefined,
measureCanvas: HTMLCanvasElement | undefined, measureCanvas: HTMLCanvasElement | undefined,
@@ -90,13 +159,11 @@ export function createCharacterComparison<
return; return;
} }
// Use offsetWidth instead of getBoundingClientRect() to avoid CSS transform scaling issues // Use offsetWidth to avoid CSS transform scaling issues
// getBoundingClientRect() returns transformed dimensions, which causes incorrect line breaking // getBoundingClientRect() includes transform scale which breaks calculations
// when PerspectivePlan applies scale() transforms (e.g., scale(0.5) in settings mode)
const width = container.offsetWidth; const width = container.offsetWidth;
containerWidth = width; containerWidth = width;
// Padding considerations - matches the container padding
const padding = window.innerWidth < 640 ? 48 : 96; const padding = window.innerWidth < 640 ? 48 : 96;
const availableWidth = width - padding; const availableWidth = width - padding;
const ctx = measureCanvas.getContext('2d'); const ctx = measureCanvas.getContext('2d');
@@ -106,17 +173,19 @@ export function createCharacterComparison<
const controlledFontSize = size(); const controlledFontSize = size();
const fontSize = getFontSize(); const fontSize = getFontSize();
const currentWeight = weight(); // Get current weight const currentWeight = weight();
const words = text().split(' '); const words = text().split(' ');
const newLines: LineData[] = []; const newLines: LineData[] = [];
let currentLineWords: string[] = []; let currentLineWords: string[] = [];
/**
* Adds a line to the output using the wider font's width
*/
function pushLine(words: string[]) { function pushLine(words: string[]) {
if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) { if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
return; return;
} }
const lineText = words.join(' '); const lineText = words.join(' ');
// Measure both fonts at the CURRENT weight
const widthA = measureText( const widthA = measureText(
ctx!, ctx!,
lineText, lineText,
@@ -139,7 +208,7 @@ export function createCharacterComparison<
const testLine = currentLineWords.length > 0 const testLine = currentLineWords.length > 0
? currentLineWords.join(' ') + ' ' + word ? currentLineWords.join(' ') + ' ' + word
: word; : word;
// Measure with both fonts and use the wider one to prevent layout shifts // Measure with both fonts - use wider to prevent shifts
const widthA = measureText( const widthA = measureText(
ctx, ctx,
testLine, testLine,
@@ -163,6 +232,7 @@ export function createCharacterComparison<
currentLineWords = []; currentLineWords = [];
} }
// Check if word alone fits
const wordWidthA = measureText( const wordWidthA = measureText(
ctx, ctx,
word, word,
@@ -180,16 +250,16 @@ export function createCharacterComparison<
const wordAloneWidth = Math.max(wordWidthA, wordWidthB); const wordAloneWidth = Math.max(wordWidthA, wordWidthB);
if (wordAloneWidth <= availableWidth) { if (wordAloneWidth <= availableWidth) {
// If word fits start new line with it
currentLineWords = [word]; currentLineWords = [word];
} else { } else {
// Word doesn't fit - binary search to find break point
let remainingWord = word; let remainingWord = word;
while (remainingWord.length > 0) { while (remainingWord.length > 0) {
let low = 1; let low = 1;
let high = remainingWord.length; let high = remainingWord.length;
let bestBreak = 1; let bestBreak = 1;
// Binary Search to find the maximum characters that fit // Binary search for maximum characters that fit
while (low <= high) { while (low <= high) {
const mid = Math.floor((low + high) / 2); const mid = Math.floor((low + high) / 2);
const testFragment = remainingWord.slice(0, mid); const testFragment = remainingWord.slice(0, mid);
@@ -236,13 +306,16 @@ export function createCharacterComparison<
} }
/** /**
* precise calculation of character state based on global slider position. * Calculates character proximity to slider position
* *
* @param charIndex - Index of the character in the line * Used for morphing effects - returns how close a character is to
* @param sliderPos - Current slider position (0-100) * the slider and whether it's on the "past" side.
* @param lineElement - The line element *
* @param container - The container element * @param charIndex - Index of character within its line
* @returns Object containing proximity (0-1) and isPast (boolean) * @param sliderPos - Slider position (0-100, percent across container)
* @param lineElement - The line element containing the character
* @param container - The container element for position calculations
* @returns Proximity (0-1, 1 = at slider) and isPast (true = right of slider)
*/ */
function getCharState( function getCharState(
charIndex: number, charIndex: number,
@@ -262,14 +335,15 @@ export function createCharacterComparison<
return { proximity: 0, isPast: false }; return { proximity: 0, isPast: false };
} }
// Get the actual bounding box of the character // Get character bounding box relative to container
const charRect = charElement.getBoundingClientRect(); const charRect = charElement.getBoundingClientRect();
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
// Calculate character center relative to container // Calculate character center as percentage of container width
const charCenter = charRect.left + (charRect.width / 2) - containerRect.left; const charCenter = charRect.left + (charRect.width / 2) - containerRect.left;
const charGlobalPercent = (charCenter / containerWidth) * 100; const charGlobalPercent = (charCenter / containerWidth) * 100;
// Calculate proximity (1.0 = at slider, 0.0 = 5% away)
const distance = Math.abs(sliderPos - charGlobalPercent); const distance = Math.abs(sliderPos - charGlobalPercent);
const range = 5; const range = 5;
const proximity = Math.max(0, 1 - distance / range); const proximity = Math.max(0, 1 - distance / range);
@@ -279,15 +353,22 @@ export function createCharacterComparison<
} }
return { return {
/** Reactive array of broken lines */
get lines() { get lines() {
return lines; return lines;
}, },
/** Container width in pixels */
get containerWidth() { get containerWidth() {
return containerWidth; return containerWidth;
}, },
/** Break text into lines based on current container and fonts */
breakIntoLines, breakIntoLines,
/** Get character state for morphing calculations */
getCharState, getCharState,
}; };
} }
/**
* Type representing a character comparison instance
*/
export type CharacterComparison = ReturnType<typeof createCharacterComparison>; export type CharacterComparison = ReturnType<typeof createCharacterComparison>;

View File

@@ -0,0 +1,312 @@
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { createCharacterComparison } from './createCharacterComparison.svelte';
type Font = { name: string; id: string };
const fontA: Font = { name: 'Roboto', id: 'roboto' };
const fontB: Font = { name: 'Open Sans', id: 'open-sans' };
function createMockCanvas(charWidth = 10): HTMLCanvasElement {
return {
getContext: () => ({
font: '',
measureText: (text: string) => ({ width: text.length * charWidth }),
}),
} as unknown as HTMLCanvasElement;
}
function createMockContainer(offsetWidth = 500): HTMLElement {
return {
offsetWidth,
getBoundingClientRect: () => ({
left: 0,
width: offsetWidth,
top: 0,
right: offsetWidth,
bottom: 0,
height: 0,
}),
} as unknown as HTMLElement;
}
describe('createCharacterComparison', () => {
beforeEach(() => {
// Mock window.innerWidth for getFontSize and padding calculations
Object.defineProperty(globalThis, 'window', {
value: { innerWidth: 1024 },
writable: true,
configurable: true,
});
});
describe('Initial State', () => {
it('should initialize with empty lines and zero container width', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
expect(comparison.lines).toEqual([]);
expect(comparison.containerWidth).toBe(0);
});
});
describe('breakIntoLines', () => {
it('should not break lines when container or canvas is undefined', () => {
const comparison = createCharacterComparison(
() => 'Hello world',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(undefined, undefined);
expect(comparison.lines).toEqual([]);
comparison.breakIntoLines(createMockContainer(), undefined);
expect(comparison.lines).toEqual([]);
});
it('should not break lines when fonts are undefined', () => {
const comparison = createCharacterComparison(
() => 'Hello world',
() => undefined,
() => undefined,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(), createMockCanvas());
expect(comparison.lines).toEqual([]);
});
it('should produce a single line when text fits within container', () => {
// charWidth=10, padding=96 (innerWidth>=640), availableWidth=500-96=404
// "Hello" = 5 chars * 10 = 50px, fits easily
const comparison = createCharacterComparison(
() => 'Hello',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
expect(comparison.lines).toHaveLength(1);
expect(comparison.lines[0].text).toBe('Hello');
});
it('should break text into multiple lines when it overflows', () => {
// charWidth=10, container=200, padding=96, availableWidth=104
// "Hello world test" => "Hello" (50px), "Hello world" (110px > 104)
// So "Hello" goes on line 1, "world" (50px) fits, "world test" (100px) fits
const comparison = createCharacterComparison(
() => 'Hello world test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(200), createMockCanvas(10));
expect(comparison.lines.length).toBeGreaterThan(1);
// All original text should be preserved across lines
const reconstructed = comparison.lines.map(l => l.text).join(' ');
expect(reconstructed).toBe('Hello world test');
});
it('should update containerWidth after breaking lines', () => {
const comparison = createCharacterComparison(
() => 'Hi',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(750), createMockCanvas(10));
expect(comparison.containerWidth).toBe(750);
});
it('should use smaller padding on narrow viewports', () => {
Object.defineProperty(globalThis, 'window', {
value: { innerWidth: 500 },
writable: true,
configurable: true,
});
// container=150, padding=48 (innerWidth<640), availableWidth=102
// "ABCDEFGHIJ" = 10 chars * 10 = 100px, fits in 102
const comparison = createCharacterComparison(
() => 'ABCDEFGHIJ',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10));
expect(comparison.lines).toHaveLength(1);
expect(comparison.lines[0].text).toBe('ABCDEFGHIJ');
});
it('should break a single long word using binary search', () => {
// container=150, padding=96, availableWidth=54
// "ABCDEFGHIJ" = 10 chars * 10 = 100px, doesn't fit as single word
// Binary search should split it
const comparison = createCharacterComparison(
() => 'ABCDEFGHIJ',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10));
expect(comparison.lines.length).toBeGreaterThan(1);
const reconstructed = comparison.lines.map(l => l.text).join('');
expect(reconstructed).toBe('ABCDEFGHIJ');
});
it('should store max width between both fonts for each line', () => {
// Use a canvas where measureText returns text.length * charWidth
// Both fonts measure the same, so width = text.length * charWidth
const comparison = createCharacterComparison(
() => 'Hi',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
expect(comparison.lines[0].width).toBe(20); // "Hi" = 2 chars * 10
});
});
describe('getCharState', () => {
it('should return zero proximity and isPast=false when containerWidth is 0', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
const state = comparison.getCharState(0, 50, undefined, undefined);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
});
it('should return zero proximity when charElement is not found', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
// First break lines to set containerWidth
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
const lineEl = { children: [] } as unknown as HTMLElement;
const container = createMockContainer(500);
const state = comparison.getCharState(0, 50, lineEl, container);
expect(state.proximity).toBe(0);
expect(state.isPast).toBe(false);
});
it('should calculate proximity based on distance from slider', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
// Character centered at 250px in a 500px container = 50%
const charEl = {
getBoundingClientRect: () => ({ left: 240, width: 20 }),
};
const lineEl = { children: [charEl] } as unknown as HTMLElement;
const container = createMockContainer(500);
// Slider at 50% => charCenter at 250px => charGlobalPercent = 50%
// distance = |50 - 50| = 0, proximity = max(0, 1 - 0/5) = 1
const state = comparison.getCharState(0, 50, lineEl, container);
expect(state.proximity).toBe(1);
expect(state.isPast).toBe(false);
});
it('should return isPast=true when slider is past the character', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
// Character centered at 100px => 20% of 500px
const charEl = {
getBoundingClientRect: () => ({ left: 90, width: 20 }),
};
const lineEl = { children: [charEl] } as unknown as HTMLElement;
const container = createMockContainer(500);
// Slider at 80% => past the character at 20%
const state = comparison.getCharState(0, 80, lineEl, container);
expect(state.isPast).toBe(true);
});
it('should return zero proximity when character is far from slider', () => {
const comparison = createCharacterComparison(
() => 'test',
() => fontA,
() => fontB,
() => 400,
() => 48,
);
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
// Character at 10% of container, slider at 90% => distance = 80%, range = 5%
const charEl = {
getBoundingClientRect: () => ({ left: 45, width: 10 }),
};
const lineEl = { children: [charEl] } as unknown as HTMLElement;
const container = createMockContainer(500);
const state = comparison.getCharState(0, 90, lineEl, container);
expect(state.proximity).toBe(0);
});
});
});

View File

@@ -1,48 +1,102 @@
/**
* Generic entity store using Svelte 5's reactive SvelteMap
*
* Provides O(1) lookups by ID and granular reactivity for entity collections.
* Ideal for managing collections of objects with unique identifiers.
*
* @example
* ```ts
* interface User extends Entity {
* id: string;
* name: string;
* }
*
* const store = createEntityStore<User>([
* { id: '1', name: 'Alice' },
* { id: '2', name: 'Bob' }
* ]);
*
* // Access is reactive in Svelte components
* const allUsers = store.all;
* const alice = store.getById('1');
* ```
*/
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
/**
* Base entity interface requiring an ID field
*/
export interface Entity { export interface Entity {
/** Unique identifier for the entity */
id: string; id: string;
} }
/** /**
* Svelte 5 Entity Store * Reactive entity store with O(1) lookups
* Uses SvelteMap for O(1) lookups and granular reactivity. *
* Uses SvelteMap internally for reactive state that automatically
* triggers updates when entities are added, removed, or modified.
*/ */
export class EntityStore<T extends Entity> { export class EntityStore<T extends Entity> {
// SvelteMap is a reactive version of the native Map /** Reactive map of entities keyed by ID */
#entities = new SvelteMap<string, T>(); #entities = new SvelteMap<string, T>();
/**
* Creates a new entity store with optional initial data
* @param initialEntities - Initial entities to populate the store
*/
constructor(initialEntities: T[] = []) { constructor(initialEntities: T[] = []) {
this.setAll(initialEntities); this.setAll(initialEntities);
} }
// --- Selectors (Equivalent to Selectors) --- /**
* Get all entities as an array
/** Get all entities as an array */ * @returns Array of all entities in the store
*/
get all() { get all() {
return Array.from(this.#entities.values()); return Array.from(this.#entities.values());
} }
/** Select a single entity by ID */ /**
* Get a single entity by ID
* @param id - Entity ID to look up
* @returns The entity if found, undefined otherwise
*/
getById(id: string) { getById(id: string) {
return this.#entities.get(id); return this.#entities.get(id);
} }
/** Select multiple entities by IDs */ /**
* Get multiple entities by their IDs
* @param ids - Array of entity IDs to look up
* @returns Array of found entities (undefined IDs are filtered out)
*/
getByIds(ids: string[]) { getByIds(ids: string[]) {
return ids.map(id => this.#entities.get(id)).filter((e): e is T => !!e); return ids.map(id => this.#entities.get(id)).filter((e): e is T => !!e);
} }
// --- Actions (CRUD) --- /**
* Add a single entity to the store
* @param entity - Entity to add (updates if ID already exists)
*/
addOne(entity: T) { addOne(entity: T) {
this.#entities.set(entity.id, entity); this.#entities.set(entity.id, entity);
} }
/**
* Add multiple entities to the store
* @param entities - Array of entities to add
*/
addMany(entities: T[]) { addMany(entities: T[]) {
entities.forEach(e => this.addOne(e)); entities.forEach(e => this.addOne(e));
} }
/**
* Update an existing entity by merging changes
* @param id - ID of entity to update
* @param changes - Partial changes to merge into existing entity
*/
updateOne(id: string, changes: Partial<T>) { updateOne(id: string, changes: Partial<T>) {
const entity = this.#entities.get(id); const entity = this.#entities.get(id);
if (entity) { if (entity) {
@@ -50,32 +104,61 @@ export class EntityStore<T extends Entity> {
} }
} }
/**
* Remove a single entity by ID
* @param id - ID of entity to remove
*/
removeOne(id: string) { removeOne(id: string) {
this.#entities.delete(id); this.#entities.delete(id);
} }
/**
* Remove multiple entities by their IDs
* @param ids - Array of entity IDs to remove
*/
removeMany(ids: string[]) { removeMany(ids: string[]) {
ids.forEach(id => this.#entities.delete(id)); ids.forEach(id => this.#entities.delete(id));
} }
/**
* Replace all entities in the store
* Clears existing entities and adds new ones
* @param entities - New entities to populate the store with
*/
setAll(entities: T[]) { setAll(entities: T[]) {
this.#entities.clear(); this.#entities.clear();
this.addMany(entities); this.addMany(entities);
} }
/**
* Check if an entity exists in the store
* @param id - Entity ID to check
* @returns true if entity exists, false otherwise
*/
has(id: string) { has(id: string) {
return this.#entities.has(id); return this.#entities.has(id);
} }
/**
* Remove all entities from the store
*/
clear() { clear() {
this.#entities.clear(); this.#entities.clear();
} }
} }
/** /**
* Creates a new EntityStore instance with the given initial entities. * Creates a new entity store instance
* @param initialEntities The initial entities to populate the store with. * @param initialEntities - Initial entities to populate the store with
* @returns - A new EntityStore instance. * @returns A new EntityStore instance
*
* @example
* ```ts
* const store = createEntityStore([
* { id: '1', name: 'Item 1' },
* { id: '2', name: 'Item 2' }
* ]);
* ```
*/ */
export function createEntityStore<T extends Entity>(initialEntities: T[] = []) { export function createEntityStore<T extends Entity>(initialEntities: T[] = []) {
return new EntityStore<T>(initialEntities); return new EntityStore<T>(initialEntities);

View File

@@ -1,35 +1,80 @@
/**
* Filter state management for multi-select property filtering
*
* Creates reactive state for managing filterable properties with selection state.
* Commonly used for category filters, tag selection, and other multi-select UIs.
*
* @example
* ```ts
* const filter = createFilter({
* properties: [
* { id: 'sans', name: 'Sans Serif', value: 'sans-serif', selected: false },
* { id: 'serif', name: 'Serif', value: 'serif', selected: false }
* ]
* });
*
* // Access state
* filter.selectedProperties; // Currently selected items
* filter.selectedCount; // Number of selected items
*
* // Modify state
* filter.toggleProperty('sans');
* filter.selectAll();
* ```
*/
/**
* A filterable property with selection state
*
* @template TValue - The type of the property value (typically string)
*/
export interface Property<TValue extends string> { export interface Property<TValue extends string> {
/** /** Unique identifier for the property */
* Property identifier
*/
id: string; id: string;
/** /** Human-readable display name */
* Property name
*/
name: string; name: string;
/** /** Underlying value for filtering logic */
* Property value
*/
value: TValue; value: TValue;
/** /** Whether the property is currently selected */
* Property selected state
*/
selected?: boolean; selected?: boolean;
} }
/**
* Initial state configuration for a filter
*
* @template TValue - The type of property values
*/
export interface FilterModel<TValue extends string> { export interface FilterModel<TValue extends string> {
/** /** Array of filterable properties */
* Properties
*/
properties: Property<TValue>[]; properties: Property<TValue>[];
} }
/** /**
* Create a filter store. * Creates a reactive filter store for managing multi-select state
* @param initialState - Initial state of filter store *
* Provides methods for toggling, selecting, and deselecting properties
* along with derived state for selected items and counts.
*
* @param initialState - Initial configuration of properties and their selection state
* @returns Filter instance with reactive properties and methods
*
* @example
* ```ts
* // Create category filter
* const categoryFilter = createFilter({
* properties: [
* { id: 'sans', name: 'Sans Serif', value: 'sans-serif' },
* { id: 'serif', name: 'Serif', value: 'serif' },
* { id: 'display', name: 'Display', value: 'display' }
* ]
* });
*
* // In a Svelte component
* $: selected = categoryFilter.selectedProperties;
* ```
*/ */
export function createFilter<TValue extends string>(initialState: FilterModel<TValue>) { export function createFilter<TValue extends string>(initialState: FilterModel<TValue>) {
// We map the initial properties into a reactive state array // Map initial properties to reactive state with defaulted selection
const properties = $state( const properties = $state(
initialState.properties.map(p => ({ initialState.properties.map(p => ({
...p, ...p,
@@ -41,41 +86,77 @@ export function createFilter<TValue extends string>(initialState: FilterModel<TV
const findProp = (id: string) => properties.find(p => p.id === id); const findProp = (id: string) => properties.find(p => p.id === id);
return { return {
/**
* All properties with their current selection state
*/
get properties() { get properties() {
return properties; return properties;
}, },
/**
* Only properties that are currently selected
*/
get selectedProperties() { get selectedProperties() {
return properties.filter(p => p.selected); return properties.filter(p => p.selected);
}, },
/**
* Count of currently selected properties
*/
get selectedCount() { get selectedCount() {
return properties.filter(p => p.selected)?.length; return properties.filter(p => p.selected)?.length;
}, },
/**
* Toggle the selection state of a property
* @param id - Property ID to toggle
*/
toggleProperty(id: string) { toggleProperty(id: string) {
const property = findProp(id); const property = findProp(id);
if (property) { if (property) {
property.selected = !property.selected; property.selected = !property.selected;
} }
}, },
/**
* Select a property (idempotent - safe if already selected)
* @param id - Property ID to select
*/
selectProperty(id: string) { selectProperty(id: string) {
const property = findProp(id); const property = findProp(id);
if (property) { if (property) {
property.selected = true; property.selected = true;
} }
}, },
/**
* Deselect a property (idempotent - safe if already deselected)
* @param id - Property ID to deselect
*/
deselectProperty(id: string) { deselectProperty(id: string) {
const property = findProp(id); const property = findProp(id);
if (property) { if (property) {
property.selected = false; property.selected = false;
} }
}, },
/**
* Select all properties
*/
selectAll() { selectAll() {
properties.forEach(property => property.selected = true); properties.forEach(property => property.selected = true);
}, },
/**
* Deselect all properties
*/
deselectAll() { deselectAll() {
properties.forEach(property => property.selected = false); properties.forEach(property => property.selected = false);
}, },
}; };
} }
/**
* Type representing a filter instance
*/
export type Filter = ReturnType<typeof createFilter>; export type Filter = ReturnType<typeof createFilter>;

View File

@@ -1,10 +1,66 @@
/** /**
* Reusable persistent storage utility using Svelte 5 runes * Persistent localStorage-backed reactive state
* *
* Automatically syncs state with localStorage. * Creates reactive state that automatically syncs with localStorage.
* Values persist across browser sessions and are restored on page load.
*
* Handles edge cases:
* - SSR safety (no localStorage on server)
* - JSON parse errors (falls back to default)
* - Storage quota errors (logs warning, doesn't crash)
*
* @example
* ```ts
* // Store user preferences
* const preferences = createPersistentStore('user-prefs', {
* theme: 'dark',
* fontSize: 16,
* sidebarOpen: true
* });
*
* // Access reactive state
* $: currentTheme = preferences.value.theme;
*
* // Update (auto-saves to localStorage)
* preferences.value.theme = 'light';
*
* // Clear stored value
* preferences.clear();
* ```
*/
/**
* Creates a reactive store backed by localStorage
*
* The value is loaded from localStorage on initialization and automatically
* saved whenever it changes. Uses Svelte 5's $effect for reactive sync.
*
* @param key - localStorage key for storing the value
* @param defaultValue - Default value if no stored value exists
* @returns Persistent store with getter/setter and clear method
*
* @example
* ```ts
* // Simple value
* const counter = createPersistentStore('counter', 0);
* counter.value++;
*
* // Complex object
* interface Settings {
* theme: 'light' | 'dark';
* fontSize: number;
* }
* const settings = createPersistentStore<Settings>('app-settings', {
* theme: 'light',
* fontSize: 16
* });
* ```
*/ */
export function createPersistentStore<T>(key: string, defaultValue: T) { export function createPersistentStore<T>(key: string, defaultValue: T) {
// Initialize from storage or default /**
* Load value from localStorage or return default
* Safely handles missing keys, parse errors, and SSR
*/
const loadFromStorage = (): T => { const loadFromStorage = (): T => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return defaultValue; return defaultValue;
@@ -21,6 +77,7 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
let value = $state<T>(loadFromStorage()); let value = $state<T>(loadFromStorage());
// Sync to storage whenever value changes // Sync to storage whenever value changes
// Wrapped in $effect.root to prevent memory leaks
$effect.root(() => { $effect.root(() => {
$effect(() => { $effect(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -29,18 +86,27 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
try { try {
localStorage.setItem(key, JSON.stringify(value)); localStorage.setItem(key, JSON.stringify(value));
} catch (error) { } catch (error) {
// Quota exceeded or privacy mode - log but don't crash
console.warn(`[createPersistentStore] Error saving ${key}:`, error); console.warn(`[createPersistentStore] Error saving ${key}:`, error);
} }
}); });
}); });
return { return {
/**
* Current value (getter/setter)
* Changes automatically persist to localStorage
*/
get value() { get value() {
return value; return value;
}, },
set value(v: T) { set value(v: T) {
value = v; value = v;
}, },
/**
* Remove value from localStorage and reset to default
*/
clear() { clear() {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.removeItem(key); localStorage.removeItem(key);
@@ -50,4 +116,7 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
}; };
} }
/**
* Type representing a persistent store instance
*/
export type PersistentStore<T> = ReturnType<typeof createPersistentStore<T>>; export type PersistentStore<T> = ReturnType<typeof createPersistentStore<T>>;

View File

@@ -1,61 +1,83 @@
/**
* 3D perspective animation state manager
*
* Manages smooth transitions between "front" (interactive) and "back" (background)
* visual states using Svelte springs. Used for creating depth-based UI effects
* like settings panels, modal transitions, and spatial navigation.
*
* @example
* ```svelte
* <script lang="ts">
* import { createPerspectiveManager } from '$shared/lib/helpers';
*
* const perspective = createPerspectiveManager({
* depthStep: 100,
* scaleStep: 0.5,
* blurStep: 4
* });
* </script>
*
* <div
* style="transform: scale({perspective.isBack ? 0.5 : 1});
* filter: blur({perspective.isBack ? 4 : 0}px)"
* >
* <button on:click={perspective.toggle}>Toggle View</button>
* </div>
* ```
*/
import { Spring } from 'svelte/motion'; import { Spring } from 'svelte/motion';
/**
* Configuration options for perspective effects
*/
export interface PerspectiveConfig { export interface PerspectiveConfig {
/** /** Z-axis translation per level in pixels */
* How many px to move back per level
*/
depthStep?: number; depthStep?: number;
/** /** Scale reduction per level (0-1) */
* Scale reduction per level
*/
scaleStep?: number; scaleStep?: number;
/** /** Blur amount per level in pixels */
* Blur amount per level
*/
blurStep?: number; blurStep?: number;
/** /** Opacity reduction per level (0-1) */
* Opacity reduction per level
*/
opacityStep?: number; opacityStep?: number;
/** /** Parallax movement intensity per level */
* Parallax intensity per level
*/
parallaxIntensity?: number; parallaxIntensity?: number;
/** /** Horizontal offset - positive for right, negative for left */
* Horizontal offset for each plan (x-axis positioning)
* Positive = right, Negative = left
*/
horizontalOffset?: number; horizontalOffset?: number;
/** /** Layout mode: 'center' for centered, 'split' for side-by-side */
* Layout mode: 'center' (default) or 'split' for Swiss-style side-by-side
*/
layoutMode?: 'center' | 'split'; layoutMode?: 'center' | 'split';
} }
/** /**
* Manages perspective state with a simple boolean flag. * Manages perspective state with spring-based transitions
* *
* Drastically simplified from the complex camera/index system. * Simplified from a complex camera system to just track back/front state.
* Just manages whether content is in "back" or "front" state. * The spring value animates between 0 (front) and 1 (back) for smooth
* visual transitions of scale, blur, opacity, and position.
* *
* @example * @example
* ```typescript * ```ts
* const perspective = createPerspectiveManager({ * const perspective = createPerspectiveManager({
* depthStep: 100, * depthStep: 100,
* scaleStep: 0.5, * scaleStep: 0.5,
* blurStep: 4, * blurStep: 4
* }); * });
* *
* // Toggle back/front * // Check state (reactive)
* perspective.toggle(); * console.log(perspective.isBack); // false
* *
* // Check state * // Toggle with animation
* const isBack = perspective.isBack; // reactive boolean * perspective.toggle(); // Smoothly animates to back position
*
* // Direct control
* perspective.setBack(); // Go to back
* perspective.setFront(); // Go to front
* ``` * ```
*/ */
export class PerspectiveManager { export class PerspectiveManager {
/** /**
* Spring for smooth back/front transitions * Spring animation state
* Animates between 0 (front) and 1 (back) with configurable physics
*/ */
spring = new Spring(0, { spring = new Spring(0, {
stiffness: 0.2, stiffness: 0.2,
@@ -63,20 +85,30 @@ export class PerspectiveManager {
}); });
/** /**
* Reactive boolean: true when in back position (blurred, scaled down) * Reactive state: true when in back position
*
* Content should appear blurred, scaled down, and less interactive
* when this is true. Derived from spring value > 0.5.
*/ */
isBack = $derived(this.spring.current > 0.5); isBack = $derived(this.spring.current > 0.5);
/** /**
* Reactive boolean: true when in front position (fully visible, interactive) * Reactive state: true when in front position
*
* Content should be fully visible, sharp, and interactive
* when this is true. Derived from spring value < 0.5.
*/ */
isFront = $derived(this.spring.current < 0.5); isFront = $derived(this.spring.current < 0.5);
/** /**
* Configuration values for style computation * Internal configuration with defaults applied
*/ */
private config: Required<PerspectiveConfig>; private config: Required<PerspectiveConfig>;
/**
* Creates a new perspective manager
* @param config - Configuration for visual effects
*/
constructor(config: PerspectiveConfig = {}) { constructor(config: PerspectiveConfig = {}) {
this.config = { this.config = {
depthStep: config.depthStep ?? 100, depthStep: config.depthStep ?? 100,
@@ -90,8 +122,10 @@ export class PerspectiveManager {
} }
/** /**
* Toggle between front (0) and back (1) positions. * Toggle between front and back positions
* Smooth spring animation handles the transition. *
* Uses spring animation for smooth transition. Toggles based on
* current state - if spring < 0.5 goes to 1, otherwise goes to 0.
*/ */
toggle = () => { toggle = () => {
const target = this.spring.current < 0.5 ? 1 : 0; const target = this.spring.current < 0.5 ? 1 : 0;
@@ -99,31 +133,40 @@ export class PerspectiveManager {
}; };
/** /**
* Force to back position * Force to back position (blurred, scaled down)
*/ */
setBack = () => { setBack = () => {
this.spring.target = 1; this.spring.target = 1;
}; };
/** /**
* Force to front position * Force to front position (fully visible, interactive)
*/ */
setFront = () => { setFront = () => {
this.spring.target = 0; this.spring.target = 0;
}; };
/** /**
* Get configuration for style computation * Get current configuration
* @internal * @internal Used by components to compute styles
*/ */
getConfig = () => this.config; getConfig = () => this.config;
} }
/** /**
* Factory function to create a PerspectiveManager instance. * Factory function to create a perspective manager
* *
* @param config - Configuration options * @param config - Configuration options for visual effects
* @returns Configured PerspectiveManager instance * @returns Configured PerspectiveManager instance
*
* @example
* ```ts
* const perspective = createPerspectiveManager({
* scaleStep: 0.6,
* blurStep: 8,
* layoutMode: 'split'
* });
* ```
*/ */
export function createPerspectiveManager(config: PerspectiveConfig = {}) { export function createPerspectiveManager(config: PerspectiveConfig = {}) {
return new PerspectiveManager(config); return new PerspectiveManager(config);

View File

@@ -1,66 +1,96 @@
// $shared/lib/createResponsiveManager.svelte.ts /**
* Responsive breakpoint tracking using Svelte 5 runes
*
* Provides reactive viewport dimensions and breakpoint detection that
* automatically updates on window resize. Includes touch device detection
* and orientation tracking.
*
* Default breakpoints match Tailwind CSS:
* - xs: < 640px (mobile)
* - sm: 640px (mobile)
* - md: 768px (tablet portrait)
* - lg: 1024px (tablet)
* - xl: 1280px (desktop)
* - 2xl: 1536px (desktop large)
*
* @example
* ```svelte
* <script lang="ts">
* import { responsiveManager } from '$shared/lib/helpers';
*
* // Singleton is auto-initialized
* </script>
*
* {#if responsiveManager.isMobile}
* <MobileNav />
* {:else}
* <DesktopNav />
* {/if}
*
* <p>Viewport: {responsiveManager.width}x{responsiveManager.height}</p>
* <p>Breakpoint: {responsiveManager.currentBreakpoint}</p>
* ```
*/
/** /**
* Breakpoint definitions following common device sizes * Breakpoint definitions for responsive design
* Customize these values to match your design system *
* Values represent the minimum width (in pixels) for each breakpoint.
* Customize to match your design system's breakpoints.
*/ */
export interface Breakpoints { export interface Breakpoints {
/** Mobile devices (portrait phones) */ /** Mobile devices - default 640px */
mobile: number; mobile: number;
/** Tablet portrait */ /** Tablet portrait - default 768px */
tabletPortrait: number; tabletPortrait: number;
/** Tablet landscape */ /** Tablet landscape - default 1024px */
tablet: number; tablet: number;
/** Desktop */ /** Desktop - default 1280px */
desktop: number; desktop: number;
/** Large desktop */ /** Large desktop - default 1536px */
desktopLarge: number; desktopLarge: number;
} }
/** /**
* Default breakpoints (matches common Tailwind-like breakpoints) * Default breakpoint values (Tailwind CSS compatible)
*/ */
const DEFAULT_BREAKPOINTS: Breakpoints = { const DEFAULT_BREAKPOINTS: Breakpoints = {
mobile: 640, // sm mobile: 640,
tabletPortrait: 768, // md tabletPortrait: 768,
tablet: 1024, // lg tablet: 1024,
desktop: 1280, // xl desktop: 1280,
desktopLarge: 1536, // 2xl desktopLarge: 1536,
}; };
/** /**
* Orientation type * Device orientation type
*/ */
export type Orientation = 'portrait' | 'landscape'; export type Orientation = 'portrait' | 'landscape';
/** /**
* Creates a reactive responsive manager that tracks viewport size and breakpoints. * Creates a responsive manager for tracking viewport state
* *
* Provides reactive getters for: * Tracks viewport dimensions, calculates breakpoint states, and detects
* - Current breakpoint detection (isMobile, isTablet, etc.) * device capabilities (touch, orientation). Uses ResizeObserver for
* - Viewport dimensions (width, height) * accurate tracking and falls back to window resize events.
* - Device orientation (portrait/landscape)
* - Custom breakpoint matching
* *
* @param customBreakpoints - Optional custom breakpoint values * @param customBreakpoints - Optional custom breakpoint values
* @returns Responsive manager instance with reactive properties * @returns Responsive manager instance with reactive properties
* *
* @example * @example
* ```svelte * ```ts
* <script lang="ts"> * // Use defaults
* const responsive = createResponsiveManager(); * const responsive = createResponsiveManager();
* </script>
* *
* {#if responsive.isMobile} * // Custom breakpoints
* <MobileNav /> * const custom = createResponsiveManager({
* {:else if responsive.isTablet} * mobile: 480,
* <TabletNav /> * desktop: 1024
* {:else} * });
* <DesktopNav />
* {/if}
* *
* <p>Width: {responsive.width}px</p> * // In component
* <p>Orientation: {responsive.orientation}</p> * $: isMobile = responsive.isMobile;
* $: cols = responsive.isDesktop ? 3 : 1;
* ``` * ```
*/ */
export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>) { export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>) {
@@ -69,7 +99,7 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
...customBreakpoints, ...customBreakpoints,
}; };
// Reactive state // Reactive viewport dimensions
let width = $state(typeof window !== 'undefined' ? window.innerWidth : 0); let width = $state(typeof window !== 'undefined' ? window.innerWidth : 0);
let height = $state(typeof window !== 'undefined' ? window.innerHeight : 0); let height = $state(typeof window !== 'undefined' ? window.innerHeight : 0);
@@ -90,12 +120,12 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
const isMobileOrTablet = $derived(width < breakpoints.desktop); const isMobileOrTablet = $derived(width < breakpoints.desktop);
const isTabletOrDesktop = $derived(width >= breakpoints.tabletPortrait); const isTabletOrDesktop = $derived(width >= breakpoints.tabletPortrait);
// Orientation // Orientation detection
const orientation = $derived<Orientation>(height > width ? 'portrait' : 'landscape'); const orientation = $derived<Orientation>(height > width ? 'portrait' : 'landscape');
const isPortrait = $derived(orientation === 'portrait'); const isPortrait = $derived(orientation === 'portrait');
const isLandscape = $derived(orientation === 'landscape'); const isLandscape = $derived(orientation === 'landscape');
// Touch device detection (best effort) // Touch device detection (best effort heuristic)
const isTouchDevice = $derived( const isTouchDevice = $derived(
typeof window !== 'undefined' typeof window !== 'undefined'
&& ('ontouchstart' in window || navigator.maxTouchPoints > 0), && ('ontouchstart' in window || navigator.maxTouchPoints > 0),
@@ -103,7 +133,11 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
/** /**
* Initialize responsive tracking * Initialize responsive tracking
* Call this in an $effect or component mount *
* Sets up ResizeObserver on document.documentElement and falls back
* to window resize event listener. Returns cleanup function.
*
* @returns Cleanup function to remove listeners
*/ */
function init() { function init() {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@@ -130,9 +164,17 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
} }
/** /**
* Check if current width matches a custom breakpoint * Check if current viewport matches a custom breakpoint range
*
* @param min - Minimum width (inclusive) * @param min - Minimum width (inclusive)
* @param max - Maximum width (exclusive) * @param max - Optional maximum width (exclusive)
* @returns true if viewport width matches the range
*
* @example
* ```ts
* responsive.matches(768, 1024); // true for tablet only
* responsive.matches(1280); // true for desktop and larger
* ```
*/ */
function matches(min: number, max?: number): boolean { function matches(min: number, max?: number): boolean {
if (max !== undefined) { if (max !== undefined) {
@@ -142,7 +184,7 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
} }
/** /**
* Get the current breakpoint name * Current breakpoint name based on viewport width
*/ */
const currentBreakpoint = $derived<keyof Breakpoints | 'xs'>( const currentBreakpoint = $derived<keyof Breakpoints | 'xs'>(
(() => { (() => {
@@ -158,16 +200,17 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
case isDesktopLarge: case isDesktopLarge:
return 'desktopLarge'; return 'desktopLarge';
default: default:
return 'xs'; // Fallback for very small screens return 'xs';
} }
})(), })(),
); );
return { return {
// Dimensions /** Viewport width in pixels */
get width() { get width() {
return width; return width;
}, },
/** Viewport height in pixels */
get height() { get height() {
return height; return height;
}, },
@@ -227,6 +270,12 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
}; };
} }
/**
* Singleton responsive manager instance
*
* Auto-initializes on the client side. Use this throughout the app
* rather than creating multiple instances.
*/
export const responsiveManager = createResponsiveManager(); export const responsiveManager = createResponsiveManager();
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {

View File

@@ -0,0 +1,107 @@
/**
* Tests for createResponsiveManager
*/
import {
describe,
expect,
it,
} from 'vitest';
import { createResponsiveManager } from './createResponsiveManager.svelte';
describe('createResponsiveManager', () => {
describe('initialization', () => {
it('should create with default breakpoints', () => {
const manager = createResponsiveManager();
expect(manager.breakpoints).toEqual({
mobile: 640,
tabletPortrait: 768,
tablet: 1024,
desktop: 1280,
desktopLarge: 1536,
});
});
it('should merge custom breakpoints with defaults', () => {
const manager = createResponsiveManager({ mobile: 480, desktop: 1200 });
expect(manager.breakpoints.mobile).toBe(480);
expect(manager.breakpoints.desktop).toBe(1200);
expect(manager.breakpoints.tablet).toBe(1024); // default preserved
});
it('should have initial width and height from window', () => {
const manager = createResponsiveManager();
// In test environment, window dimensions come from jsdom/mocks
expect(typeof manager.width).toBe('number');
expect(typeof manager.height).toBe('number');
});
});
describe('matches', () => {
it('should return true when width is above min', () => {
const manager = createResponsiveManager();
// width is 0 in node env (no window), so matches(0) should be true
expect(manager.matches(0)).toBe(true);
});
it('should return false when width is below min', () => {
const manager = createResponsiveManager();
// width is 0, so matches(100) should be false
expect(manager.matches(100)).toBe(false);
});
it('should handle range with max', () => {
const manager = createResponsiveManager();
// width is 0, so matches(0, 100) should be true (0 >= 0 && 0 < 100)
expect(manager.matches(0, 100)).toBe(true);
// matches(1, 100) should be false (0 >= 1 is false)
expect(manager.matches(1, 100)).toBe(false);
});
});
describe('breakpoint states at width 0', () => {
it('should report isMobile when width is 0', () => {
const manager = createResponsiveManager();
expect(manager.isMobile).toBe(true);
expect(manager.isTabletPortrait).toBe(false);
expect(manager.isTablet).toBe(false);
expect(manager.isDesktop).toBe(false);
expect(manager.isDesktopLarge).toBe(false);
});
it('should report correct convenience groupings', () => {
const manager = createResponsiveManager();
expect(manager.isMobileOrTablet).toBe(true);
expect(manager.isTabletOrDesktop).toBe(false);
});
});
describe('orientation', () => {
it('should detect portrait when height > width', () => {
// Default: width=0, height=0 => not portrait (0 > 0 is false)
const manager = createResponsiveManager();
expect(manager.orientation).toBe('landscape');
expect(manager.isLandscape).toBe(true);
expect(manager.isPortrait).toBe(false);
});
});
describe('init', () => {
it('should return undefined in non-browser environment', () => {
const manager = createResponsiveManager();
const cleanup = manager.init();
// In node test env, window is undefined so init returns early
expect(cleanup).toBeUndefined();
});
});
});

View File

@@ -1,46 +1,98 @@
/**
* Numeric control with bounded values and step precision
*
* Creates a reactive control for numeric values that enforces min/max bounds
* and rounds to a specific step increment. Commonly used for typography controls
* like font size, line height, and letter spacing.
*
* @example
* ```ts
* const fontSize = createTypographyControl({
* value: 16,
* min: 12,
* max: 72,
* step: 1
* });
*
* // Access current value
* fontSize.value; // 16
* fontSize.isAtMin; // false
*
* // Modify value (automatically clamped and rounded)
* fontSize.increase();
* fontSize.value = 100; // Will be clamped to max (72)
* ```
*/
import { import {
clampNumber, clampNumber,
roundToStepPrecision, roundToStepPrecision,
} from '$shared/lib/utils'; } from '$shared/lib/utils';
/**
* Core numeric control configuration
* Defines the bounds and stepping behavior for a control
*/
export interface ControlDataModel { export interface ControlDataModel {
/** /** Current numeric value */
* Control value
*/
value: number; value: number;
/** /** Minimum allowed value (inclusive) */
* Minimal possible value
*/
min: number; min: number;
/** /** Maximum allowed value (inclusive) */
* Maximal possible value
*/
max: number; max: number;
/** /** Step size for increment/decrement operations */
* Step size for increase/decrease
*/
step: number; step: number;
} }
/**
* Full control model including accessibility labels
*
* @template T - Type for the control identifier
*/
export interface ControlModel<T extends string = string> extends ControlDataModel { export interface ControlModel<T extends string = string> extends ControlDataModel {
/** /** Unique identifier for the control */
* Control identifier
*/
id: T; id: T;
/** /** ARIA label for the increase button */
* Area label for increase button
*/
increaseLabel?: string; increaseLabel?: string;
/** /** ARIA label for the decrease button */
* Area label for decrease button
*/
decreaseLabel?: string; decreaseLabel?: string;
/** /** ARIA label for the control area */
* Control area label
*/
controlLabel?: string; controlLabel?: string;
} }
/**
* Creates a reactive numeric control with bounds and stepping
*
* The control automatically:
* - Clamps values to the min/max range
* - Rounds values to the step precision
* - Tracks whether at min/max bounds
*
* @param initialState - Initial value, bounds, and step configuration
* @returns Typography control instance with reactive state and methods
*
* @example
* ```ts
* // Font size control: 12-72px in 1px increments
* const fontSize = createTypographyControl({
* value: 16,
* min: 12,
* max: 72,
* step: 1
* });
*
* // Line height control: 1.0-2.0 in 0.1 increments
* const lineHeight = createTypographyControl({
* value: 1.5,
* min: 1.0,
* max: 2.0,
* step: 0.1
* });
*
* // Direct assignment (auto-clamped)
* fontSize.value = 100; // Becomes 72 (max)
* ```
*/
export function createTypographyControl<T extends ControlDataModel>( export function createTypographyControl<T extends ControlDataModel>(
initialState: T, initialState: T,
) { ) {
@@ -49,12 +101,17 @@ export function createTypographyControl<T extends ControlDataModel>(
let min = $state(initialState.min); let min = $state(initialState.min);
let step = $state(initialState.step); let step = $state(initialState.step);
// Derived state for boundary detection
const { isAtMax, isAtMin } = $derived({ const { isAtMax, isAtMin } = $derived({
isAtMax: value >= max, isAtMax: value >= max,
isAtMin: value <= min, isAtMin: value <= min,
}); });
return { return {
/**
* Current control value (getter/setter)
* Setting automatically clamps to bounds and rounds to step precision
*/
get value() { get value() {
return value; return value;
}, },
@@ -64,27 +121,45 @@ export function createTypographyControl<T extends ControlDataModel>(
value = rounded; value = rounded;
} }
}, },
/** Maximum allowed value */
get max() { get max() {
return max; return max;
}, },
/** Minimum allowed value */
get min() { get min() {
return min; return min;
}, },
/** Step increment size */
get step() { get step() {
return step; return step;
}, },
/** Whether the value is at or exceeds the maximum */
get isAtMax() { get isAtMax() {
return isAtMax; return isAtMax;
}, },
/** Whether the value is at or below the minimum */
get isAtMin() { get isAtMin() {
return isAtMin; return isAtMin;
}, },
/**
* Increase value by one step (clamped to max)
*/
increase() { increase() {
value = roundToStepPrecision( value = roundToStepPrecision(
clampNumber(value + step, min, max), clampNumber(value + step, min, max),
step, step,
); );
}, },
/**
* Decrease value by one step (clamped to min)
*/
decrease() { decrease() {
value = roundToStepPrecision( value = roundToStepPrecision(
clampNumber(value - step, min, max), clampNumber(value - step, min, max),
@@ -94,4 +169,7 @@ export function createTypographyControl<T extends ControlDataModel>(
}; };
} }
/**
* Type representing a typography control instance
*/
export type TypographyControl = ReturnType<typeof createTypographyControl>; export type TypographyControl = ReturnType<typeof createTypographyControl>;

View File

@@ -291,7 +291,7 @@ export function createVirtualizer<T>(
}, },
}; };
} else { } else {
containerHeight = node.offsetHeight; containerHeight = node.clientHeight;
const handleScroll = () => { const handleScroll = () => {
scrollOffset = node.scrollTop; scrollOffset = node.scrollTop;

View File

@@ -56,6 +56,11 @@ function createMockContainer(height = 500, scrollTop = 0): any {
configurable: true, configurable: true,
writable: true, writable: true,
}); });
Object.defineProperty(container, 'clientHeight', {
value: height,
configurable: true,
writable: true,
});
Object.defineProperty(container, 'scrollTop', { Object.defineProperty(container, 'scrollTop', {
value: scrollTop, value: scrollTop,
writable: true, writable: true,

View File

@@ -1,3 +1,27 @@
/**
* Reactive helper factories using Svelte 5 runes
*
* Provides composable state management patterns for common UI needs:
* - Filter management with multi-selection
* - Typography controls with bounds and stepping
* - Virtual scrolling for large lists
* - Debounced state for search inputs
* - Entity stores with O(1) lookups
* - Character-by-character font comparison
* - Persistent localStorage-backed state
* - Responsive breakpoint tracking
* - 3D perspective animations
*
* @example
* ```ts
* import { createFilter, createVirtualizer, createTypographyControl } from '$shared/lib/helpers';
*
* const filter = createFilter({ properties: [...] });
* const virtualizer = createVirtualizer(() => ({ count: 1000, estimateSize: () => 50 }));
* const control = createTypographyControl({ value: 16, min: 12, max: 72, step: 1 });
* ```
*/
export { export {
createFilter, createFilter,
type Filter, type Filter,