refactor(createTypographyControl): createControlStore rewrote to runes

This commit is contained in:
Ilia Mashkov
2026-01-07 16:53:17 +03:00
parent baff3b9e27
commit 76f27a64b2
10 changed files with 178 additions and 368 deletions

View File

@@ -0,0 +1,22 @@
import {
type ControlModel,
createTypographyControl,
} from '$shared/lib';
export function createTypographyControlManager(configs: ControlModel[]) {
const controls = $state(
configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({
id,
increaseLabel,
decreaseLabel,
controlLabel,
instance: createTypographyControl(config),
})),
);
return {
get controls() {
return controls;
},
};
}

View File

@@ -0,0 +1,56 @@
import {
createTypographyControlManager,
} from '$features/SetupFont/lib/controlManager/controlManager.svelte';
import type { ControlModel } from '$shared/lib';
import {
DEFAULT_FONT_SIZE,
DEFAULT_FONT_WEIGHT,
DEFAULT_LINE_HEIGHT,
FONT_SIZE_STEP,
FONT_WEIGHT_STEP,
LINE_HEIGHT_STEP,
MAX_FONT_SIZE,
MAX_FONT_WEIGHT,
MAX_LINE_HEIGHT,
MIN_FONT_SIZE,
MIN_FONT_WEIGHT,
MIN_LINE_HEIGHT,
} from '../const/const';
const controlData: ControlModel[] = [
{
id: 'font_size',
value: DEFAULT_FONT_SIZE,
max: MAX_FONT_SIZE,
min: MIN_FONT_SIZE,
step: FONT_SIZE_STEP,
increaseLabel: 'Increase Font Size',
decreaseLabel: 'Decrease Font Size',
controlLabel: 'Font Size',
},
{
id: 'font_weight',
value: DEFAULT_FONT_WEIGHT,
max: MAX_FONT_WEIGHT,
min: MIN_FONT_WEIGHT,
step: FONT_WEIGHT_STEP,
increaseLabel: 'Increase Font Weight',
decreaseLabel: 'Decrease Font Weight',
controlLabel: 'Font Weight',
},
{
id: 'line_height',
value: DEFAULT_LINE_HEIGHT,
max: MAX_LINE_HEIGHT,
min: MIN_LINE_HEIGHT,
step: LINE_HEIGHT_STEP,
increaseLabel: 'Increase Line Height',
decreaseLabel: 'Decrease Line Height',
controlLabel: 'Line Height',
},
];
export const controlManager = createTypographyControlManager(controlData);

View File

