refactor(helpers): modernize reactive helpers and add tests
This commit is contained in:
@@ -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 {
|
||||
/**
|
||||
* Line's text
|
||||
*/
|
||||
/** The text content of the line */
|
||||
text: string;
|
||||
/**
|
||||
* It's width
|
||||
*/
|
||||
/** Maximum width between both fonts in pixels */
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a helper for splitting text into lines and calculating character proximity.
|
||||
* This is used by the ComparisonSlider (TestTen) to render morphing text.
|
||||
* Creates a character comparison helper for morphing text effects
|
||||
*
|
||||
* @param text - The text to split and measure
|
||||
* @param fontA - The first font definition
|
||||
* @param fontB - The second font definition
|
||||
* @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState)
|
||||
* Measures text in both fonts to determine line breaks and calculates
|
||||
* character-level proximity for morphing animations.
|
||||
*
|
||||
* @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<
|
||||
T extends { name: string; id: string } | undefined = undefined,
|
||||
@@ -33,17 +91,22 @@ export function createCharacterComparison<
|
||||
let lines = $state<LineData[]>([]);
|
||||
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 {
|
||||
return font !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures text width using a canvas context.
|
||||
* Measures text width using canvas 2D context
|
||||
*
|
||||
* @param ctx - Canvas rendering context
|
||||
* @param text - Text string to measure
|
||||
* @param fontFamily - Font family name
|
||||
* @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(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
@@ -58,8 +121,13 @@ export function createCharacterComparison<
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the appropriate font size based on window width.
|
||||
* Matches the Tailwind breakpoints used in the component.
|
||||
* Gets responsive font size based on viewport width
|
||||
*
|
||||
* Matches Tailwind breakpoints used in the component:
|
||||
* - < 640px: 64px
|
||||
* - 640-767px: 80px
|
||||
* - 768-1023px: 96px
|
||||
* - >= 1024px: 112px
|
||||
*/
|
||||
function getFontSize() {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -75,13 +143,14 @@ export function createCharacterComparison<
|
||||
}
|
||||
|
||||
/**
|
||||
* Breaks the text into lines based on the container width and measure canvas.
|
||||
* Populates the `lines` state.
|
||||
* Breaks text into lines based on container width
|
||||
*
|
||||
* @param container - The container element to measure width from
|
||||
* @param measureCanvas - The canvas element used for text measurement
|
||||
* Measures text in BOTH fonts and uses the wider width to prevent
|
||||
* 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(
|
||||
container: HTMLElement | undefined,
|
||||
measureCanvas: HTMLCanvasElement | undefined,
|
||||
@@ -90,13 +159,11 @@ export function createCharacterComparison<
|
||||
return;
|
||||
}
|
||||
|
||||
// Use offsetWidth instead of getBoundingClientRect() to avoid CSS transform scaling issues
|
||||
// getBoundingClientRect() returns transformed dimensions, which causes incorrect line breaking
|
||||
// when PerspectivePlan applies scale() transforms (e.g., scale(0.5) in settings mode)
|
||||
// Use offsetWidth to avoid CSS transform scaling issues
|
||||
// getBoundingClientRect() includes transform scale which breaks calculations
|
||||
const width = container.offsetWidth;
|
||||
containerWidth = width;
|
||||
|
||||
// Padding considerations - matches the container padding
|
||||
const padding = window.innerWidth < 640 ? 48 : 96;
|
||||
const availableWidth = width - padding;
|
||||
const ctx = measureCanvas.getContext('2d');
|
||||
@@ -106,17 +173,19 @@ export function createCharacterComparison<
|
||||
|
||||
const controlledFontSize = size();
|
||||
const fontSize = getFontSize();
|
||||
const currentWeight = weight(); // Get current weight
|
||||
const currentWeight = weight();
|
||||
const words = text().split(' ');
|
||||
const newLines: LineData[] = [];
|
||||
let currentLineWords: string[] = [];
|
||||
|
||||
/**
|
||||
* Adds a line to the output using the wider font's width
|
||||
*/
|
||||
function pushLine(words: string[]) {
|
||||
if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
|
||||
return;
|
||||
}
|
||||
const lineText = words.join(' ');
|
||||
// Measure both fonts at the CURRENT weight
|
||||
const widthA = measureText(
|
||||
ctx!,
|
||||
lineText,
|
||||
@@ -139,7 +208,7 @@ export function createCharacterComparison<
|
||||
const testLine = currentLineWords.length > 0
|
||||
? currentLineWords.join(' ') + ' ' + 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(
|
||||
ctx,
|
||||
testLine,
|
||||
@@ -163,6 +232,7 @@ export function createCharacterComparison<
|
||||
currentLineWords = [];
|
||||
}
|
||||
|
||||
// Check if word alone fits
|
||||
const wordWidthA = measureText(
|
||||
ctx,
|
||||
word,
|
||||
@@ -180,16 +250,16 @@ export function createCharacterComparison<
|
||||
const wordAloneWidth = Math.max(wordWidthA, wordWidthB);
|
||||
|
||||
if (wordAloneWidth <= availableWidth) {
|
||||
// If word fits start new line with it
|
||||
currentLineWords = [word];
|
||||
} else {
|
||||
// Word doesn't fit - binary search to find break point
|
||||
let remainingWord = word;
|
||||
while (remainingWord.length > 0) {
|
||||
let low = 1;
|
||||
let high = remainingWord.length;
|
||||
let bestBreak = 1;
|
||||
|
||||
// Binary Search to find the maximum characters that fit
|
||||
// Binary search for maximum characters that fit
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
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
|
||||
* @param sliderPos - Current slider position (0-100)
|
||||
* @param lineElement - The line element
|
||||
* @param container - The container element
|
||||
* @returns Object containing proximity (0-1) and isPast (boolean)
|
||||
* Used for morphing effects - returns how close a character is to
|
||||
* the slider and whether it's on the "past" side.
|
||||
*
|
||||
* @param charIndex - Index of character within its line
|
||||
* @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(
|
||||
charIndex: number,
|
||||
@@ -262,14 +335,15 @@ export function createCharacterComparison<
|
||||
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 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 charGlobalPercent = (charCenter / containerWidth) * 100;
|
||||
|
||||
// Calculate proximity (1.0 = at slider, 0.0 = 5% away)
|
||||
const distance = Math.abs(sliderPos - charGlobalPercent);
|
||||
const range = 5;
|
||||
const proximity = Math.max(0, 1 - distance / range);
|
||||
@@ -279,15 +353,22 @@ export function createCharacterComparison<
|
||||
}
|
||||
|
||||
return {
|
||||
/** Reactive array of broken lines */
|
||||
get lines() {
|
||||
return lines;
|
||||
},
|
||||
/** Container width in pixels */
|
||||
get containerWidth() {
|
||||
return containerWidth;
|
||||
},
|
||||
/** Break text into lines based on current container and fonts */
|
||||
breakIntoLines,
|
||||
/** Get character state for morphing calculations */
|
||||
getCharState,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Type representing a character comparison instance
|
||||
*/
|
||||
export type CharacterComparison = ReturnType<typeof createCharacterComparison>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* Base entity interface requiring an ID field
|
||||
*/
|
||||
export interface Entity {
|
||||
/** Unique identifier for the entity */
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Svelte 5 Entity Store
|
||||
* Uses SvelteMap for O(1) lookups and granular reactivity.
|
||||
* Reactive entity store with O(1) lookups
|
||||
*
|
||||
* Uses SvelteMap internally for reactive state that automatically
|
||||
* triggers updates when entities are added, removed, or modified.
|
||||
*/
|
||||
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>();
|
||||
|
||||
/**
|
||||
* Creates a new entity store with optional initial data
|
||||
* @param initialEntities - Initial entities to populate the store
|
||||
*/
|
||||
constructor(initialEntities: T[] = []) {
|
||||
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() {
|
||||
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) {
|
||||
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[]) {
|
||||
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) {
|
||||
this.#entities.set(entity.id, entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple entities to the store
|
||||
* @param entities - Array of entities to add
|
||||
*/
|
||||
addMany(entities: T[]) {
|
||||
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>) {
|
||||
const entity = this.#entities.get(id);
|
||||
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) {
|
||||
this.#entities.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove multiple entities by their IDs
|
||||
* @param ids - Array of entity IDs to remove
|
||||
*/
|
||||
removeMany(ids: string[]) {
|
||||
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[]) {
|
||||
this.#entities.clear();
|
||||
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) {
|
||||
return this.#entities.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all entities from the store
|
||||
*/
|
||||
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.
|
||||
* Creates a new entity store instance
|
||||
* @param initialEntities - Initial entities to populate the store with
|
||||
* @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[] = []) {
|
||||
return new EntityStore<T>(initialEntities);
|
||||
|
||||
@@ -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> {
|
||||
/**
|
||||
* Property identifier
|
||||
*/
|
||||
/** Unique identifier for the property */
|
||||
id: string;
|
||||
/**
|
||||
* Property name
|
||||
*/
|
||||
/** Human-readable display name */
|
||||
name: string;
|
||||
/**
|
||||
* Property value
|
||||
*/
|
||||
/** Underlying value for filtering logic */
|
||||
value: TValue;
|
||||
/**
|
||||
* Property selected state
|
||||
*/
|
||||
/** Whether the property is currently selected */
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export interface FilterModel<TValue extends string> {
|
||||
/**
|
||||
* Properties
|
||||
* Initial state configuration for a filter
|
||||
*
|
||||
* @template TValue - The type of property values
|
||||
*/
|
||||
export interface FilterModel<TValue extends string> {
|
||||
/** Array of filterable properties */
|
||||
properties: Property<TValue>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filter store.
|
||||
* @param initialState - Initial state of filter store
|
||||
* Creates a reactive filter store for managing multi-select state
|
||||
*
|
||||
* 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>) {
|
||||
// We map the initial properties into a reactive state array
|
||||
// Map initial properties to reactive state with defaulted selection
|
||||
const properties = $state(
|
||||
initialState.properties.map(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);
|
||||
|
||||
return {
|
||||
/**
|
||||
* All properties with their current selection state
|
||||
*/
|
||||
get properties() {
|
||||
return properties;
|
||||
},
|
||||
|
||||
/**
|
||||
* Only properties that are currently selected
|
||||
*/
|
||||
get selectedProperties() {
|
||||
return properties.filter(p => p.selected);
|
||||
},
|
||||
|
||||
/**
|
||||
* Count of currently selected properties
|
||||
*/
|
||||
get selectedCount() {
|
||||
return properties.filter(p => p.selected)?.length;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle the selection state of a property
|
||||
* @param id - Property ID to toggle
|
||||
*/
|
||||
toggleProperty(id: string) {
|
||||
const property = findProp(id);
|
||||
if (property) {
|
||||
property.selected = !property.selected;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Select a property (idempotent - safe if already selected)
|
||||
* @param id - Property ID to select
|
||||
*/
|
||||
selectProperty(id: string) {
|
||||
const property = findProp(id);
|
||||
if (property) {
|
||||
property.selected = true;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Deselect a property (idempotent - safe if already deselected)
|
||||
* @param id - Property ID to deselect
|
||||
*/
|
||||
deselectProperty(id: string) {
|
||||
const property = findProp(id);
|
||||
if (property) {
|
||||
property.selected = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Select all properties
|
||||
*/
|
||||
selectAll() {
|
||||
properties.forEach(property => property.selected = true);
|
||||
},
|
||||
|
||||
/**
|
||||
* Deselect all properties
|
||||
*/
|
||||
deselectAll() {
|
||||
properties.forEach(property => property.selected = false);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Type representing a filter instance
|
||||
*/
|
||||
export type Filter = ReturnType<typeof createFilter>;
|
||||
|
||||
@@ -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) {
|
||||
// Initialize from storage or default
|
||||
/**
|
||||
* Load value from localStorage or return default
|
||||
* Safely handles missing keys, parse errors, and SSR
|
||||
*/
|
||||
const loadFromStorage = (): T => {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultValue;
|
||||
@@ -21,6 +77,7 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
|
||||
let value = $state<T>(loadFromStorage());
|
||||
|
||||
// Sync to storage whenever value changes
|
||||
// Wrapped in $effect.root to prevent memory leaks
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -29,18 +86,27 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
// Quota exceeded or privacy mode - log but don't crash
|
||||
console.warn(`[createPersistentStore] Error saving ${key}:`, error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
/**
|
||||
* Current value (getter/setter)
|
||||
* Changes automatically persist to localStorage
|
||||
*/
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
set value(v: T) {
|
||||
value = v;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove value from localStorage and reset to default
|
||||
*/
|
||||
clear() {
|
||||
if (typeof window !== 'undefined') {
|
||||
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>>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* Configuration options for perspective effects
|
||||
*/
|
||||
export interface PerspectiveConfig {
|
||||
/**
|
||||
* How many px to move back per level
|
||||
*/
|
||||
/** Z-axis translation per level in pixels */
|
||||
depthStep?: number;
|
||||
/**
|
||||
* Scale reduction per level
|
||||
*/
|
||||
/** Scale reduction per level (0-1) */
|
||||
scaleStep?: number;
|
||||
/**
|
||||
* Blur amount per level
|
||||
*/
|
||||
/** Blur amount per level in pixels */
|
||||
blurStep?: number;
|
||||
/**
|
||||
* Opacity reduction per level
|
||||
*/
|
||||
/** Opacity reduction per level (0-1) */
|
||||
opacityStep?: number;
|
||||
/**
|
||||
* Parallax intensity per level
|
||||
*/
|
||||
/** Parallax movement intensity per level */
|
||||
parallaxIntensity?: number;
|
||||
/**
|
||||
* Horizontal offset for each plan (x-axis positioning)
|
||||
* Positive = right, Negative = left
|
||||
*/
|
||||
/** Horizontal offset - positive for right, negative for left */
|
||||
horizontalOffset?: number;
|
||||
/**
|
||||
* Layout mode: 'center' (default) or 'split' for Swiss-style side-by-side
|
||||
*/
|
||||
/** Layout mode: 'center' for centered, 'split' for side-by-side */
|
||||
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.
|
||||
* Just manages whether content is in "back" or "front" state.
|
||||
* Simplified from a complex camera system to just track back/front state.
|
||||
* The spring value animates between 0 (front) and 1 (back) for smooth
|
||||
* visual transitions of scale, blur, opacity, and position.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* ```ts
|
||||
* const perspective = createPerspectiveManager({
|
||||
* depthStep: 100,
|
||||
* scaleStep: 0.5,
|
||||
* blurStep: 4,
|
||||
* blurStep: 4
|
||||
* });
|
||||
*
|
||||
* // Toggle back/front
|
||||
* perspective.toggle();
|
||||
* // Check state (reactive)
|
||||
* console.log(perspective.isBack); // false
|
||||
*
|
||||
* // Check state
|
||||
* const isBack = perspective.isBack; // reactive boolean
|
||||
* // Toggle with animation
|
||||
* perspective.toggle(); // Smoothly animates to back position
|
||||
*
|
||||
* // Direct control
|
||||
* perspective.setBack(); // Go to back
|
||||
* perspective.setFront(); // Go to front
|
||||
* ```
|
||||
*/
|
||||
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, {
|
||||
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);
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* Configuration values for style computation
|
||||
* Internal configuration with defaults applied
|
||||
*/
|
||||
private config: Required<PerspectiveConfig>;
|
||||
|
||||
/**
|
||||
* Creates a new perspective manager
|
||||
* @param config - Configuration for visual effects
|
||||
*/
|
||||
constructor(config: PerspectiveConfig = {}) {
|
||||
this.config = {
|
||||
depthStep: config.depthStep ?? 100,
|
||||
@@ -90,8 +122,10 @@ export class PerspectiveManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between front (0) and back (1) positions.
|
||||
* Smooth spring animation handles the transition.
|
||||
* Toggle between front and back positions
|
||||
*
|
||||
* Uses spring animation for smooth transition. Toggles based on
|
||||
* current state - if spring < 0.5 goes to 1, otherwise goes to 0.
|
||||
*/
|
||||
toggle = () => {
|
||||
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 = () => {
|
||||
this.spring.target = 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Force to front position
|
||||
* Force to front position (fully visible, interactive)
|
||||
*/
|
||||
setFront = () => {
|
||||
this.spring.target = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get configuration for style computation
|
||||
* @internal
|
||||
* Get current configuration
|
||||
* @internal Used by components to compute styles
|
||||
*/
|
||||
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
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const perspective = createPerspectiveManager({
|
||||
* scaleStep: 0.6,
|
||||
* blurStep: 8,
|
||||
* layoutMode: 'split'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createPerspectiveManager(config: PerspectiveConfig = {}) {
|
||||
return new PerspectiveManager(config);
|
||||
|
||||
@@ -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
|
||||
* Customize these values to match your design system
|
||||
* Breakpoint definitions for responsive design
|
||||
*
|
||||
* Values represent the minimum width (in pixels) for each breakpoint.
|
||||
* Customize to match your design system's breakpoints.
|
||||
*/
|
||||
export interface Breakpoints {
|
||||
/** Mobile devices (portrait phones) */
|
||||
/** Mobile devices - default 640px */
|
||||
mobile: number;
|
||||
/** Tablet portrait */
|
||||
/** Tablet portrait - default 768px */
|
||||
tabletPortrait: number;
|
||||
/** Tablet landscape */
|
||||
/** Tablet landscape - default 1024px */
|
||||
tablet: number;
|
||||
/** Desktop */
|
||||
/** Desktop - default 1280px */
|
||||
desktop: number;
|
||||
/** Large desktop */
|
||||
/** Large desktop - default 1536px */
|
||||
desktopLarge: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default breakpoints (matches common Tailwind-like breakpoints)
|
||||
* Default breakpoint values (Tailwind CSS compatible)
|
||||
*/
|
||||
const DEFAULT_BREAKPOINTS: Breakpoints = {
|
||||
mobile: 640, // sm
|
||||
tabletPortrait: 768, // md
|
||||
tablet: 1024, // lg
|
||||
desktop: 1280, // xl
|
||||
desktopLarge: 1536, // 2xl
|
||||
mobile: 640,
|
||||
tabletPortrait: 768,
|
||||
tablet: 1024,
|
||||
desktop: 1280,
|
||||
desktopLarge: 1536,
|
||||
};
|
||||
|
||||
/**
|
||||
* Orientation type
|
||||
* Device orientation type
|
||||
*/
|
||||
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:
|
||||
* - Current breakpoint detection (isMobile, isTablet, etc.)
|
||||
* - Viewport dimensions (width, height)
|
||||
* - Device orientation (portrait/landscape)
|
||||
* - Custom breakpoint matching
|
||||
* Tracks viewport dimensions, calculates breakpoint states, and detects
|
||||
* device capabilities (touch, orientation). Uses ResizeObserver for
|
||||
* accurate tracking and falls back to window resize events.
|
||||
*
|
||||
* @param customBreakpoints - Optional custom breakpoint values
|
||||
* @returns Responsive manager instance with reactive properties
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* ```ts
|
||||
* // Use defaults
|
||||
* const responsive = createResponsiveManager();
|
||||
* </script>
|
||||
*
|
||||
* {#if responsive.isMobile}
|
||||
* <MobileNav />
|
||||
* {:else if responsive.isTablet}
|
||||
* <TabletNav />
|
||||
* {:else}
|
||||
* <DesktopNav />
|
||||
* {/if}
|
||||
* // Custom breakpoints
|
||||
* const custom = createResponsiveManager({
|
||||
* mobile: 480,
|
||||
* desktop: 1024
|
||||
* });
|
||||
*
|
||||
* <p>Width: {responsive.width}px</p>
|
||||
* <p>Orientation: {responsive.orientation}</p>
|
||||
* // In component
|
||||
* $: isMobile = responsive.isMobile;
|
||||
* $: cols = responsive.isDesktop ? 3 : 1;
|
||||
* ```
|
||||
*/
|
||||
export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>) {
|
||||
@@ -69,7 +99,7 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
||||
...customBreakpoints,
|
||||
};
|
||||
|
||||
// Reactive state
|
||||
// Reactive viewport dimensions
|
||||
let width = $state(typeof window !== 'undefined' ? window.innerWidth : 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 isTabletOrDesktop = $derived(width >= breakpoints.tabletPortrait);
|
||||
|
||||
// Orientation
|
||||
// Orientation detection
|
||||
const orientation = $derived<Orientation>(height > width ? 'portrait' : 'landscape');
|
||||
const isPortrait = $derived(orientation === 'portrait');
|
||||
const isLandscape = $derived(orientation === 'landscape');
|
||||
|
||||
// Touch device detection (best effort)
|
||||
// Touch device detection (best effort heuristic)
|
||||
const isTouchDevice = $derived(
|
||||
typeof window !== 'undefined'
|
||||
&& ('ontouchstart' in window || navigator.maxTouchPoints > 0),
|
||||
@@ -103,7 +133,11 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
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 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 {
|
||||
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'>(
|
||||
(() => {
|
||||
@@ -158,16 +200,17 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
||||
case isDesktopLarge:
|
||||
return 'desktopLarge';
|
||||
default:
|
||||
return 'xs'; // Fallback for very small screens
|
||||
return 'xs';
|
||||
}
|
||||
})(),
|
||||
);
|
||||
|
||||
return {
|
||||
// Dimensions
|
||||
/** Viewport width in pixels */
|
||||
get width() {
|
||||
return width;
|
||||
},
|
||||
/** Viewport height in pixels */
|
||||
get 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();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
clampNumber,
|
||||
roundToStepPrecision,
|
||||
} from '$shared/lib/utils';
|
||||
|
||||
/**
|
||||
* Core numeric control configuration
|
||||
* Defines the bounds and stepping behavior for a control
|
||||
*/
|
||||
export interface ControlDataModel {
|
||||
/**
|
||||
* Control value
|
||||
*/
|
||||
/** Current numeric value */
|
||||
value: number;
|
||||
/**
|
||||
* Minimal possible value
|
||||
*/
|
||||
/** Minimum allowed value (inclusive) */
|
||||
min: number;
|
||||
/**
|
||||
* Maximal possible value
|
||||
*/
|
||||
/** Maximum allowed value (inclusive) */
|
||||
max: number;
|
||||
/**
|
||||
* Step size for increase/decrease
|
||||
*/
|
||||
/** Step size for increment/decrement operations */
|
||||
step: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full control model including accessibility labels
|
||||
*
|
||||
* @template T - Type for the control identifier
|
||||
*/
|
||||
export interface ControlModel<T extends string = string> extends ControlDataModel {
|
||||
/**
|
||||
* Control identifier
|
||||
*/
|
||||
/** Unique identifier for the control */
|
||||
id: T;
|
||||
/**
|
||||
* Area label for increase button
|
||||
*/
|
||||
/** ARIA label for the increase button */
|
||||
increaseLabel?: string;
|
||||
/**
|
||||
* Area label for decrease button
|
||||
*/
|
||||
/** ARIA label for the decrease button */
|
||||
decreaseLabel?: string;
|
||||
/**
|
||||
* Control area label
|
||||
*/
|
||||
/** ARIA label for the control area */
|
||||
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>(
|
||||
initialState: T,
|
||||
) {
|
||||
@@ -49,12 +101,17 @@ export function createTypographyControl<T extends ControlDataModel>(
|
||||
let min = $state(initialState.min);
|
||||
let step = $state(initialState.step);
|
||||
|
||||
// Derived state for boundary detection
|
||||
const { isAtMax, isAtMin } = $derived({
|
||||
isAtMax: value >= max,
|
||||
isAtMin: value <= min,
|
||||
});
|
||||
|
||||
return {
|
||||
/**
|
||||
* Current control value (getter/setter)
|
||||
* Setting automatically clamps to bounds and rounds to step precision
|
||||
*/
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
@@ -64,27 +121,45 @@ export function createTypographyControl<T extends ControlDataModel>(
|
||||
value = rounded;
|
||||
}
|
||||
},
|
||||
|
||||
/** Maximum allowed value */
|
||||
get max() {
|
||||
return max;
|
||||
},
|
||||
|
||||
/** Minimum allowed value */
|
||||
get min() {
|
||||
return min;
|
||||
},
|
||||
|
||||
/** Step increment size */
|
||||
get step() {
|
||||
return step;
|
||||
},
|
||||
|
||||
/** Whether the value is at or exceeds the maximum */
|
||||
get isAtMax() {
|
||||
return isAtMax;
|
||||
},
|
||||
|
||||
/** Whether the value is at or below the minimum */
|
||||
get isAtMin() {
|
||||
return isAtMin;
|
||||
},
|
||||
|
||||
/**
|
||||
* Increase value by one step (clamped to max)
|
||||
*/
|
||||
increase() {
|
||||
value = roundToStepPrecision(
|
||||
clampNumber(value + step, min, max),
|
||||
step,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Decrease value by one step (clamped to min)
|
||||
*/
|
||||
decrease() {
|
||||
value = roundToStepPrecision(
|
||||
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>;
|
||||
|
||||
@@ -291,7 +291,7 @@ export function createVirtualizer<T>(
|
||||
},
|
||||
};
|
||||
} else {
|
||||
containerHeight = node.offsetHeight;
|
||||
containerHeight = node.clientHeight;
|
||||
|
||||
const handleScroll = () => {
|
||||
scrollOffset = node.scrollTop;
|
||||
|
||||
@@ -56,6 +56,11 @@ function createMockContainer(height = 500, scrollTop = 0): any {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(container, 'clientHeight', {
|
||||
value: height,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(container, 'scrollTop', {
|
||||
value: scrollTop,
|
||||
writable: true,
|
||||
|
||||
@@ -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 {
|
||||
createFilter,
|
||||
type Filter,
|
||||
|
||||
Reference in New Issue
Block a user