Compare commits
11 Commits
0692711726
...
36a326817d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36a326817d | ||
|
|
f4c2a38873 | ||
|
|
614d6b0673 | ||
|
|
f26f56ddef | ||
|
|
76f27a64b2 | ||
|
|
baff3b9e27 | ||
|
|
d15b90cfcb | ||
|
|
893bb02459 | ||
|
|
f7b19bd97f | ||
|
|
2c4bfaba41 | ||
|
|
9fd98aca5d |
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
7
src/features/FilterFonts/model/const/types/common.ts
Normal file
7
src/features/FilterFonts/model/const/types/common.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Property } from '$shared/lib';
|
||||
|
||||
export interface FilterGroupConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
properties: Property[];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
27
src/features/FilterFonts/model/state/manager.svelte.ts
Normal file
27
src/features/FilterFonts/model/state/manager.svelte.ts
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
3
src/features/SetupFont/index.ts
Normal file
3
src/features/SetupFont/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import SetupFontMenu from './ui/SetupFontMenu.svelte';
|
||||
|
||||
export { SetupFontMenu };
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
56
src/features/SetupFont/model/state/manager.svetle.ts
Normal file
56
src/features/SetupFont/model/state/manager.svetle.ts
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
111
src/shared/lib/helpers/createFilter/createFilter.svelte.ts
Normal file
111
src/shared/lib/helpers/createFilter/createFilter.svelte.ts
Normal 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>;
|
||||
@@ -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>;
|
||||
@@ -112,3 +112,5 @@ export function createVirtualizer(
|
||||
measureElement: (el: HTMLElement) => state.measureElement(el),
|
||||
};
|
||||
}
|
||||
|
||||
export type Virtualizer = ReturnType<typeof createVirtualizer>;
|
||||
20
src/shared/lib/helpers/index.ts
Normal file
20
src/shared/lib/helpers/index.ts
Normal 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
14
src/shared/lib/index.ts
Normal 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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 })),
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
176
src/shared/lib/utils/clampNumber/clampNumber.test.ts
Normal file
176
src/shared/lib/utils/clampNumber/clampNumber.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
10
src/shared/lib/utils/clampNumber/clampNumber.ts
Normal file
10
src/shared/lib/utils/clampNumber/clampNumber.ts
Normal 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);
|
||||
}
|
||||
188
src/shared/lib/utils/getDecimalPlaces/getDecimalPlaces.test.ts
Normal file
188
src/shared/lib/utils/getDecimalPlaces/getDecimalPlaces.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
17
src/shared/lib/utils/getDecimalPlaces/getDecimalPlaces.ts
Normal file
17
src/shared/lib/utils/getDecimalPlaces/getDecimalPlaces.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user