@@ -1,17 +0,0 @@
import {
type ControlModel,
createControlStore,
} from '$shared/lib/store/createControlStore/createControlStore';
import {
DEFAULT_FONT_SIZE,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from '../const/const';
const initialValue: ControlModel = {
value: DEFAULT_FONT_SIZE,
max: MAX_FONT_SIZE,
min: MIN_FONT_SIZE,
};
export const fontSizeStore = createControlStore(initialValue);

View File

@@ -1,19 +0,0 @@
import {
type ControlModel,
createControlStore,
} from '$shared/lib/store/createControlStore/createControlStore';
import {
DEFAULT_FONT_WEIGHT,
FONT_WEIGHT_STEP,
MAX_FONT_WEIGHT,
MIN_FONT_WEIGHT,
} from '../const/const';
const initialValue: ControlModel = {
value: DEFAULT_FONT_WEIGHT,
max: MAX_FONT_WEIGHT,
min: MIN_FONT_WEIGHT,
step: FONT_WEIGHT_STEP,
};
export const fontWeightStore = createControlStore(initialValue);

View File

@@ -1,19 +0,0 @@
import {
type ControlModel,
createControlStore,
} from '$shared/lib/store/createControlStore/createControlStore';
import {
DEFAULT_LINE_HEIGHT,
LINE_HEIGHT_STEP,
MAX_LINE_HEIGHT,
MIN_LINE_HEIGHT,
} from '../const/const';
const initialValue: ControlModel = {
value: DEFAULT_LINE_HEIGHT,
max: MAX_LINE_HEIGHT,
min: MIN_LINE_HEIGHT,
step: LINE_HEIGHT_STEP,
};
export const lineHeightStore = createControlStore(initialValue);

View File

@@ -1,55 +1,14 @@
<script lang="ts"> <script lang="ts">
import * as Item from '$shared/shadcn/ui/item';
import { Separator } from '$shared/shadcn/ui/separator/index'; import { Separator } from '$shared/shadcn/ui/separator/index';
import * as Sidebar from '$shared/shadcn/ui/sidebar/index'; import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
import ComboControl from '$shared/ui/ComboControl/ComboControl.svelte'; import { ComboControl } from '$shared/ui';
import { fontSizeStore } from '../model/stores/fontSizeStore'; import { controlManager } from '../model/state/manager.svetle';
import { fontWeightStore } from '../model/stores/fontWeightStore';
import { lineHeightStore } from '../model/stores/lineHeightStore';
const fontSize = $derived($fontSizeStore);
const fontWeight = $derived($fontWeightStore);
const lineHeight = $derived($lineHeightStore);
</script> </script>
<div class="w-full p-2 flex flex-row items-center"> <div class="w-full p-2 flex flex-row items-center gap-2">
<Sidebar.Trigger /> <Sidebar.Trigger />
<Separator orientation="vertical" class="h-full" /> <Separator orientation="vertical" class="h-full" />
<ComboControl {#each controlManager.controls as control (control.id)}
value={fontSize.value} <ComboControl control={control.instance} />
minValue={fontSize.min} {/each}
maxValue={fontSize.max}
onChange={fontSizeStore.setValue}
onIncrease={fontSizeStore.increase}
onDecrease={fontSizeStore.decrease}
increaseDisabled={fontSizeStore.isAtMax()}
decreaseDisabled={fontSizeStore.isAtMin()}
increaseLabel="Increase Font Size"
decreaseLabel="Decrease Font Size"
/>
<ComboControl
value={fontWeight.value}
minValue={fontWeight.min}
maxValue={fontWeight.max}
onChange={fontWeightStore.setValue}
onIncrease={fontWeightStore.increase}
onDecrease={fontWeightStore.decrease}
increaseDisabled={fontWeightStore.isAtMax()}
decreaseDisabled={fontWeightStore.isAtMin()}
increaseLabel="Increase Font Weight"
decreaseLabel="Decrease Font Weight"
/>
<ComboControl
value={lineHeight.value}
minValue={lineHeight.min}
maxValue={lineHeight.max}
step={lineHeight.step}
onChange={lineHeightStore.setValue}
onIncrease={lineHeightStore.increase}
onDecrease={lineHeightStore.decrease}
increaseDisabled={lineHeightStore.isAtMax()}
decreaseDisabled={lineHeightStore.isAtMin()}
increaseLabel="Increase Line Height"
decreaseLabel="Decrease Line Height"
/>
</div> </div>

View File

@@ -0,0 +1,73 @@
import {
clampNumber,
roundToStepPrecision,
} from '$shared/lib/utils';
export interface ControlDataModel {
value: number;
min: number;
max: number;
step: number;
}
export interface ControlModel extends ControlDataModel {
id: string;
increaseLabel: string;
decreaseLabel: string;
controlLabel: string;
}
export function createTypographyControl<T extends ControlDataModel>(
initialState: T,
) {
let value = $state(initialState.value);
let max = $state(initialState.max);
let min = $state(initialState.min);
let step = $state(initialState.step);
const { isAtMax, isAtMin } = $derived({
isAtMax: value >= max,
isAtMin: value <= min,
});
return {
get value() {
return value;
},
set value(newValue) {
value = roundToStepPrecision(
clampNumber(newValue, min, max),
step,
);
},
get max() {
return max;
},
get min() {
return min;
},
get step() {
return step;
},
get isAtMax() {
return isAtMax;
},
get isAtMin() {
return isAtMin;
},
increase() {
value = roundToStepPrecision(
clampNumber(value + step, min, max),
step,
);
},
decrease() {
value = roundToStepPrecision(
clampNumber(value - step, min, max),
step,
);
},
};
}
export type TypographyControl = ReturnType<typeof createTypographyControl>;

View File

@@ -1,89 +0,0 @@
import { get } from 'svelte/store';
import {
beforeEach,
describe,
expect,
it,
} from 'vitest';
import {
type ControlModel,
createControlStore,
} from './createControlStore';
describe('createControlStore', () => {
let store: ReturnType<typeof createControlStore<number>>;
beforeEach(() => {
const initialState: ControlModel<number> = {
value: 10,
min: 0,
max: 100,
step: 5,
};
store = createControlStore(initialState);
});
it('initializes with correct state', () => {
expect(get(store)).toEqual({
value: 10,
min: 0,
max: 100,
step: 5,
});
});
it('increases value by step', () => {
store.increase();
expect(get(store).value).toBe(15);
});
it('decreases value by step', () => {
store.decrease();
expect(get(store).value).toBe(5);
});
it('clamps value at maximum', () => {
store.setValue(200);
expect(get(store).value).toBe(100);
});
it('clamps value at minimum', () => {
store.setValue(-10);
expect(get(store).value).toBe(0);
});
it('rounds to step precision', () => {
store.setValue(12.34);
// With step=5, 12.34 is clamped and rounded to nearest integer (0 decimal places)
expect(get(store).value).toBe(12);
});
it('handles decimal steps correctly', () => {
const decimalStore = createControlStore({
value: 1.0,
min: 0,
max: 2,
step: 0.05,
});
decimalStore.increase();
expect(get(decimalStore).value).toBe(1.05);
});
it('isAtMax returns true when at maximum', () => {
store.setValue(100);
expect(store.isAtMax()).toBe(true);
});
it('isAtMax returns false when not at maximum', () => {
expect(store.isAtMax()).toBe(false);
});
it('isAtMin returns true when at minimum', () => {
store.setValue(0);
expect(store.isAtMin()).toBe(true);
});
it('isAtMin returns false when not at minimum', () => {
expect(store.isAtMin()).toBe(false);
});
});

View File

@@ -1,117 +0,0 @@
import {
type Writable,
get,
writable,
} from 'svelte/store';
/**
* Model for a control value with min/max bounds
*/
export type ControlModel<
TValue extends number = number,
> = {
value: TValue;
min: TValue;
max: TValue;
step?: TValue;
};
/**
* Store model with methods for control manipulation
*/
export type ControlStoreModel<
TValue extends number,
> =
& Writable<ControlModel<TValue>>
& {
increase: () => void;
decrease: () => void;
/** Set a specific value */
setValue: (newValue: TValue) => void;
isAtMax: () => boolean;
isAtMin: () => boolean;
};
/**
* Create a writable store for numeric control values with bounds
*
* @template TValue - The value type (extends number)
* @param initialState - Initial state containing value, min, and max
*/
/**
* Get the number of decimal places in a number
*
* For example:
* - 1 -> 0
* - 0.1 -> 1
* - 0.01 -> 2
* - 0.05 -> 2
*
* @param step - The step number to analyze
* @returns The number of decimal places
*/
function getDecimalPlaces(step: number): number {
const str = step.toString();
const decimalPart = str.split('.')[1];
return decimalPart ? decimalPart.length : 0;
}
/**
* Round a value to the precision of the given step
*
* This fixes floating-point precision errors that occur with decimal steps.
* For example, with step=0.05, adding it repeatedly can produce values like
* 1.3499999999999999 instead of 1.35.
*
* We use toFixed() to round to the appropriate decimal places instead of
* Math.round(value / step) * step, which doesn't always work correctly
* due to floating-point arithmetic errors.
*
* @param value - The value to round
* @param step - The step to round to (defaults to 1)
* @returns The rounded value
*/
function roundToStepPrecision(value: number, step: number = 1): number {
if (step <= 0) {
return value;
}
const decimals = getDecimalPlaces(step);
return parseFloat(value.toFixed(decimals));
}
export function createControlStore<
TValue extends number = number,
>(
initialState: ControlModel<TValue>,
): ControlStoreModel<TValue> {
const store = writable(initialState);
const { subscribe, set, update } = store;
const clamp = (value: number): TValue => {
return Math.max(initialState.min, Math.min(value, initialState.max)) as TValue;
};
return {
subscribe,
set,
update,
increase: () =>
update(m => {
const step = m.step ?? 1;
const newValue = clamp(m.value + step);
return { ...m, value: roundToStepPrecision(newValue, step) as TValue };
}),
decrease: () =>
update(m => {
const step = m.step ?? 1;
const newValue = clamp(m.value - step);
return { ...m, value: roundToStepPrecision(newValue, step) as TValue };
}),
setValue: (v: TValue) => {
const step = initialState.step ?? 1;
update(m => ({ ...m, value: roundToStepPrecision(clamp(v), step) as TValue }));
},
isAtMin: () => get(store).value === initialState.min,
isAtMax: () => get(store).value === initialState.max,
};
}

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { TypographyControl } from '$shared/lib';
import { Button } from '$shared/shadcn/ui/button'; import { Button } from '$shared/shadcn/ui/button';
import * as ButtonGroup from '$shared/shadcn/ui/button-group'; import * as ButtonGroup from '$shared/shadcn/ui/button-group';
import { Input } from '$shared/shadcn/ui/input'; import { Input } from '$shared/shadcn/ui/input';
@@ -9,22 +10,6 @@ import PlusIcon from '@lucide/svelte/icons/plus';
import type { ChangeEventHandler } from 'svelte/elements'; import type { ChangeEventHandler } from 'svelte/elements';
interface ComboControlProps { interface ComboControlProps {
/**
* Controlled value
*/
value: number;
/**
* Callback function to handle value change
*/
onChange: (value: number) => void;
/**
* Callback function to handle increase
*/
onIncrease: () => void;
/**
* Callback function to handle decrease
*/
onDecrease: () => void;
/** /**
* Text for increase button aria-label * Text for increase button aria-label
*/ */
@@ -33,59 +18,35 @@ interface ComboControlProps {
* Text for decrease button aria-label * Text for decrease button aria-label
*/ */
decreaseLabel?: string; decreaseLabel?: string;
/**
* Flag for disabling increase button
*/
increaseDisabled?: boolean;
/**
* Flag for disabling decrease button
*/
decreaseDisabled?: boolean;
/** /**
* Text for control button aria-label * Text for control button aria-label
*/ */
controlLabel?: string; controlLabel?: string;
/** /**
* Minimum value for the input * Control instance
*/ */
minValue?: number; control: TypographyControl;
/**
* Maximum value for the input
*/
maxValue?: number;
/**
* Step value for the slider
*/
step?: number;
} }
const { const {
value, control,
onChange,
onIncrease,
onDecrease,
increaseLabel,
decreaseLabel, decreaseLabel,
increaseDisabled, increaseLabel,
decreaseDisabled,
controlLabel, controlLabel,
minValue = 0,
maxValue = 100,
step = 1,
}: ComboControlProps = $props(); }: ComboControlProps = $props();
// Local state for the slider to prevent infinite loops // Local state for the slider to prevent infinite loops
let sliderValue = $state(value); let sliderValue = $state(Number(control.value));
// Sync sliderValue when external value changes // Sync sliderValue when external value changes
$effect(() => { $effect(() => {
sliderValue = value; sliderValue = Number(control.value);
}); });
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => { const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
const parsedValue = parseFloat(event.currentTarget.value); const parsedValue = parseFloat(event.currentTarget.value);
if (!isNaN(parsedValue)) { if (!isNaN(parsedValue)) {
onChange(parsedValue); control.value = parsedValue;
} }
}; };
@@ -93,8 +54,8 @@ const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
* Handle slider value change. * Handle slider value change.
* The Slider component passes the value as a number directly. * The Slider component passes the value as a number directly.
*/ */
const handleSliderChange = (value: number) => { const handleSliderChange = (newValue: number) => {
onChange(value); control.value = newValue;
}; };
</script> </script>
@@ -103,8 +64,8 @@ const handleSliderChange = (value: number) => {
variant="outline" variant="outline"
size="icon" size="icon"
aria-label={decreaseLabel} aria-label={decreaseLabel}
onclick={onDecrease} onclick={control.decrease}
disabled={decreaseDisabled} disabled={control.isAtMin}
> >
<MinusIcon /> <MinusIcon />
</Button> </Button>
@@ -117,16 +78,16 @@ const handleSliderChange = (value: number) => {
size="icon" size="icon"
aria-label={controlLabel} aria-label={controlLabel}
> >
{value} {control.value}
</Button> </Button>
{/snippet} {/snippet}
</Popover.Trigger> </Popover.Trigger>
<Popover.Content class="w-auto p-4"> <Popover.Content class="w-auto p-4">
<div class="flex flex-col items-center gap-3"> <div class="flex flex-col items-center gap-3">
<Slider <Slider
min={minValue} min={control.min}
max={maxValue} max={control.max}
step={step} step={control.step}
value={sliderValue} value={sliderValue}
onValueChange={handleSliderChange} onValueChange={handleSliderChange}
type="single" type="single"
@@ -134,10 +95,10 @@ const handleSliderChange = (value: number) => {
class="h-48" class="h-48"
/> />
<Input <Input
value={String(value)} value={control.value}
min={minValue}
max={maxValue}
onchange={handleInputChange} onchange={handleInputChange}
min={control.min}
max={control.max}
class="w-16 text-center" class="w-16 text-center"
/> />
</div> </div>
@@ -147,8 +108,8 @@ const handleSliderChange = (value: number) => {
variant="outline" variant="outline"
size="icon" size="icon"
aria-label={increaseLabel} aria-label={increaseLabel}
onclick={onIncrease} onclick={control.increase}
disabled={increaseDisabled} disabled={control.isAtMax}
> >
<PlusIcon /> <PlusIcon />
</Button> </Button>