Compare commits

..

11 Commits

Author SHA1 Message Date
Ilia Mashkov
36a326817d feat: test coverage for utils
Some checks failed
Lint / Lint Code (push) Failing after 7m20s
Test / Svelte Checks (push) Failing after 7m20s
2026-01-07 17:26:59 +03:00
Ilia Mashkov
f4c2a38873 fix: imports path 2026-01-07 16:54:19 +03:00
Ilia Mashkov
614d6b0673 fix: imports path 2026-01-07 16:54:12 +03:00
Ilia Mashkov
f26f56ddef chore: move createVirtualizer 2026-01-07 16:53:44 +03:00
Ilia Mashkov
76f27a64b2 refactor(createTypographyControl): createControlStore rewrote to runes 2026-01-07 16:53:17 +03:00
Ilia Mashkov
baff3b9e27 refactor(createFilter): createFilterStore rewrote to runes 2026-01-07 16:52:17 +03:00
Ilia Mashkov
d15b90cfcb feat: move buildQueryString to separate directory 2026-01-07 16:49:37 +03:00
Ilia Mashkov
893bb02459 feat: move buildQueryString to separate directory 2026-01-07 16:49:18 +03:00
Ilia Mashkov
f7b19bd97f feat: move functions to separate files 2026-01-07 16:48:49 +03:00
Ilia Mashkov
2c4bfaba41 fix: rename file from .ts to .svelte.ts to support svelte runes 2026-01-07 14:27:25 +03:00
Ilia Mashkov
9fd98aca5d refactor(createFilterStore): move from store pattern to svelte 5 runes usage 2026-01-07 14:26:37 +03:00
41 changed files with 1144 additions and 862 deletions

View File

@@ -1,5 +1,11 @@
export { categoryFilterStore } from './model/stores/categoryFilterStore';
export { providersFilterStore } from './model/stores/providersFilterStore';
export { subsetsFilterStore } from './model/stores/subsetsFilterStore';
export { clearAllFilters } from './model/services/clearAllFilters/clearAllFilters';
export {
createFilterManager,
type FilterManager,
} from './lib/filterManager/filterManager.svelte';
export {
FONT_CATEGORIES,
FONT_PROVIDERS,
FONT_SUBSETS,
} from './model/const/const';
export type { FilterGroupConfig } from './model/const/types/common';
export { filterManager } from './model/state/manager.svelte';

View File

@@ -0,0 +1,45 @@
import { createFilter } from '$shared/lib';
import type { FilterGroupConfig } from '../../model/const/types/common';
/**
* Create a filter manager instance.
*/
export function createFilterManager(configs: FilterGroupConfig[]) {
// Create filter instances upfront
const groups = $state(
configs.map(config => ({
id: config.id,
label: config.label,
instance: createFilter({ properties: config.properties }),
})),
);
// Derived: any selection across all groups
const hasAnySelection = $derived(
groups.some(group => group.instance.selectedProperties.length > 0),
);
return {
// Direct array reference (reactive)
get groups() {
return groups;
},
// Derived values
get hasAnySelection() {
return hasAnySelection;
},
// Global action
deselectAllGlobal: () => {
groups.forEach(group => group.instance.deselectAll());
},
// Helper to get group by id
getGroup: (id: string) => {
return groups.find(g => g.id === id);
},
};
}
export type FilterManager = ReturnType<typeof createFilterManager>;

View File

