feature/project-redesign #28
@@ -1,3 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Typography control manager
|
||||||
|
*
|
||||||
|
* Manages a collection of typography controls (font size, weight, line height,
|
||||||
|
* letter spacing) with persistent storage. Supports responsive scaling
|
||||||
|
* through a multiplier system.
|
||||||
|
*
|
||||||
|
* The font size control uses a multiplier system to allow responsive scaling
|
||||||
|
* while preserving the user's base size preference. The multiplier is applied
|
||||||
|
* when displaying/editing, but the base size is what's stored.
|
||||||
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ControlDataModel,
|
type ControlDataModel,
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
@@ -17,14 +29,37 @@ import {
|
|||||||
|
|
||||||
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A control with its instance
|
||||||
|
*/
|
||||||
export interface Control extends ControlOnlyFields<ControlId> {
|
export interface Control extends ControlOnlyFields<ControlId> {
|
||||||
instance: TypographyControl;
|
instance: TypographyControl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage schema for typography settings
|
||||||
|
*/
|
||||||
|
export interface TypographySettings {
|
||||||
|
fontSize: number;
|
||||||
|
fontWeight: number;
|
||||||
|
lineHeight: number;
|
||||||
|
letterSpacing: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typography control manager class
|
||||||
|
*
|
||||||
|
* Manages multiple typography controls with persistent storage and
|
||||||
|
* responsive scaling support for font size.
|
||||||
|
*/
|
||||||
export class TypographyControlManager {
|
export class TypographyControlManager {
|
||||||
|
/** Map of controls keyed by ID */
|
||||||
#controls = new SvelteMap<string, Control>();
|
#controls = new SvelteMap<string, Control>();
|
||||||
|
/** Responsive multiplier for font size display */
|
||||||
#multiplier = $state(1);
|
#multiplier = $state(1);
|
||||||
|
/** Persistent storage for settings */
|
||||||
#storage: PersistentStore<TypographySettings>;
|
#storage: PersistentStore<TypographySettings>;
|
||||||
|
/** Base font size (user preference, unscaled) */
|
||||||
#baseSize = $state(DEFAULT_FONT_SIZE);
|
#baseSize = $state(DEFAULT_FONT_SIZE);
|
||||||
|
|
||||||
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
||||||
@@ -85,6 +120,9 @@ export class TypographyControlManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets initial value for a control from storage or defaults
|
||||||
|
*/
|
||||||
#getInitialValue(id: string, saved: TypographySettings): number {
|
#getInitialValue(id: string, saved: TypographySettings): number {
|
||||||
if (id === 'font_size') return saved.fontSize * this.#multiplier;
|
if (id === 'font_size') return saved.fontSize * this.#multiplier;
|
||||||
if (id === 'font_weight') return saved.fontWeight;
|
if (id === 'font_weight') return saved.fontWeight;
|
||||||
@@ -93,11 +131,17 @@ export class TypographyControlManager {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Getters / Setters ---
|
/** Current multiplier for responsive scaling */
|
||||||
|
|
||||||
get multiplier() {
|
get multiplier() {
|
||||||
return this.#multiplier;
|
return this.#multiplier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the multiplier and update font size display
|
||||||
|
*
|
||||||
|
* When multiplier changes, the font size control's display value
|
||||||
|
* is updated to reflect the new scale while preserving base size.
|
||||||
|
*/
|
||||||
set multiplier(value: number) {
|
set multiplier(value: number) {
|
||||||
if (this.#multiplier === value) return;
|
if (this.#multiplier === value) return;
|
||||||
this.#multiplier = value;
|
this.#multiplier = value;
|
||||||
@@ -109,7 +153,10 @@ export class TypographyControlManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The scaled size for CSS usage */
|
/**
|
||||||
|
* The scaled size for CSS usage
|
||||||
|
* Returns baseSize * multiplier for actual rendering
|
||||||
|
*/
|
||||||
get renderedSize() {
|
get renderedSize() {
|
||||||
return this.#baseSize * this.#multiplier;
|
return this.#baseSize * this.#multiplier;
|
||||||
}
|
}
|
||||||
@@ -118,6 +165,7 @@ export class TypographyControlManager {
|
|||||||
get baseSize() {
|
get baseSize() {
|
||||||
return this.#baseSize;
|
return this.#baseSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
set baseSize(val: number) {
|
set baseSize(val: number) {
|
||||||
this.#baseSize = val;
|
this.#baseSize = val;
|
||||||
const ctrl = this.#controls.get('font_size')?.instance;
|
const ctrl = this.#controls.get('font_size')?.instance;
|
||||||
@@ -162,6 +210,9 @@ export class TypographyControlManager {
|
|||||||
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
|
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all controls to default values
|
||||||
|
*/
|
||||||
reset() {
|
reset() {
|
||||||
this.#storage.clear();
|
this.#storage.clear();
|
||||||
const defaults = this.#storage.value;
|
const defaults = this.#storage.value;
|
||||||
@@ -185,21 +236,11 @@ export class TypographyControlManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage schema for typography settings
|
* Creates a typography control manager
|
||||||
*/
|
|
||||||
export interface TypographySettings {
|
|
||||||
fontSize: number;
|
|
||||||
fontWeight: number;
|
|
||||||
lineHeight: number;
|
|
||||||
letterSpacing: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a typography control manager that handles a collection of typography controls.
|
|
||||||
*
|
*
|
||||||
* @param configs - Array of control configurations.
|
* @param configs - Array of control configurations
|
||||||
* @param storageId - Persistent storage identifier.
|
* @param storageId - Persistent storage identifier
|
||||||
* @returns - Typography control manager instance.
|
* @returns Typography control manager instance
|
||||||
*/
|
*/
|
||||||
export function createTypographyControlManager(
|
export function createTypographyControlManager(
|
||||||
configs: ControlModel<ControlId>[],
|
configs: ControlModel<ControlId>[],
|
||||||
|
|||||||
723
src/features/SetupFont/lib/controlManager/controlManager.test.ts
Normal file
723
src/features/SetupFont/lib/controlManager/controlManager.test.ts
Normal file
@@ -0,0 +1,723 @@
|
|||||||
|
/** @vitest-environment jsdom */
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
DEFAULT_FONT_SIZE,
|
||||||
|
DEFAULT_FONT_WEIGHT,
|
||||||
|
DEFAULT_LETTER_SPACING,
|
||||||
|
DEFAULT_LINE_HEIGHT,
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
} from '../../model';
|
||||||
|
import {
|
||||||
|
TypographyControlManager,
|
||||||
|
type TypographySettings,
|
||||||
|
} from './controlManager.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Strategy for TypographyControlManager
|
||||||
|
*
|
||||||
|
* This test suite validates the TypographyControlManager state management logic.
|
||||||
|
* These are unit tests for the manager logic, separate from component rendering.
|
||||||
|
*
|
||||||
|
* NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
|
||||||
|
* after state changes to test reactive behavior. This is a limitation of unit
|
||||||
|
* testing Svelte 5 reactive code in Node.js.
|
||||||
|
*
|
||||||
|
* Test Coverage:
|
||||||
|
* 1. Initialization: Loading from storage, creating controls with correct values
|
||||||
|
* 2. Multiplier System: Changing multiplier updates font size display
|
||||||
|
* 3. Base Size Proxy: UI changes update #baseSize via the proxy effect
|
||||||
|
* 4. Storage Sync: Changes to controls sync to storage (via $effect)
|
||||||
|
* 5. Reset Functionality: Clearing storage resets all controls
|
||||||
|
* 6. Rendered Size: base * multiplier calculation
|
||||||
|
* 7. Control Getters: Return correct control instances
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Helper to flush Svelte effects (they run in microtasks)
|
||||||
|
async function flushEffects() {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TypographyControlManager - Unit Tests', () => {
|
||||||
|
let mockStorage: TypographySettings;
|
||||||
|
let mockPersistentStore: {
|
||||||
|
value: TypographySettings;
|
||||||
|
clear: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMockPersistentStore = (initialValue: TypographySettings) => {
|
||||||
|
let value = initialValue;
|
||||||
|
return {
|
||||||
|
get value() {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
set value(v: TypographySettings) {
|
||||||
|
value = v;
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
value = {
|
||||||
|
fontSize: DEFAULT_FONT_SIZE,
|
||||||
|
fontWeight: DEFAULT_FONT_WEIGHT,
|
||||||
|
lineHeight: DEFAULT_LINE_HEIGHT,
|
||||||
|
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset mock storage with default values before each test
|
||||||
|
mockStorage = {
|
||||||
|
fontSize: DEFAULT_FONT_SIZE,
|
||||||
|
fontWeight: DEFAULT_FONT_WEIGHT,
|
||||||
|
lineHeight: DEFAULT_LINE_HEIGHT,
|
||||||
|
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||||
|
};
|
||||||
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initialization', () => {
|
||||||
|
it('creates manager with default values from storage', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(manager.baseSize).toBe(DEFAULT_FONT_SIZE);
|
||||||
|
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
||||||
|
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
||||||
|
expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates manager with saved values from storage', () => {
|
||||||
|
mockStorage = {
|
||||||
|
fontSize: 72,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.8,
|
||||||
|
letterSpacing: 0.05,
|
||||||
|
};
|
||||||
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(manager.baseSize).toBe(72);
|
||||||
|
expect(manager.weight).toBe(700);
|
||||||
|
expect(manager.height).toBe(1.8);
|
||||||
|
expect(manager.spacing).toBe(0.05);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all controls via controls getter', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
const controls = manager.controls;
|
||||||
|
expect(controls).toHaveLength(4);
|
||||||
|
expect(controls.map(c => c.id)).toEqual([
|
||||||
|
'font_size',
|
||||||
|
'font_weight',
|
||||||
|
'line_height',
|
||||||
|
'letter_spacing',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns individual controls via specific getters', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(manager.sizeControl).toBeDefined();
|
||||||
|
expect(manager.weightControl).toBeDefined();
|
||||||
|
expect(manager.heightControl).toBeDefined();
|
||||||
|
expect(manager.spacingControl).toBeDefined();
|
||||||
|
|
||||||
|
// Control instances have value, min, max, step, isAtMax, isAtMin, increase, decrease
|
||||||
|
expect(manager.sizeControl).toHaveProperty('value');
|
||||||
|
expect(manager.weightControl).toHaveProperty('value');
|
||||||
|
expect(manager.heightControl).toHaveProperty('value');
|
||||||
|
expect(manager.spacingControl).toHaveProperty('value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('control instances have expected interface', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
const ctrl = manager.sizeControl!;
|
||||||
|
expect(typeof ctrl.value).toBe('number');
|
||||||
|
expect(typeof ctrl.min).toBe('number');
|
||||||
|
expect(typeof ctrl.max).toBe('number');
|
||||||
|
expect(typeof ctrl.step).toBe('number');
|
||||||
|
expect(typeof ctrl.isAtMax).toBe('boolean');
|
||||||
|
expect(typeof ctrl.isAtMin).toBe('boolean');
|
||||||
|
expect(typeof ctrl.increase).toBe('function');
|
||||||
|
expect(typeof ctrl.decrease).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Multiplier System', () => {
|
||||||
|
it('has default multiplier of 1', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(manager.multiplier).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates multiplier when set', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.multiplier = 0.75;
|
||||||
|
expect(manager.multiplier).toBe(0.75);
|
||||||
|
|
||||||
|
manager.multiplier = 0.5;
|
||||||
|
expect(manager.multiplier).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not update multiplier if set to same value', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
const originalSizeValue = manager.sizeControl?.value;
|
||||||
|
|
||||||
|
manager.multiplier = 1; // Same as default
|
||||||
|
|
||||||
|
expect(manager.sizeControl?.value).toBe(originalSizeValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates font size control display value when multiplier changes', () => {
|
||||||
|
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
||||||
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial state: base = 48, multiplier = 1, display = 48
|
||||||
|
expect(manager.baseSize).toBe(48);
|
||||||
|
expect(manager.sizeControl?.value).toBe(48);
|
||||||
|
|
||||||
|
// Change multiplier to 0.75
|
||||||
|
manager.multiplier = 0.75;
|
||||||
|
// Display should be 48 * 0.75 = 36
|
||||||
|
expect(manager.sizeControl?.value).toBe(36);
|
||||||
|
|
||||||
|
// Change multiplier to 0.5
|
||||||
|
manager.multiplier = 0.5;
|
||||||
|
// Display should be 48 * 0.5 = 24
|
||||||
|
expect(manager.sizeControl?.value).toBe(24);
|
||||||
|
|
||||||
|
// Base size should remain unchanged
|
||||||
|
expect(manager.baseSize).toBe(48);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates font size control display value when multiplier increases', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start with multiplier 0.5
|
||||||
|
manager.multiplier = 0.5;
|
||||||
|
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.5);
|
||||||
|
|
||||||
|
// Increase to 0.75
|
||||||
|
manager.multiplier = 0.75;
|
||||||
|
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.75);
|
||||||
|
|
||||||
|
// Increase to 1.0
|
||||||
|
manager.multiplier = 1;
|
||||||
|
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Base Size Setter', () => {
|
||||||
|
it('updates baseSize when set directly', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.baseSize = 72;
|
||||||
|
|
||||||
|
expect(manager.baseSize).toBe(72);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates size control value when baseSize is set', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.baseSize = 60;
|
||||||
|
|
||||||
|
expect(manager.sizeControl?.value).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies multiplier to size control when baseSize is set', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.multiplier = 0.5;
|
||||||
|
manager.baseSize = 60;
|
||||||
|
|
||||||
|
expect(manager.sizeControl?.value).toBe(30); // 60 * 0.5
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendered Size Calculation', () => {
|
||||||
|
it('calculates renderedSize as baseSize * multiplier', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(manager.renderedSize).toBe(DEFAULT_FONT_SIZE * 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates renderedSize when multiplier changes', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.multiplier = 0.5;
|
||||||
|
expect(manager.renderedSize).toBe(DEFAULT_FONT_SIZE * 0.5);
|
||||||
|
|
||||||
|
manager.multiplier = 0.75;
|
||||||
|
expect(manager.renderedSize).toBe(DEFAULT_FONT_SIZE * 0.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates renderedSize when baseSize changes', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.baseSize = 72;
|
||||||
|
expect(manager.renderedSize).toBe(72);
|
||||||
|
|
||||||
|
manager.multiplier = 0.5;
|
||||||
|
expect(manager.renderedSize).toBe(36);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Base Size Proxy Effect (UI -> baseSize)', () => {
|
||||||
|
// NOTE: The proxy effect that updates baseSize when the control value changes
|
||||||
|
// runs in a $effect, which is asynchronous in unit tests. We test the
|
||||||
|
// synchronous behavior here (baseSize setter) and note that the full
|
||||||
|
// proxy effect behavior should be tested in E2E tests.
|
||||||
|
|
||||||
|
it('does NOT immediately update baseSize from control change (effect is async)', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
const originalBaseSize = manager.baseSize;
|
||||||
|
|
||||||
|
// Change the control value directly
|
||||||
|
manager.sizeControl!.value = 60;
|
||||||
|
|
||||||
|
// baseSize is NOT updated immediately because the effect runs in microtasks
|
||||||
|
expect(manager.baseSize).toBe(originalBaseSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates baseSize via direct setter (synchronous)', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.baseSize = 60;
|
||||||
|
|
||||||
|
expect(manager.baseSize).toBe(60);
|
||||||
|
expect(manager.sizeControl?.value).toBe(60);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Storage Sync (Controls -> Storage)', () => {
|
||||||
|
// NOTE: Storage sync happens via $effect which runs in microtasks.
|
||||||
|
// In unit tests, we verify the initial sync and test async behavior.
|
||||||
|
|
||||||
|
it('has initial values in storage from constructor', () => {
|
||||||
|
mockStorage = {
|
||||||
|
fontSize: 60,
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
letterSpacing: 0.02,
|
||||||
|
};
|
||||||
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial values are loaded from storage
|
||||||
|
expect(manager.baseSize).toBe(60);
|
||||||
|
expect(manager.weight).toBe(500);
|
||||||
|
expect(manager.height).toBe(1.6);
|
||||||
|
expect(manager.spacing).toBe(0.02);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs to storage after effect flush (async)', async () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.baseSize = 72;
|
||||||
|
|
||||||
|
// Storage is NOT updated immediately
|
||||||
|
expect(mockPersistentStore.value.fontSize).toBe(DEFAULT_FONT_SIZE);
|
||||||
|
|
||||||
|
// After flushing effects, storage should be updated
|
||||||
|
await flushEffects();
|
||||||
|
expect(mockPersistentStore.value.fontSize).toBe(72);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs control changes to storage after effect flush (async)', async () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.weightControl!.value = 700;
|
||||||
|
|
||||||
|
// After flushing effects
|
||||||
|
await flushEffects();
|
||||||
|
expect(mockPersistentStore.value.fontWeight).toBe(700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs height control changes to storage after effect flush (async)', async () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.heightControl!.value = 1.8;
|
||||||
|
|
||||||
|
await flushEffects();
|
||||||
|
expect(mockPersistentStore.value.lineHeight).toBe(1.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs spacing control changes to storage after effect flush (async)', async () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.spacingControl!.value = 0.05;
|
||||||
|
|
||||||
|
await flushEffects();
|
||||||
|
expect(mockPersistentStore.value.letterSpacing).toBe(0.05);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Control Value Getters', () => {
|
||||||
|
it('returns current weight value', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
||||||
|
|
||||||
|
manager.weightControl!.value = 700;
|
||||||
|
expect(manager.weight).toBe(700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns current height value', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
||||||
|
|
||||||
|
manager.heightControl!.value = 1.8;
|
||||||
|
expect(manager.height).toBe(1.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns current spacing value', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING);
|
||||||
|
|
||||||
|
manager.spacingControl!.value = 0.05;
|
||||||
|
expect(manager.spacing).toBe(0.05);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns default value when control is not found', () => {
|
||||||
|
// Create a manager with empty configs (no controls)
|
||||||
|
const manager = new TypographyControlManager([], mockPersistentStore);
|
||||||
|
|
||||||
|
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
||||||
|
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
||||||
|
expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reset Functionality', () => {
|
||||||
|
it('resets all controls to default values', () => {
|
||||||
|
mockStorage = {
|
||||||
|
fontSize: 72,
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.8,
|
||||||
|
letterSpacing: 0.05,
|
||||||
|
};
|
||||||
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Modify values
|
||||||
|
manager.baseSize = 80;
|
||||||
|
manager.weightControl!.value = 900;
|
||||||
|
manager.heightControl!.value = 2.0;
|
||||||
|
manager.spacingControl!.value = 0.1;
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
manager.reset();
|
||||||
|
|
||||||
|
// Check all values are reset to defaults
|
||||||
|
expect(manager.baseSize).toBe(DEFAULT_FONT_SIZE);
|
||||||
|
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
||||||
|
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
||||||
|
expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls storage.clear() on reset', () => {
|
||||||
|
const clearSpy = vi.fn();
|
||||||
|
mockPersistentStore = {
|
||||||
|
get value() {
|
||||||
|
return mockStorage;
|
||||||
|
},
|
||||||
|
set value(v: TypographySettings) {
|
||||||
|
mockStorage = v;
|
||||||
|
},
|
||||||
|
clear: clearSpy,
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.reset();
|
||||||
|
|
||||||
|
expect(clearSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects multiplier when resetting font size control', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.multiplier = 0.5;
|
||||||
|
manager.baseSize = 80;
|
||||||
|
|
||||||
|
manager.reset();
|
||||||
|
|
||||||
|
// Font size control should show default * multiplier
|
||||||
|
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.5);
|
||||||
|
expect(manager.baseSize).toBe(DEFAULT_FONT_SIZE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Complex Scenarios', () => {
|
||||||
|
it('handles changing multiplier then modifying baseSize', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Change multiplier
|
||||||
|
manager.multiplier = 0.5;
|
||||||
|
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.5);
|
||||||
|
|
||||||
|
// Change baseSize
|
||||||
|
manager.baseSize = 60;
|
||||||
|
expect(manager.sizeControl?.value).toBe(30); // 60 * 0.5
|
||||||
|
expect(manager.baseSize).toBe(60);
|
||||||
|
|
||||||
|
// Change multiplier again
|
||||||
|
manager.multiplier = 1;
|
||||||
|
expect(manager.sizeControl?.value).toBe(60); // 60 * 1
|
||||||
|
expect(manager.baseSize).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maintains correct renderedSize throughout changes', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial: 48 * 1 = 48
|
||||||
|
expect(manager.renderedSize).toBe(48);
|
||||||
|
|
||||||
|
// Change baseSize: 60 * 1 = 60
|
||||||
|
manager.baseSize = 60;
|
||||||
|
expect(manager.renderedSize).toBe(60);
|
||||||
|
|
||||||
|
// Change multiplier: 60 * 0.5 = 30
|
||||||
|
manager.multiplier = 0.5;
|
||||||
|
expect(manager.renderedSize).toBe(30);
|
||||||
|
|
||||||
|
// Change baseSize again: 72 * 0.5 = 36
|
||||||
|
manager.baseSize = 72;
|
||||||
|
expect(manager.renderedSize).toBe(36);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple control changes in sequence', async () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Change multiple controls
|
||||||
|
manager.baseSize = 72;
|
||||||
|
manager.weightControl!.value = 700;
|
||||||
|
manager.heightControl!.value = 1.8;
|
||||||
|
manager.spacingControl!.value = 0.05;
|
||||||
|
|
||||||
|
// After flushing effects, verify all are synced to storage
|
||||||
|
await flushEffects();
|
||||||
|
expect(mockPersistentStore.value.fontSize).toBe(72);
|
||||||
|
expect(mockPersistentStore.value.fontWeight).toBe(700);
|
||||||
|
expect(mockPersistentStore.value.lineHeight).toBe(1.8);
|
||||||
|
expect(mockPersistentStore.value.letterSpacing).toBe(0.05);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('handles multiplier of 1 (no change)', () => {
|
||||||
|
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
||||||
|
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||||
|
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.multiplier = 1;
|
||||||
|
|
||||||
|
expect(manager.sizeControl?.value).toBe(48);
|
||||||
|
expect(manager.baseSize).toBe(48);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles very small multiplier', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.baseSize = 100;
|
||||||
|
manager.multiplier = 0.1;
|
||||||
|
|
||||||
|
expect(manager.sizeControl?.value).toBe(10);
|
||||||
|
expect(manager.renderedSize).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles large base size with multiplier', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.baseSize = 100;
|
||||||
|
manager.multiplier = 0.75;
|
||||||
|
|
||||||
|
expect(manager.sizeControl?.value).toBe(75);
|
||||||
|
expect(manager.renderedSize).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles floating point precision in multiplier', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.baseSize = 48;
|
||||||
|
manager.multiplier = 0.5;
|
||||||
|
|
||||||
|
// 48 * 0.5 = 24 (exact, no rounding needed)
|
||||||
|
expect(manager.sizeControl?.value).toBe(24);
|
||||||
|
expect(manager.renderedSize).toBe(24);
|
||||||
|
|
||||||
|
// 48 * 0.33 = 15.84 -> rounds to 16 (step precision is 1)
|
||||||
|
manager.multiplier = 0.33;
|
||||||
|
expect(manager.sizeControl?.value).toBe(16);
|
||||||
|
expect(manager.renderedSize).toBeCloseTo(15.84);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles control methods (increase/decrease)', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialWeight = manager.weight;
|
||||||
|
manager.weightControl!.increase();
|
||||||
|
expect(manager.weight).toBe(initialWeight + 100);
|
||||||
|
|
||||||
|
manager.weightControl!.decrease();
|
||||||
|
expect(manager.weight).toBe(initialWeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles control boundary conditions', () => {
|
||||||
|
const manager = new TypographyControlManager(
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
mockPersistentStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sizeCtrl = manager.sizeControl!;
|
||||||
|
|
||||||
|
// Test min boundary
|
||||||
|
sizeCtrl.value = 5;
|
||||||
|
expect(sizeCtrl.value).toBe(sizeCtrl.min); // Should clamp to MIN_FONT_SIZE (8)
|
||||||
|
|
||||||
|
// Test max boundary
|
||||||
|
sizeCtrl.value = 200;
|
||||||
|
expect(sizeCtrl.value).toBe(sizeCtrl.max); // Should clamp to MAX_FONT_SIZE (100)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,7 +43,7 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
|||||||
|
|
||||||
increaseLabel: 'Increase Font Size',
|
increaseLabel: 'Increase Font Size',
|
||||||
decreaseLabel: 'Decrease Font Size',
|
decreaseLabel: 'Decrease Font Size',
|
||||||
controlLabel: 'Font Size',
|
controlLabel: 'Size',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'font_weight',
|
id: 'font_weight',
|
||||||
@@ -54,7 +54,7 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
|||||||
|
|
||||||
increaseLabel: 'Increase Font Weight',
|
increaseLabel: 'Increase Font Weight',
|
||||||
decreaseLabel: 'Decrease Font Weight',
|
decreaseLabel: 'Decrease Font Weight',
|
||||||
controlLabel: 'Font Weight',
|
controlLabel: 'Weight',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'line_height',
|
id: 'line_height',
|
||||||
@@ -65,7 +65,7 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
|||||||
|
|
||||||
increaseLabel: 'Increase Line Height',
|
increaseLabel: 'Increase Line Height',
|
||||||
decreaseLabel: 'Decrease Line Height',
|
decreaseLabel: 'Decrease Line Height',
|
||||||
controlLabel: 'Line Height',
|
controlLabel: 'Leading',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'letter_spacing',
|
id: 'letter_spacing',
|
||||||
@@ -76,7 +76,7 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
|||||||
|
|
||||||
increaseLabel: 'Increase Letter Spacing',
|
increaseLabel: 'Increase Letter Spacing',
|
||||||
decreaseLabel: 'Decrease Letter Spacing',
|
decreaseLabel: 'Decrease Letter Spacing',
|
||||||
controlLabel: 'Letter Spacing',
|
controlLabel: 'Tracking',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: TypographyMenu
|
|
||||||
Floating controls bar for typography settings.
|
|
||||||
Warm surface, sharp corners, Settings icon header, dividers between units.
|
|
||||||
Mobile: same bar with overflow-x-auto — no drawer.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
|
||||||
import { ComboControl } from '$shared/ui';
|
|
||||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
|
||||||
import { getContext } from 'svelte';
|
|
||||||
import { cubicOut } from 'svelte/easing';
|
|
||||||
import { fly } from 'svelte/transition';
|
|
||||||
import {
|
|
||||||
MULTIPLIER_L,
|
|
||||||
MULTIPLIER_M,
|
|
||||||
MULTIPLIER_S,
|
|
||||||
controlManager,
|
|
||||||
} from '../model';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
class?: string;
|
|
||||||
hidden?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { class: className, hidden = false }: Props = $props();
|
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the common font size multiplier based on the current responsive state.
|
|
||||||
*/
|
|
||||||
$effect(() => {
|
|
||||||
if (!responsive) return;
|
|
||||||
switch (true) {
|
|
||||||
case responsive.isMobile:
|
|
||||||
controlManager.multiplier = MULTIPLIER_S;
|
|
||||||
break;
|
|
||||||
case responsive.isTablet:
|
|
||||||
controlManager.multiplier = MULTIPLIER_M;
|
|
||||||
break;
|
|
||||||
case responsive.isDesktop:
|
|
||||||
controlManager.multiplier = MULTIPLIER_L;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
controlManager.multiplier = MULTIPLIER_L;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{#if !hidden}
|
|
||||||
<div
|
|
||||||
class={cn('w-full md:w-auto', className)}
|
|
||||||
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
|
|
||||||
'bg-[#f3f0e9]/95 dark:bg-[#121212]/95 backdrop-blur-xl',
|
|
||||||
'border border-black/5 dark:border-white/10',
|
|
||||||
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]',
|
|
||||||
'rounded-none ring-1 ring-black/5 dark:ring-white/5',
|
|
||||||
responsive?.isMobile && 'overflow-x-auto',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<!-- Header: icon + label -->
|
|
||||||
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-black/5 dark:border-white/10 mr-1 text-[#1a1a1a] dark:text-[#e5e5e5] shrink-0">
|
|
||||||
<Settings2Icon
|
|
||||||
size={responsive?.isMobile ? 12 : 14}
|
|
||||||
class="text-[#ff3b30]"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="text-[0.5625rem] md:text-[0.625rem] font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
|
|
||||||
>
|
|
||||||
GLOBAL_CONTROLS
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Controls with dividers between each -->
|
|
||||||
{#each controlManager.controls as control, i (control.id)}
|
|
||||||
{#if i > 0}
|
|
||||||
<div class="w-px h-6 md:h-8 bg-black/5 dark:bg-white/10 mx-0.5 md:mx-1 shrink-0"></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<ComboControl
|
|
||||||
control={control.instance}
|
|
||||||
label={control.controlLabel}
|
|
||||||
increaseLabel={control.increaseLabel}
|
|
||||||
decreaseLabel={control.decreaseLabel}
|
|
||||||
controlLabel={control.controlLabel}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
193
src/features/SetupFont/ui/TypographyMenu/TypographyMenu.svelte
Normal file
193
src/features/SetupFont/ui/TypographyMenu/TypographyMenu.svelte
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<!--
|
||||||
|
Component: TypographyMenu
|
||||||
|
Floating controls bar for typography settings.
|
||||||
|
Warm surface, sharp corners, Settings icon header, dividers between units.
|
||||||
|
Mobile: popover with slider controls anchored to settings button.
|
||||||
|
Desktop: inline bar with combo controls.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ComboControl,
|
||||||
|
ControlGroup,
|
||||||
|
Slider,
|
||||||
|
} from '$shared/ui';
|
||||||
|
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||||
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
|
import { Popover } from 'bits-ui';
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
import {
|
||||||
|
MULTIPLIER_L,
|
||||||
|
MULTIPLIER_M,
|
||||||
|
MULTIPLIER_S,
|
||||||
|
controlManager,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* CSS classes
|
||||||
|
*/
|
||||||
|
class?: string;
|
||||||
|
/**
|
||||||
|
* Hidden state
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { class: className, hidden = false }: Props = $props();
|
||||||
|
|
||||||
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
|
let isOpen = $state(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the common font size multiplier based on the current responsive state.
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
if (!responsive) return;
|
||||||
|
switch (true) {
|
||||||
|
case responsive.isMobile:
|
||||||
|
controlManager.multiplier = MULTIPLIER_S;
|
||||||
|
break;
|
||||||
|
case responsive.isTablet:
|
||||||
|
controlManager.multiplier = MULTIPLIER_M;
|
||||||
|
break;
|
||||||
|
case responsive.isDesktop:
|
||||||
|
controlManager.multiplier = MULTIPLIER_L;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
controlManager.multiplier = MULTIPLIER_L;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !hidden}
|
||||||
|
{#if responsive.isMobile}
|
||||||
|
<Popover.Root bind:open={isOpen}>
|
||||||
|
<Popover.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
class={cn(
|
||||||
|
'inline-flex items-center justify-center',
|
||||||
|
'size-8 p-0',
|
||||||
|
'border border-transparent rounded-none',
|
||||||
|
'transition-colors duration-150',
|
||||||
|
'hover:bg-white/50 dark:hover:bg-white/5',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
|
||||||
|
isOpen && 'bg-white dark:bg-[#1e1e1e] border-black/5 dark:border-white/10 shadow-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Settings2Icon class="size-4" />
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Popover.Trigger>
|
||||||
|
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
sideOffset={8}
|
||||||
|
class={cn(
|
||||||
|
'z-50 w-72',
|
||||||
|
'bg-[#f3f0e9] dark:bg-[#1e1e1e]',
|
||||||
|
'border border-black/5 dark:border-white/10',
|
||||||
|
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.15)]',
|
||||||
|
'rounded-none p-4',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
'data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
'data-[side=bottom]:slide-in-from-top-2',
|
||||||
|
)}
|
||||||
|
interactOutsideBehavior="close"
|
||||||
|
escapeKeydownBehavior="close"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-3 pb-3 border-b border-black/5 dark:border-white/10">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<Settings2Icon size={12} class="text-[#ff3b30]" />
|
||||||
|
<span
|
||||||
|
class="text-[0.5625rem] font-mono uppercase tracking-widest font-bold text-[#1a1a1a] dark:text-[#e5e5e5]"
|
||||||
|
>
|
||||||
|
CONTROLS
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Popover.Close>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
class="inline-flex items-center justify-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||||
|
aria-label="Close controls"
|
||||||
|
>
|
||||||
|
<XIcon class="size-3.5 text-neutral-500" />
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</Popover.Close>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
{#each controlManager.controls as control (control.id)}
|
||||||
|
<ControlGroup label={control.controlLabel ?? ''}>
|
||||||
|
<Slider
|
||||||
|
bind:value={control.instance.value}
|
||||||
|
min={control.instance.min}
|
||||||
|
max={control.instance.max}
|
||||||
|
step={control.instance.step}
|
||||||
|
/>
|
||||||
|
</ControlGroup>
|
||||||
|
{/each}
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class={cn('w-full md:w-auto', className)}
|
||||||
|
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
|
||||||
|
'bg-[#f3f0e9]/95 dark:bg-[#121212]/95 backdrop-blur-xl',
|
||||||
|
'border border-black/5 dark:border-white/10',
|
||||||
|
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]',
|
||||||
|
'rounded-none ring-1 ring-black/5 dark:ring-white/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<!-- Header: icon + label -->
|
||||||
|
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-black/5 dark:border-white/10 mr-1 text-[#1a1a1a] dark:text-[#e5e5e5] shrink-0">
|
||||||
|
<Settings2Icon
|
||||||
|
size={14}
|
||||||
|
class="text-[#ff3b30]"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="text-[0.5625rem] md:text-[0.625rem] font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
|
||||||
|
>
|
||||||
|
GLOBAL_CONTROLS
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls with dividers between each -->
|
||||||
|
{#each controlManager.controls as control, i (control.id)}
|
||||||
|
{#if i > 0}
|
||||||
|
<div class="w-px h-6 md:h-8 bg-black/5 dark:bg-white/10 mx-0.5 md:mx-1 shrink-0"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ComboControl
|
||||||
|
control={control.instance}
|
||||||
|
label={control.controlLabel}
|
||||||
|
increaseLabel={control.increaseLabel}
|
||||||
|
decreaseLabel={control.decreaseLabel}
|
||||||
|
controlLabel={control.controlLabel}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -1 +1 @@
|
|||||||
export { default as TypographyMenu } from './TypographyMenu.svelte';
|
export { default as TypographyMenu } from './TypographyMenu/TypographyMenu.svelte';
|
||||||
|
|||||||
Reference in New Issue
Block a user