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 {
|
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>;
|
||||||
|
|||||||
@@ -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';
|
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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterModel<TValue extends string> {
|
/**
|
||||||
/**
|
* Initial state configuration for a filter
|
||||||
* Properties
|
*
|
||||||
|
* @template TValue - The type of property values
|
||||||
*/
|
*/
|
||||||
|
export interface FilterModel<TValue extends string> {
|
||||||
|
/** Array of filterable 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>;
|
||||||
|
|||||||
@@ -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>>;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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 {
|
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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user