@@ -1,4 +1,4 @@
import type { Property } from '$shared/lib/store/createFilterStore/createFilterStore';
import type { Property } from '$shared/lib/store';
export const FONT_CATEGORIES: Property[] = [
{

View File

@@ -0,0 +1,7 @@
import type { Property } from '$shared/lib';
export interface FilterGroupConfig {
id: string;
label: string;
properties: Property[];
}

View File

@@ -1,9 +0,0 @@
import { categoryFilterStore } from '../../stores/categoryFilterStore';
import { providersFilterStore } from '../../stores/providersFilterStore';
import { subsetsFilterStore } from '../../stores/subsetsFilterStore';
export function clearAllFilters() {
categoryFilterStore.deselectAllProperties();
providersFilterStore.deselectAllProperties();
subsetsFilterStore.deselectAllProperties();
}

View File

@@ -0,0 +1,27 @@
import { createFilterManager } from '../../lib/filterManager/filterManager.svelte';
import {
FONT_CATEGORIES,
FONT_PROVIDERS,
FONT_SUBSETS,
} from '../const/const';
import type { FilterGroupConfig } from '../const/types/common';
const filtersData: FilterGroupConfig[] = [
{
id: 'providers',
label: 'Font provider',
properties: FONT_PROVIDERS,
},
{
id: 'subsets',
label: 'Font subset',
properties: FONT_SUBSETS,
},
{
id: 'categories',
label: 'Font category',
properties: FONT_CATEGORIES,
},
];
export const filterManager = createFilterManager(filtersData);

View File

@@ -1,18 +0,0 @@
import {
type FilterModel,
createFilterStore,
} from '$shared/lib/store/createFilterStore/createFilterStore';
import { FONT_CATEGORIES } from '../const/const';
/**
* Initial state for CategoryFilter
*/
export const initialState: FilterModel = {
searchQuery: '',
properties: FONT_CATEGORIES,
};
/**
* CategoryFilter store
*/
export const categoryFilterStore = createFilterStore(initialState);

View File

@@ -1,18 +0,0 @@
import {
type FilterModel,
createFilterStore,
} from '$shared/lib/store/createFilterStore/createFilterStore';
import { FONT_PROVIDERS } from '../const/const';
/**
* Initial state for ProvidersFilter
*/
export const initialState: FilterModel = {
searchQuery: '',
properties: FONT_PROVIDERS,
};
/**
* ProvidersFilter store
*/
export const providersFilterStore = createFilterStore(initialState);

View File

@@ -1,18 +0,0 @@
import {
type FilterModel,
createFilterStore,
} from '$shared/lib/store/createFilterStore/createFilterStore';
import { FONT_SUBSETS } from '../const/const';
/**
* Initial state for SubsetsFilter
*/
const initialState: FilterModel = {
searchQuery: '',
properties: FONT_SUBSETS,
};
/**
* SubsetsFilter store
*/
export const subsetsFilterStore = createFilterStore(initialState);

View File

@@ -0,0 +1,3 @@
import SetupFontMenu from './ui/SetupFontMenu.svelte';
export { SetupFontMenu };

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">
import * as Item from '$shared/shadcn/ui/item';
import { Separator } from '$shared/shadcn/ui/separator/index';
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
import ComboControl from '$shared/ui/ComboControl/ComboControl.svelte';
import { fontSizeStore } from '../model/stores/fontSizeStore';
import { fontWeightStore } from '../model/stores/fontWeightStore';
import { lineHeightStore } from '../model/stores/lineHeightStore';
const fontSize = $derived($fontSizeStore);
const fontWeight = $derived($fontWeightStore);
const lineHeight = $derived($lineHeightStore);
import { ComboControl } from '$shared/ui';
import { controlManager } from '../model/state/manager.svetle';
</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 />
<Separator orientation="vertical" class="h-full" />
<ComboControl
value={fontSize.value}
minValue={fontSize.min}
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"
/>
{#each controlManager.controls as control (control.id)}
<ComboControl control={control.instance} />
{/each}
</div>

View File

@@ -0,0 +1,111 @@
export interface Property {
/**
* Property identifier
*/
id: string;
/**
* Property name
*/
name: string;
/**
* Property selected state
*/
selected?: boolean;
}
export interface FilterModel {
/**
* Search query
*/
searchQuery?: string;
/**
* Properties
*/
properties: Property[];
}
/**
* Create a filter store.
* @param initialState - Initial state of the filter store
*/
export function createFilter<T extends FilterModel>(
initialState: T,
) {
let properties = $state(
initialState.properties.map(p => ({
...p,
selected: p.selected ?? false,
})),
);
const selectedProperties = $derived(properties.filter(p => p.selected));
const selectedCount = $derived(selectedProperties.length);
return {
/**
* Get all properties.
*/
get properties() {
return properties;
},
/**
* Get selected properties.
*/
get selectedProperties() {
return selectedProperties;
},
/**
* Get selected count.
*/
get selectedCount() {
return selectedCount;
},
/**
* Toggle property selection.
*/
toggleProperty: (id: string) => {
properties = properties.map(p => ({
...p,
selected: p.id === id ? !p.selected : p.selected,
}));
},
/**
* Select property.
*/
selectProperty(id: string) {
properties = properties.map(p => ({
...p,
selected: p.id === id ? true : p.selected,
}));
},
/**
* Deselect property.
*/
deselectProperty(id: string) {
properties = properties.map(p => ({
...p,
selected: p.id === id ? false : p.selected,
}));
},
/**
* Select all properties.
*/
selectAll: () => {
properties = properties.map(p => ({
...p,
selected: true,
}));
},
/**
* Deselect all properties.
*/
deselectAll: () => {
properties = properties.map(p => ({
...p,
selected: false,
}));
},
};
}
export type Filter = ReturnType<typeof createFilter>;

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

@@ -112,3 +112,5 @@ export function createVirtualizer(
measureElement: (el: HTMLElement) => state.measureElement(el),
};
}
export type Virtualizer = ReturnType<typeof createVirtualizer>;

View File

@@ -0,0 +1,20 @@
export {
createFilter,
type Filter,
type FilterModel,
type Property,
} from './createFilter/createFilter.svelte';
export {
type ControlDataModel,
type ControlModel,
createTypographyControl,
type TypographyControl,
} from './createTypographyControl/createTypographyControl.svelte';
export {
createVirtualizer,
type VirtualItem,
type Virtualizer,
type VirtualizerOptions,
} from './createVirtualizer/createVirtualizer.svelte';

14
src/shared/lib/index.ts Normal file
View File

@@ -0,0 +1,14 @@
export {
type ControlDataModel,
type ControlModel,
createFilter,
createTypographyControl,
createVirtualizer,
type Filter,
type FilterModel,
type Property,
type TypographyControl,
type VirtualItem,
type Virtualizer,
type VirtualizerOptions,
} from './helpers';

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,136 +0,0 @@
import { get } from 'svelte/store';
import {
beforeEach,
describe,
expect,
it,
} from 'vitest';
import {
type FilterModel,
type Property,
createFilterStore,
} from './createFilterStore';
describe('createFilterStore', () => {
const mockProperties: Property[] = [
{ id: '1', name: 'Sans-serif', selected: false },
{ id: '2', name: 'Serif', selected: false },
{ id: '3', name: 'Display', selected: false },
];
let store: ReturnType<typeof createFilterStore>;
beforeEach(() => {
const initialState: FilterModel = {
searchQuery: '',
properties: mockProperties,
};
store = createFilterStore(initialState);
});
it('initializes with correct state', () => {
const state = get(store);
expect(state).toEqual({
searchQuery: '',
properties: mockProperties,
});
});
it('sets search query', () => {
store.setSearchQuery('serif');
const state = get(store);
expect(state.searchQuery).toBe('serif');
});
it('clears search query', () => {
store.setSearchQuery('test');
store.clearSearchQuery();
const state = get(store);
expect(state.searchQuery).toBeUndefined();
});
it('selects a property', () => {
store.selectProperty('1');
const state = get(store);
const property = state.properties.find(p => p.id === '1');
expect(property?.selected).toBe(true);
});
it('deselects a property', () => {
store.selectProperty('1');
store.deselectProperty('1');
const state = get(store);
const property = state.properties.find(p => p.id === '1');
expect(property?.selected).toBe(false);
});
it('toggles property from unselected to selected', () => {
store.toggleProperty('1');
const state = get(store);
const property = state.properties.find(p => p.id === '1');
expect(property?.selected).toBe(true);
});
it('toggles property from selected to unselected', () => {
store.selectProperty('1');
store.toggleProperty('1');
const state = get(store);
const property = state.properties.find(p => p.id === '1');
expect(property?.selected).toBe(false);
});
it('selects all properties', () => {
store.selectAllProperties();
const state = get(store);
expect(state.properties.every(p => p.selected)).toBe(true);
});
it('deselects all properties', () => {
store.selectAllProperties();
store.deselectAllProperties();
const state = get(store);
expect(state.properties.every(p => !p.selected)).toBe(true);
});
it('gets all properties', () => {
const allProps = store.getAllProperties();
const props = get(allProps);
expect(props).toEqual(mockProperties);
});
it('gets selected properties', () => {
store.selectProperty('1');
store.selectProperty('3');
const selectedProps = store.getSelectedProperties();
const props = get(selectedProps);
expect(props).toHaveLength(2);
expect(props?.[0].id).toBe('1');
expect(props?.[1].id).toBe('3');
});
it('filters properties by search query', () => {
store.setSearchQuery('serif');
const filteredProps = store.getFilteredProperties();
const props = get(filteredProps);
// 'serif' is a substring of 'Sans-serif' (case-sensitive match)
expect(props).toHaveLength(1);
expect(props?.[0].id).toBe('1');
});
it('filter is case-sensitive', () => {
store.setSearchQuery('San');
const filteredProps = store.getFilteredProperties();
const props = get(filteredProps);
// 'San' matches 'Sans-serif' exactly (case-sensitive)
expect(props).toHaveLength(1);
expect(props?.[0].id).toBe('1');
});
it('filter returns all properties when query is empty', () => {
store.setSearchQuery('');
const filteredProps = store.getFilteredProperties();
let props: Property[] | undefined = undefined;
filteredProps.subscribe(p => (props = p))();
expect(props).toHaveLength(3);
});
});

View File

@@ -1,226 +0,0 @@
import {
type Readable,
type Writable,
derived,
writable,
} from 'svelte/store';
export interface Property {
/**
* Property identifier
*/
id: string;
/**
* Property name
*/
name: string;
/**
* Property selected state
*/
selected?: boolean;
}
export interface FilterModel {
/**
* Search query
*/
searchQuery?: string;
/**
* Properties
*/
properties: Property[];
}
/**
* Model for reusable filter store with search support and property selection
*/
export interface FilterStore<T extends FilterModel> extends Writable<T> {
/**
* Get the store.
* @returns Readable store with filter data
*/
getStore: () => Readable<T>;
/**
* Get all properties.
* @returns Readable store with properties
*/
getAllProperties: () => Readable<Property[]>;
/**
* Get the selected properties.
* @returns Readable store with selected properties
*/
getSelectedProperties: () => Readable<Property[]>;
/**
* Get the filtered properties.
* @returns Readable store with filtered properties
*/
getFilteredProperties: () => Readable<Property[]>;
/**
* Update the search query filter.
*
* @param searchQuery - Search text (undefined to clear)
*/
setSearchQuery: (searchQuery: string | undefined) => void;
/**
* Clear the search query filter.
*/
clearSearchQuery: () => void;
/**
* Select a property.
*
* @param property - Property to select
*/
selectProperty: (propertyId: string) => void;
/**
* Deselect a property.
*
* @param property - Property to deselect
*/
deselectProperty: (propertyId: string) => void;
/**
* Toggle a property.
*
* @param propertyId - Property ID
*/
toggleProperty: (propertyId: string) => void;
/**
* Select all properties.
*/
selectAllProperties: () => void;
/**
* Deselect all properties.
*/
deselectAllProperties: () => void;
}
/**
* Create a filter store.
* @param initialState - Initial state of the filter store
* @returns FilterStore<T>
*/
export function createFilterStore<T extends FilterModel>(
initialState?: T,
): FilterStore<T> {
const { subscribe, set, update } = writable<T>(initialState);
return {
/*
* Expose subscribe, set, and update from Writable.
* This makes FilterStore compatible with Writable interface.
*/
subscribe,
set,
update,
/**
* Get the current state of the filter store.
*/
getStore: () => {
return {
subscribe,
};
},
/**
* Get the filtered properties.
*/
getAllProperties: () => {
return derived({ subscribe }, $store => {
return $store.properties;
});
},
/**
* Get the selected properties.
*/
getSelectedProperties: () => {
return derived({ subscribe }, $store => {
return $store.properties.filter(property => property.selected);
});
},
/**
* Get the filtered properties.
*/
getFilteredProperties: () => {
return derived({ subscribe }, $store => {
return $store.properties.filter(property =>
property.name.includes($store.searchQuery || '')
);
});
},
/**
* Update the search query filter.
*
* @param searchQuery - Search text (undefined to clear)
*/
setSearchQuery: (searchQuery: string | undefined) => {
update(state => ({
...state,
searchQuery: searchQuery || undefined,
}));
},
/**
* Clear the search query filter.
*/
clearSearchQuery: () => {
update(state => ({
...state,
searchQuery: undefined,
}));
},
/**
* Select a property.
*
* @param propertyId - Property ID
*/
selectProperty: (propertyId: string) => {
update(state => ({
...state,
properties: state.properties.map(c =>
c.id === propertyId ? { ...c, selected: true } : c
),
}));
},
/**
* Deselect a property.
*
* @param propertyId - Property ID
*/
deselectProperty: (propertyId: string) => {
update(state => ({
...state,
properties: state.properties.map(c =>
c.id === propertyId ? { ...c, selected: false } : c
),
}));
},
/**
* Toggle a property.
*
* @param propertyId - Property ID
*/
toggleProperty: (propertyId: string) => {
update(state => ({
...state,
properties: state.properties.map(c =>
c.id === propertyId ? { ...c, selected: !c.selected } : c
),
}));
},
/**
* Select all properties
*/
selectAllProperties: () => {
update(state => ({
...state,
properties: state.properties.map(c => ({ ...c, selected: true })),
}));
},
/**
* Deselect all properties
*/
deselectAllProperties: () => {
update(state => ({
...state,
properties: state.properties.map(c => ({ ...c, selected: false })),
}));
},
};
}

View File

@@ -1,18 +0,0 @@
/**
* Shared store exports
*
* Exports all store creators and types for Svelte 5 reactive state management
*/
export { createFilterStore } from './createFilterStore/createFilterStore';
export type {
FilterModel,
FilterStore,
Property,
} from './createFilterStore/createFilterStore';
export { createControlStore } from './createControlStore/createControlStore';
export type {
ControlModel,
ControlStoreModel,
} from './createControlStore/createControlStore';

View File

@@ -0,0 +1,176 @@
/**
* Tests for clampNumber utility
*/
import {
describe,
expect,
test,
} from 'vitest';
import { clampNumber } from './clampNumber';
describe('clampNumber', () => {
describe('basic functionality', () => {
test('should return value when within range', () => {
expect(clampNumber(5, 0, 10)).toBe(5);
expect(clampNumber(0.5, 0, 1)).toBe(0.5);
expect(clampNumber(-3, -10, 10)).toBe(-3);
});
test('should clamp value to minimum', () => {
expect(clampNumber(-5, 0, 10)).toBe(0);
expect(clampNumber(-100, -50, 100)).toBe(-50);
expect(clampNumber(0, 1, 10)).toBe(1);
});
test('should clamp value to maximum', () => {
expect(clampNumber(15, 0, 10)).toBe(10);
expect(clampNumber(150, -50, 100)).toBe(100);
expect(clampNumber(100, 1, 50)).toBe(50);
});
test('should handle boundary values', () => {
expect(clampNumber(0, 0, 10)).toBe(0);
expect(clampNumber(10, 0, 10)).toBe(10);
expect(clampNumber(-5, -5, 5)).toBe(-5);
expect(clampNumber(5, -5, 5)).toBe(5);
});
});
describe('negative ranges', () => {
test('should handle fully negative ranges', () => {
expect(clampNumber(-5, -10, -1)).toBe(-5);
expect(clampNumber(-15, -10, -1)).toBe(-10);
expect(clampNumber(-0.5, -10, -1)).toBe(-1);
});
test('should handle ranges spanning zero', () => {
expect(clampNumber(0, -10, 10)).toBe(0);
expect(clampNumber(-5, -10, 10)).toBe(-5);
expect(clampNumber(5, -10, 10)).toBe(5);
});
});
describe('floating-point numbers', () => {
test('should clamp floating-point values correctly', () => {
expect(clampNumber(0.75, 0, 1)).toBe(0.75);
expect(clampNumber(1.5, 0, 1)).toBe(1);
expect(clampNumber(-0.25, 0, 1)).toBe(0);
});
test('should handle very small decimals', () => {
expect(clampNumber(0.001, 0, 0.01)).toBe(0.001);
expect(clampNumber(0.1, 0, 0.01)).toBe(0.01);
});
test('should handle large floating-point numbers', () => {
expect(clampNumber(123.456, 100, 200)).toBe(123.456);
expect(clampNumber(99.999, 100, 200)).toBe(100);
expect(clampNumber(200.001, 100, 200)).toBe(200);
});
});
describe('edge cases', () => {
test('should handle when min equals max', () => {
expect(clampNumber(5, 10, 10)).toBe(10);
expect(clampNumber(10, 10, 10)).toBe(10);
expect(clampNumber(15, 10, 10)).toBe(10);
expect(clampNumber(0, 0, 0)).toBe(0);
});
test('should handle zero values', () => {
expect(clampNumber(0, 0, 10)).toBe(0);
expect(clampNumber(0, -10, 10)).toBe(0);
expect(clampNumber(5, 0, 0)).toBe(0);
});
test('should handle reversed min/max (min > max)', () => {
// When min > max, Math.max/Math.min will still produce a result
// but it's logically incorrect - we test the actual behavior
// Math.min(Math.max(5, 10), 0) = Math.min(10, 0) = 0
expect(clampNumber(5, 10, 0)).toBe(0);
expect(clampNumber(15, 10, 0)).toBe(0);
expect(clampNumber(-5, 10, 0)).toBe(0);
});
});
describe('special number values', () => {
test('should handle Infinity', () => {
expect(clampNumber(Infinity, 0, 10)).toBe(10);
expect(clampNumber(-Infinity, 0, 10)).toBe(0);
expect(clampNumber(5, -Infinity, Infinity)).toBe(5);
});
test('should handle NaN', () => {
expect(clampNumber(NaN, 0, 10)).toBeNaN();
});
});
describe('real-world scenarios', () => {
test('should clamp font size values', () => {
// Typical font size range: 8px to 72px
expect(clampNumber(16, 8, 72)).toBe(16);
expect(clampNumber(4, 8, 72)).toBe(8);
expect(clampNumber(100, 8, 72)).toBe(72);
});
test('should clamp slider values', () => {
// Slider range: 0 to 100
expect(clampNumber(50, 0, 100)).toBe(50);
expect(clampNumber(-10, 0, 100)).toBe(0);
expect(clampNumber(150, 0, 100)).toBe(100);
});
test('should clamp opacity values', () => {
// Opacity range: 0 to 1
expect(clampNumber(0.5, 0, 1)).toBe(0.5);
expect(clampNumber(-0.2, 0, 1)).toBe(0);
expect(clampNumber(1.2, 0, 1)).toBe(1);
});
test('should clamp percentage values', () => {
// Percentage range: 0 to 100
expect(clampNumber(75, 0, 100)).toBe(75);
expect(clampNumber(-5, 0, 100)).toBe(0);
expect(clampNumber(105, 0, 100)).toBe(100);
});
test('should clamp coordinate values', () => {
// Canvas coordinates: 0 to 800 width, 0 to 600 height
expect(clampNumber(400, 0, 800)).toBe(400);
expect(clampNumber(-50, 0, 800)).toBe(0);
expect(clampNumber(900, 0, 800)).toBe(800);
});
test('should clamp font weight values', () => {
// Font weight range: 100 to 900 (in increments of 100)
expect(clampNumber(400, 100, 900)).toBe(400);
expect(clampNumber(50, 100, 900)).toBe(100);
expect(clampNumber(950, 100, 900)).toBe(900);
});
test('should clamp line height values', () => {
// Line height range: 0.5 to 3.0
expect(clampNumber(1.5, 0.5, 3.0)).toBe(1.5);
expect(clampNumber(0.3, 0.5, 3.0)).toBe(0.5);
expect(clampNumber(4.0, 0.5, 3.0)).toBe(3.0);
});
});
describe('numeric constraints', () => {
test('should handle very large numbers', () => {
expect(clampNumber(Number.MAX_VALUE, 0, 100)).toBe(100);
expect(clampNumber(Number.MIN_VALUE, -10, 10)).toBe(Number.MIN_VALUE);
});
test('should handle negative infinity boundaries', () => {
expect(clampNumber(5, -Infinity, 10)).toBe(5);
expect(clampNumber(-1000, -Infinity, 10)).toBe(-1000);
});
test('should handle positive infinity boundaries', () => {
expect(clampNumber(5, 0, Infinity)).toBe(5);
expect(clampNumber(1000, 0, Infinity)).toBe(1000);
});
});
});

View File

@@ -0,0 +1,10 @@
/**
* Clamp a number within a range.
* @param value The number to clamp.
* @param min minimum value
* @param max maximum value
* @returns The clamped number.
*/
export function clampNumber(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}

View File

@@ -0,0 +1,188 @@
/**
* Tests for getDecimalPlaces utility
*/
import {
describe,
expect,
test,
} from 'vitest';
import { getDecimalPlaces } from './getDecimalPlaces';
describe('getDecimalPlaces', () => {
describe('basic functionality', () => {
test('should return 0 for integers', () => {
expect(getDecimalPlaces(0)).toBe(0);
expect(getDecimalPlaces(1)).toBe(0);
expect(getDecimalPlaces(42)).toBe(0);
expect(getDecimalPlaces(-7)).toBe(0);
expect(getDecimalPlaces(1000)).toBe(0);
});
test('should return correct decimal places for decimals', () => {
expect(getDecimalPlaces(0.1)).toBe(1);
expect(getDecimalPlaces(0.5)).toBe(1);
expect(getDecimalPlaces(0.01)).toBe(2);
expect(getDecimalPlaces(0.05)).toBe(2);
expect(getDecimalPlaces(0.001)).toBe(3);
expect(getDecimalPlaces(0.123)).toBe(3);
expect(getDecimalPlaces(0.123456)).toBe(6);
});
test('should handle negative decimal numbers', () => {
expect(getDecimalPlaces(-0.1)).toBe(1);
expect(getDecimalPlaces(-0.05)).toBe(2);
expect(getDecimalPlaces(-1.5)).toBe(1);
expect(getDecimalPlaces(-99.99)).toBe(2);
});
});
describe('whole numbers with decimal part', () => {
test('should handle numbers with integer and decimal parts', () => {
expect(getDecimalPlaces(1.5)).toBe(1);
expect(getDecimalPlaces(10.25)).toBe(2);
expect(getDecimalPlaces(100.125)).toBe(3);
expect(getDecimalPlaces(1234.5678)).toBe(4);
});
test('should handle trailing zeros correctly', () => {
// Note: JavaScript string representation drops trailing zeros
expect(getDecimalPlaces(1.5)).toBe(1);
expect(getDecimalPlaces(1.50)).toBe(1); // 1.50 becomes "1.5" in string
});
});
describe('edge cases', () => {
test('should handle zero', () => {
expect(getDecimalPlaces(0)).toBe(0);
expect(getDecimalPlaces(0.0)).toBe(0);
});
test('should handle very small decimals', () => {
expect(getDecimalPlaces(0.0001)).toBe(4);
expect(getDecimalPlaces(0.00001)).toBe(5);
expect(getDecimalPlaces(0.000001)).toBe(6);
});
test('should handle very large numbers', () => {
expect(getDecimalPlaces(123456789.123)).toBe(3);
expect(getDecimalPlaces(999999.9999)).toBe(4);
});
test('should handle negative whole numbers', () => {
expect(getDecimalPlaces(-1)).toBe(0);
expect(getDecimalPlaces(-100)).toBe(0);
expect(getDecimalPlaces(-9999)).toBe(0);
});
});
describe('special number values', () => {
test('should handle Infinity', () => {
expect(getDecimalPlaces(Infinity)).toBe(0);
expect(getDecimalPlaces(-Infinity)).toBe(0);
});
test('should handle NaN', () => {
expect(getDecimalPlaces(NaN)).toBe(0);
});
});
describe('scientific notation', () => {
test('should handle numbers in scientific notation', () => {
// Very small numbers may be represented in scientific notation
const tiny = 1e-10;
const result = getDecimalPlaces(tiny);
// The result depends on how JS represents this as a string
expect(typeof result).toBe('number');
});
test('should handle large scientific notation numbers', () => {
const large = 1.23e5; // 123000
expect(getDecimalPlaces(large)).toBe(0);
});
});
describe('real-world scenarios', () => {
test('should handle currency values (2 decimal places)', () => {
expect(getDecimalPlaces(0.01)).toBe(2); // 1 cent
expect(getDecimalPlaces(0.99)).toBe(2); // 99 cents
// Note: JavaScript string representation drops trailing zeros
// 10.50 becomes "10.5" in string, so returns 1 decimal place
expect(getDecimalPlaces(10.50)).toBe(1); // $10.50
expect(getDecimalPlaces(999.99)).toBe(2); // $999.99
});
test('should handle measurement values', () => {
expect(getDecimalPlaces(12.5)).toBe(1); // 12.5 mm
expect(getDecimalPlaces(12.34)).toBe(2); // 12.34 cm
expect(getDecimalPlaces(12.345)).toBe(3); // 12.345 m
});
test('should handle step values for sliders', () => {
expect(getDecimalPlaces(0.1)).toBe(1); // Fine adjustment
expect(getDecimalPlaces(0.25)).toBe(2); // Quarter steps
expect(getDecimalPlaces(0.5)).toBe(1); // Half steps
expect(getDecimalPlaces(1)).toBe(0); // Whole steps
});
test('should handle font size increments', () => {
expect(getDecimalPlaces(0.5)).toBe(1); // Half point increments
expect(getDecimalPlaces(1)).toBe(0); // Whole point increments
});
test('should handle opacity values', () => {
expect(getDecimalPlaces(0.1)).toBe(1); // 10% increments
expect(getDecimalPlaces(0.05)).toBe(2); // 5% increments
expect(getDecimalPlaces(0.01)).toBe(2); // 1% increments
});
test('should handle percentage values', () => {
expect(getDecimalPlaces(0.5)).toBe(1); // 0.5%
expect(getDecimalPlaces(12.5)).toBe(1); // 12.5%
expect(getDecimalPlaces(33.33)).toBe(2); // 33.33%
});
test('should handle coordinate precision', () => {
expect(getDecimalPlaces(12.3456789)).toBe(7); // High precision GPS
expect(getDecimalPlaces(100.5)).toBe(1); // Low precision coordinates
});
test('should handle time values', () => {
expect(getDecimalPlaces(0.1)).toBe(1); // 100ms
expect(getDecimalPlaces(0.01)).toBe(2); // 10ms
expect(getDecimalPlaces(0.001)).toBe(3); // 1ms
});
});
describe('common step values', () => {
test('should correctly identify precision of common step values', () => {
expect(getDecimalPlaces(0.05)).toBe(2); // Very fine steps
expect(getDecimalPlaces(0.1)).toBe(1); // Fine steps
expect(getDecimalPlaces(0.25)).toBe(2); // Quarter steps
expect(getDecimalPlaces(0.5)).toBe(1); // Half steps
expect(getDecimalPlaces(1)).toBe(0); // Whole steps
expect(getDecimalPlaces(2)).toBe(0); // Even steps
expect(getDecimalPlaces(5)).toBe(0); // Five steps
expect(getDecimalPlaces(10)).toBe(0); // Ten steps
expect(getDecimalPlaces(25)).toBe(0); // Twenty-five steps
expect(getDecimalPlaces(50)).toBe(0); // Fifty steps
expect(getDecimalPlaces(100)).toBe(0); // Hundred steps
});
});
describe('floating-point representation', () => {
test('should handle standard floating-point representation', () => {
expect(getDecimalPlaces(1.1)).toBe(1);
expect(getDecimalPlaces(1.2)).toBe(1);
expect(getDecimalPlaces(1.3)).toBe(1);
});
test('should handle numbers that might have floating-point issues', () => {
// 0.1 + 0.2 = 0.30000000000000004 in JS
const sum = 0.1 + 0.2;
const places = getDecimalPlaces(sum);
// The function analyzes the string representation
expect(typeof places).toBe('number');
});
});
});

View File

@@ -0,0 +1,17 @@
/**
* 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
*/
export function getDecimalPlaces(step: number): number {
const str = step.toString();
const decimalPart = str.split('.')[1];
return decimalPart ? decimalPart.length : 0;
}

View File

@@ -2,9 +2,11 @@
* Shared utility functions
*/
export { buildQueryString } from './buildQueryString';
export type {
QueryParams,
QueryParamValue,
} from './buildQueryString';
export { createVirtualizer } from './createVirtualizer/createVirtualizer';
export {
buildQueryString,
type QueryParams,
type QueryParamValue,
} from './buildQueryString/buildQueryString';
export { clampNumber } from './clampNumber/clampNumber';
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';

View File

@@ -0,0 +1,270 @@
/**
* Tests for roundToStepPrecision utility
*/
import {
describe,
expect,
test,
} from 'vitest';
import { roundToStepPrecision } from './roundToStepPrecision';
describe('roundToStepPrecision', () => {
describe('basic functionality', () => {
test('should return value unchanged for step=1', () => {
// step=1 has 0 decimal places, so it rounds to integers
expect(roundToStepPrecision(5, 1)).toBe(5);
expect(roundToStepPrecision(5.5, 1)).toBe(6); // rounds to nearest integer
expect(roundToStepPrecision(5.999, 1)).toBe(6);
});
test('should round to 1 decimal place for step=0.1', () => {
expect(roundToStepPrecision(1.23, 0.1)).toBeCloseTo(1.2);
expect(roundToStepPrecision(1.25, 0.1)).toBeCloseTo(1.3);
expect(roundToStepPrecision(1.29, 0.1)).toBeCloseTo(1.3);
});
test('should round to 2 decimal places for step=0.01', () => {
expect(roundToStepPrecision(1.234, 0.01)).toBeCloseTo(1.23);
expect(roundToStepPrecision(1.235, 0.01)).toBeCloseTo(1.24);
expect(roundToStepPrecision(1.239, 0.01)).toBeCloseTo(1.24);
});
test('should round to 3 decimal places for step=0.001', () => {
expect(roundToStepPrecision(1.2345, 0.001)).toBeCloseTo(1.235);
expect(roundToStepPrecision(1.2344, 0.001)).toBeCloseTo(1.234);
});
});
describe('floating-point precision issues', () => {
test('should fix floating-point precision errors with step=0.05', () => {
// Known floating-point issue: 0.1 + 0.05 = 0.15000000000000002
const value = 0.1 + 0.05;
const result = roundToStepPrecision(value, 0.05);
expect(result).toBeCloseTo(0.15, 2);
});
test('should fix floating-point errors with repeated additions', () => {
// Simulate adding 0.05 multiple times
let value = 1;
for (let i = 0; i < 10; i++) {
value += 0.05;
}
// value should be 1.5 but might be 1.4999999999999998
const result = roundToStepPrecision(value, 0.05);
expect(result).toBeCloseTo(1.5, 2);
});
test('should fix floating-point errors with step=0.1', () => {
// Known floating-point issue: 0.1 + 0.2 = 0.30000000000000004
const value = 0.1 + 0.2;
const result = roundToStepPrecision(value, 0.1);
expect(result).toBeCloseTo(0.3, 1);
});
test('should fix floating-point errors with step=0.01', () => {
// Known floating-point issue: 0.01 + 0.02 = 0.029999999999999999
const value = 0.01 + 0.02;
const result = roundToStepPrecision(value, 0.01);
expect(result).toBeCloseTo(0.03, 2);
});
test('should fix floating-point errors with step=0.25', () => {
const value = 0.5 + 0.25;
const result = roundToStepPrecision(value, 0.25);
expect(result).toBeCloseTo(0.75, 2);
});
test('should handle classic 0.1 + 0.2 problem', () => {
// Classic JavaScript floating-point issue
const value = 0.1 + 0.2;
// Without rounding: 0.30000000000000004
const result = roundToStepPrecision(value, 0.1);
expect(result).toBe(0.3);
});
});
describe('edge cases', () => {
test('should return value unchanged when step <= 0', () => {
expect(roundToStepPrecision(5, 0)).toBe(5);
expect(roundToStepPrecision(5, -1)).toBe(5);
expect(roundToStepPrecision(5, -0.5)).toBe(5);
});
test('should handle zero value', () => {
expect(roundToStepPrecision(0, 0.1)).toBe(0);
expect(roundToStepPrecision(0, 0.01)).toBe(0);
});
test('should handle negative values', () => {
expect(roundToStepPrecision(-1.234, 0.01)).toBeCloseTo(-1.23);
expect(roundToStepPrecision(-0.15, 0.05)).toBeCloseTo(-0.15);
expect(roundToStepPrecision(-5.5, 0.5)).toBeCloseTo(-5.5);
});
test('should handle very small step values', () => {
expect(roundToStepPrecision(1.1234, 0.0001)).toBeCloseTo(1.1234);
expect(roundToStepPrecision(1.12345, 0.0001)).toBeCloseTo(1.1235);
});
test('should handle very large values', () => {
expect(roundToStepPrecision(12345.6789, 0.01)).toBeCloseTo(12345.68);
expect(roundToStepPrecision(99999.9999, 0.001)).toBeCloseTo(100000);
});
});
describe('special number values', () => {
test('should handle Infinity', () => {
expect(roundToStepPrecision(Infinity, 0.1)).toBe(Infinity);
expect(roundToStepPrecision(-Infinity, 0.1)).toBe(-Infinity);
});
test('should handle NaN', () => {
expect(roundToStepPrecision(NaN, 0.1)).toBeNaN();
});
test('should handle step=Infinity', () => {
// getDecimalPlaces(Infinity) returns 0, so this rounds to 0 decimal places (integer)
const result = roundToStepPrecision(1.234, Infinity);
expect(result).toBeCloseTo(1);
});
});
describe('real-world scenarios', () => {
test('should handle currency calculations with step=0.01', () => {
// Add items with tax that might have floating-point errors
const subtotal = 10.99 + 5.99 + 2.99;
const rounded = roundToStepPrecision(subtotal, 0.01);
expect(rounded).toBeCloseTo(19.97, 2);
});
test('should handle slider values with step=0.1', () => {
// Slider value after multiple increments
let sliderValue = 0;
for (let i = 0; i < 15; i++) {
sliderValue += 0.1;
}
const rounded = roundToStepPrecision(sliderValue, 0.1);
expect(rounded).toBeCloseTo(1.5, 1);
});
test('should handle font size adjustments with step=0.5', () => {
// Font size adjustments
let fontSize = 12;
fontSize += 0.5; // 12.5
fontSize += 0.5; // 13.0
const rounded = roundToStepPrecision(fontSize, 0.5);
expect(rounded).toBeCloseTo(13, 1);
});
test('should handle opacity values with step=0.05', () => {
// Opacity from 0 to 1 in 5% increments
let opacity = 0;
for (let i = 0; i < 10; i++) {
opacity += 0.05;
}
const rounded = roundToStepPrecision(opacity, 0.05);
expect(rounded).toBeCloseTo(0.5, 2);
});
test('should handle percentage calculations with step=0.01', () => {
// Calculate percentage with floating-point issues
const percentage = (1 / 3) * 100;
const rounded = roundToStepPrecision(percentage, 0.01);
expect(rounded).toBeCloseTo(33.33, 2);
});
test('should handle coordinate rounding with step=0.000001', () => {
// GPS coordinates with micro-degree precision
const lat = 40.7128 + 0.000001;
const rounded = roundToStepPrecision(lat, 0.000001);
expect(rounded).toBeCloseTo(40.712801, 6);
});
test('should handle time values with step=0.001', () => {
// Millisecond precision timing
const time = 123.456 + 0.001 + 0.001;
const rounded = roundToStepPrecision(time, 0.001);
expect(rounded).toBeCloseTo(123.458, 3);
});
});
describe('common step values', () => {
test('should correctly round for step=0.05', () => {
// step=0.05 has 2 decimal places, so it rounds to 2 decimal places
// Note: This rounds to the DECIMAL PRECISION, not to the step increment
expect(roundToStepPrecision(1.34, 0.05)).toBeCloseTo(1.34);
expect(roundToStepPrecision(1.36, 0.05)).toBeCloseTo(1.36);
expect(roundToStepPrecision(1.37, 0.05)).toBeCloseTo(1.37);
expect(roundToStepPrecision(1.38, 0.05)).toBeCloseTo(1.38);
});
test('should correctly round for step=0.25', () => {
// step=0.25 has 2 decimal places, so it rounds to 2 decimal places
// Note: This rounds to the DECIMAL PRECISION, not to the step increment
expect(roundToStepPrecision(1.24, 0.25)).toBeCloseTo(1.24);
expect(roundToStepPrecision(1.26, 0.25)).toBeCloseTo(1.26);
expect(roundToStepPrecision(1.37, 0.25)).toBeCloseTo(1.37);
expect(roundToStepPrecision(1.38, 0.25)).toBeCloseTo(1.38);
});
test('should correctly round for step=0.1', () => {
// step=0.1 has 1 decimal place, so it rounds to 1 decimal place
expect(roundToStepPrecision(1.04, 0.1)).toBeCloseTo(1.0);
expect(roundToStepPrecision(1.05, 0.1)).toBeCloseTo(1.1);
expect(roundToStepPrecision(1.14, 0.1)).toBeCloseTo(1.1);
expect(roundToStepPrecision(1.15, 0.1)).toBeCloseTo(1.1); // standard banker's rounding
});
test('should correctly round for step=0.01', () => {
expect(roundToStepPrecision(1.234, 0.01)).toBeCloseTo(1.23);
expect(roundToStepPrecision(1.235, 0.01)).toBeCloseTo(1.24);
expect(roundToStepPrecision(1.236, 0.01)).toBeCloseTo(1.24);
});
});
describe('integration with getDecimalPlaces', () => {
test('should use correct decimal places from step parameter', () => {
// step=0.1 has 1 decimal place
expect(roundToStepPrecision(1.234, 0.1)).toBeCloseTo(1.2);
// step=0.01 has 2 decimal places
expect(roundToStepPrecision(1.234, 0.01)).toBeCloseTo(1.23);
// step=0.001 has 3 decimal places
expect(roundToStepPrecision(1.2345, 0.001)).toBeCloseTo(1.235);
});
test('should handle steps with different precisions correctly', () => {
const value = 1.123456789;
expect(roundToStepPrecision(value, 0.1)).toBeCloseTo(1.1);
expect(roundToStepPrecision(value, 0.01)).toBeCloseTo(1.12);
expect(roundToStepPrecision(value, 0.001)).toBeCloseTo(1.123);
expect(roundToStepPrecision(value, 0.0001)).toBeCloseTo(1.1235);
});
});
describe('return type behavior', () => {
test('should return finite number for valid inputs', () => {
expect(Number.isFinite(roundToStepPrecision(1.23, 0.01))).toBe(true);
});
});
describe('precision edge cases', () => {
test('should round 0.9999 correctly with step=0.01', () => {
expect(roundToStepPrecision(0.9999, 0.01)).toBeCloseTo(1);
});
test('should round 0.99999 correctly with step=0.001', () => {
expect(roundToStepPrecision(0.99999, 0.001)).toBeCloseTo(1);
});
test('should handle rounding up to next integer', () => {
expect(roundToStepPrecision(0.999, 0.001)).toBeCloseTo(0.999);
});
test('should handle values just below step boundary', () => {
expect(roundToStepPrecision(1.4999, 0.01)).toBeCloseTo(1.5);
expect(roundToStepPrecision(1.499, 0.01)).toBeCloseTo(1.5);
});
});
});

View File

@@ -0,0 +1,24 @@
import { getDecimalPlaces } from '$shared/lib/utils';
/**
* 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
*/
export function roundToStepPrecision(value: number, step: number = 1): number {
if (step <= 0) {
return value;
}
const decimals = getDecimalPlaces(step);
return parseFloat(value.toFixed(decimals));
}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { Property } from '$shared/lib/store';
import type { Filter } from '$shared/lib';
import { Badge } from '$shared/shadcn/ui/badge';
import { buttonVariants } from '$shared/shadcn/ui/button';
import { Checkbox } from '$shared/shadcn/ui/checkbox';
@@ -26,13 +26,11 @@ import { slide } from 'svelte/transition';
interface PropertyFilterProps {
/** Label for this filter group (e.g., "Properties", "Tags") */
displayedLabel: string;
/** Array of properties with their selection states */
properties: Property[];
/** Callback when a property checkbox is toggled */
onPropertyToggle: (id: string) => void;
/** Filter entity */
filter: Filter;
}
const { displayedLabel, properties, onPropertyToggle }: PropertyFilterProps = $props();
const { displayedLabel, filter }: PropertyFilterProps = $props();
// Toggle state - defaults to open for better discoverability
let isOpen = $state(true);
@@ -63,8 +61,10 @@ const slideConfig = $derived({
});
// Derived for reactive updates when properties change - avoids recomputing on every render
const selectedCount = $derived(properties.filter(c => c.selected).length);
const selectedCount = $derived(filter.selectedCount);
const hasSelection = $derived(selectedCount > 0);
$inspect(filter.properties).with(console.trace);
</script>
<!-- Collapsible card wrapper with subtle hover state for affordance -->
@@ -114,7 +114,7 @@ const hasSelection = $derived(selectedCount > 0);
<div class="flex flex-col gap-0.5">
<!-- Each item: checkbox + label with interactive hover/focus states -->
<!-- Keyed by property.id for efficient DOM updates -->
{#each properties as property (property.id)}
{#each filter.properties as property (property.id)}
<Label
for={property.id}
class="
@@ -129,8 +129,7 @@ const hasSelection = $derived(selectedCount > 0);
-->
<Checkbox
id={property.id}
checked={property.selected}
onclick={() => onPropertyToggle(property.id)}
bind:checked={property.selected}
class="
shrink-0 cursor-pointer transition-all duration-150 ease-out
data-[state=checked]:scale-100

View File

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

View File

@@ -8,7 +8,7 @@
- ARIA listbox/option pattern with single tab stop
-->
<script lang="ts" generics="T">
import { createVirtualizer } from '$shared/lib/utils';
import { createVirtualizer } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
type Snippet,

View File

@@ -4,6 +4,12 @@
* Exports all shared UI components and their types
*/
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
import ComboControl from './ComboControl/ComboControl.svelte';
import VirtualList from './VirtualList/VirtualList.svelte';
export { VirtualList };
export {
CheckboxFilter,
ComboControl,
VirtualList,
};

View File

@@ -10,12 +10,16 @@
* Buttons are equally sized (flex-1) for balanced layout. Note:
* Functionality not yet implemented - wire up to filter stores.
*/
import { clearAllFilters } from '$features/FilterFonts';
import { filterManager } from '$features/FilterFonts';
import { Button } from '$shared/shadcn/ui/button';
</script>
<div class="flex flex-row gap-2">
<Button variant="outline" class="flex-1 cursor-pointer" onclick={clearAllFilters}>
<Button
variant="outline"
class="flex-1 cursor-pointer"
onclick={filterManager.deselectAllGlobal}
>
Reset
</Button>
<Button class="flex-1 cursor-pointer">

View File

@@ -12,31 +12,15 @@
* Uses $derived for reactive access to filter states, ensuring UI updates
* when selections change through any means (sidebar, programmatically, etc.).
*/
import { categoryFilterStore } from '$features/FilterFonts';
import { providersFilterStore } from '$features/FilterFonts';
import { subsetsFilterStore } from '$features/FilterFonts';
import CheckboxFilter from '$shared/ui/CheckboxFilter/CheckboxFilter.svelte';
import { filterManager } from '$features/FilterFonts';
import { CheckboxFilter } from '$shared/ui';
/** Reactive properties from providers filter store */
const { properties: providers } = $derived($providersFilterStore);
/** Reactive properties from subsets filter store */
const { properties: subsets } = $derived($subsetsFilterStore);
/** Reactive properties from categories filter store */
const { properties: categories } = $derived($categoryFilterStore);
$inspect(filterManager.groups).with(console.trace);
</script>
<CheckboxFilter
displayedLabel="Font provider"
properties={providers}
onPropertyToggle={providersFilterStore.toggleProperty}
/>
<CheckboxFilter
displayedLabel="Font subset"
properties={subsets}
onPropertyToggle={subsetsFilterStore.toggleProperty}
/>
<CheckboxFilter
displayedLabel="Font category"
properties={categories}
onPropertyToggle={categoryFilterStore.toggleProperty}
/>
{#each filterManager.groups as group (group.id)}
<CheckboxFilter
displayedLabel={group.label}
filter={group.instance}
/>
{/each}