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 {
|
||||||
export { providersFilterStore } from './model/stores/providersFilterStore';
|
createFilterManager,
|
||||||
export { subsetsFilterStore } from './model/stores/subsetsFilterStore';
|
type FilterManager,
|
||||||
|
} from './lib/filterManager/filterManager.svelte';
|
||||||
export { clearAllFilters } from './model/services/clearAllFilters/clearAllFilters';
|
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[] = [
|
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">
|
<script lang="ts">
|
||||||
import * as Item from '$shared/shadcn/ui/item';
|
|
||||||
import { Separator } from '$shared/shadcn/ui/separator/index';
|
import { Separator } from '$shared/shadcn/ui/separator/index';
|
||||||
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
|
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
|
||||||
import ComboControl from '$shared/ui/ComboControl/ComboControl.svelte';
|
import { ComboControl } from '$shared/ui';
|
||||||
import { fontSizeStore } from '../model/stores/fontSizeStore';
|
import { controlManager } from '../model/state/manager.svetle';
|
||||||
import { fontWeightStore } from '../model/stores/fontWeightStore';
|
|
||||||
import { lineHeightStore } from '../model/stores/lineHeightStore';
|
|
||||||
|
|
||||||
const fontSize = $derived($fontSizeStore);
|
|
||||||
const fontWeight = $derived($fontWeightStore);
|
|
||||||
const lineHeight = $derived($lineHeightStore);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full p-2 flex flex-row items-center">
|
<div class="w-full p-2 flex flex-row items-center gap-2">
|
||||||
<Sidebar.Trigger />
|
<Sidebar.Trigger />
|
||||||
<Separator orientation="vertical" class="h-full" />
|
<Separator orientation="vertical" class="h-full" />
|
||||||
<ComboControl
|
{#each controlManager.controls as control (control.id)}
|
||||||
value={fontSize.value}
|
<ComboControl control={control.instance} />
|
||||||
minValue={fontSize.min}
|
{/each}
|
||||||
maxValue={fontSize.max}
|
|
||||||
onChange={fontSizeStore.setValue}
|
|
||||||
onIncrease={fontSizeStore.increase}
|
|
||||||
onDecrease={fontSizeStore.decrease}
|
|
||||||
increaseDisabled={fontSizeStore.isAtMax()}
|
|
||||||
decreaseDisabled={fontSizeStore.isAtMin()}
|
|
||||||
increaseLabel="Increase Font Size"
|
|
||||||
decreaseLabel="Decrease Font Size"
|
|
||||||
/>
|
|
||||||
<ComboControl
|
|
||||||
value={fontWeight.value}
|
|
||||||
minValue={fontWeight.min}
|
|
||||||
maxValue={fontWeight.max}
|
|
||||||
onChange={fontWeightStore.setValue}
|
|
||||||
onIncrease={fontWeightStore.increase}
|
|
||||||
onDecrease={fontWeightStore.decrease}
|
|
||||||
increaseDisabled={fontWeightStore.isAtMax()}
|
|
||||||
decreaseDisabled={fontWeightStore.isAtMin()}
|
|
||||||
increaseLabel="Increase Font Weight"
|
|
||||||
decreaseLabel="Decrease Font Weight"
|
|
||||||
/>
|
|
||||||
<ComboControl
|
|
||||||
value={lineHeight.value}
|
|
||||||
minValue={lineHeight.min}
|
|
||||||
maxValue={lineHeight.max}
|
|
||||||
step={lineHeight.step}
|
|
||||||
onChange={lineHeightStore.setValue}
|
|
||||||
onIncrease={lineHeightStore.increase}
|
|
||||||
onDecrease={lineHeightStore.decrease}
|
|
||||||
increaseDisabled={lineHeightStore.isAtMax()}
|
|
||||||
decreaseDisabled={lineHeightStore.isAtMin()}
|
|
||||||
increaseLabel="Increase Line Height"
|
|
||||||
decreaseLabel="Decrease Line Height"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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),
|
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
|
* Shared utility functions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { buildQueryString } from './buildQueryString';
|
export {
|
||||||
export type {
|
buildQueryString,
|
||||||
QueryParams,
|
type QueryParams,
|
||||||
QueryParamValue,
|
type QueryParamValue,
|
||||||
} from './buildQueryString';
|
} from './buildQueryString/buildQueryString';
|
||||||
export { createVirtualizer } from './createVirtualizer/createVirtualizer';
|
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">
|
<script lang="ts">
|
||||||
import type { Property } from '$shared/lib/store';
|
import type { Filter } from '$shared/lib';
|
||||||
import { Badge } from '$shared/shadcn/ui/badge';
|
import { Badge } from '$shared/shadcn/ui/badge';
|
||||||
import { buttonVariants } from '$shared/shadcn/ui/button';
|
import { buttonVariants } from '$shared/shadcn/ui/button';
|
||||||
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
||||||
@@ -26,13 +26,11 @@ import { slide } from 'svelte/transition';
|
|||||||
interface PropertyFilterProps {
|
interface PropertyFilterProps {
|
||||||
/** Label for this filter group (e.g., "Properties", "Tags") */
|
/** Label for this filter group (e.g., "Properties", "Tags") */
|
||||||
displayedLabel: string;
|
displayedLabel: string;
|
||||||
/** Array of properties with their selection states */
|
/** Filter entity */
|
||||||
properties: Property[];
|
filter: Filter;
|
||||||
/** Callback when a property checkbox is toggled */
|
|
||||||
onPropertyToggle: (id: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { displayedLabel, properties, onPropertyToggle }: PropertyFilterProps = $props();
|
const { displayedLabel, filter }: PropertyFilterProps = $props();
|
||||||
|
|
||||||
// Toggle state - defaults to open for better discoverability
|
// Toggle state - defaults to open for better discoverability
|
||||||
let isOpen = $state(true);
|
let isOpen = $state(true);
|
||||||
@@ -63,8 +61,10 @@ const slideConfig = $derived({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Derived for reactive updates when properties change - avoids recomputing on every render
|
// 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);
|
const hasSelection = $derived(selectedCount > 0);
|
||||||
|
|
||||||
|
$inspect(filter.properties).with(console.trace);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Collapsible card wrapper with subtle hover state for affordance -->
|
<!-- 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">
|
<div class="flex flex-col gap-0.5">
|
||||||
<!-- Each item: checkbox + label with interactive hover/focus states -->
|
<!-- Each item: checkbox + label with interactive hover/focus states -->
|
||||||
<!-- Keyed by property.id for efficient DOM updates -->
|
<!-- Keyed by property.id for efficient DOM updates -->
|
||||||
{#each properties as property (property.id)}
|
{#each filter.properties as property (property.id)}
|
||||||
<Label
|
<Label
|
||||||
for={property.id}
|
for={property.id}
|
||||||
class="
|
class="
|
||||||
@@ -129,8 +129,7 @@ const hasSelection = $derived(selectedCount > 0);
|
|||||||
-->
|
-->
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={property.id}
|
id={property.id}
|
||||||
checked={property.selected}
|
bind:checked={property.selected}
|
||||||
onclick={() => onPropertyToggle(property.id)}
|
|
||||||
class="
|
class="
|
||||||
shrink-0 cursor-pointer transition-all duration-150 ease-out
|
shrink-0 cursor-pointer transition-all duration-150 ease-out
|
||||||
data-[state=checked]:scale-100
|
data-[state=checked]:scale-100
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { TypographyControl } from '$shared/lib';
|
||||||
import { Button } from '$shared/shadcn/ui/button';
|
import { Button } from '$shared/shadcn/ui/button';
|
||||||
import * as ButtonGroup from '$shared/shadcn/ui/button-group';
|
import * as ButtonGroup from '$shared/shadcn/ui/button-group';
|
||||||
import { Input } from '$shared/shadcn/ui/input';
|
import { Input } from '$shared/shadcn/ui/input';
|
||||||
@@ -9,22 +10,6 @@ import PlusIcon from '@lucide/svelte/icons/plus';
|
|||||||
import type { ChangeEventHandler } from 'svelte/elements';
|
import type { ChangeEventHandler } from 'svelte/elements';
|
||||||
|
|
||||||
interface ComboControlProps {
|
interface ComboControlProps {
|
||||||
/**
|
|
||||||
* Controlled value
|
|
||||||
*/
|
|
||||||
value: number;
|
|
||||||
/**
|
|
||||||
* Callback function to handle value change
|
|
||||||
*/
|
|
||||||
onChange: (value: number) => void;
|
|
||||||
/**
|
|
||||||
* Callback function to handle increase
|
|
||||||
*/
|
|
||||||
onIncrease: () => void;
|
|
||||||
/**
|
|
||||||
* Callback function to handle decrease
|
|
||||||
*/
|
|
||||||
onDecrease: () => void;
|
|
||||||
/**
|
/**
|
||||||
* Text for increase button aria-label
|
* Text for increase button aria-label
|
||||||
*/
|
*/
|
||||||
@@ -33,59 +18,35 @@ interface ComboControlProps {
|
|||||||
* Text for decrease button aria-label
|
* Text for decrease button aria-label
|
||||||
*/
|
*/
|
||||||
decreaseLabel?: string;
|
decreaseLabel?: string;
|
||||||
/**
|
|
||||||
* Flag for disabling increase button
|
|
||||||
*/
|
|
||||||
increaseDisabled?: boolean;
|
|
||||||
/**
|
|
||||||
* Flag for disabling decrease button
|
|
||||||
*/
|
|
||||||
decreaseDisabled?: boolean;
|
|
||||||
/**
|
/**
|
||||||
* Text for control button aria-label
|
* Text for control button aria-label
|
||||||
*/
|
*/
|
||||||
controlLabel?: string;
|
controlLabel?: string;
|
||||||
/**
|
/**
|
||||||
* Minimum value for the input
|
* Control instance
|
||||||
*/
|
*/
|
||||||
minValue?: number;
|
control: TypographyControl;
|
||||||
/**
|
|
||||||
* Maximum value for the input
|
|
||||||
*/
|
|
||||||
maxValue?: number;
|
|
||||||
/**
|
|
||||||
* Step value for the slider
|
|
||||||
*/
|
|
||||||
step?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
value,
|
control,
|
||||||
onChange,
|
|
||||||
onIncrease,
|
|
||||||
onDecrease,
|
|
||||||
increaseLabel,
|
|
||||||
decreaseLabel,
|
decreaseLabel,
|
||||||
increaseDisabled,
|
increaseLabel,
|
||||||
decreaseDisabled,
|
|
||||||
controlLabel,
|
controlLabel,
|
||||||
minValue = 0,
|
|
||||||
maxValue = 100,
|
|
||||||
step = 1,
|
|
||||||
}: ComboControlProps = $props();
|
}: ComboControlProps = $props();
|
||||||
|
|
||||||
// Local state for the slider to prevent infinite loops
|
// Local state for the slider to prevent infinite loops
|
||||||
let sliderValue = $state(value);
|
let sliderValue = $state(Number(control.value));
|
||||||
|
|
||||||
// Sync sliderValue when external value changes
|
// Sync sliderValue when external value changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
sliderValue = value;
|
sliderValue = Number(control.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
|
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
|
||||||
const parsedValue = parseFloat(event.currentTarget.value);
|
const parsedValue = parseFloat(event.currentTarget.value);
|
||||||
if (!isNaN(parsedValue)) {
|
if (!isNaN(parsedValue)) {
|
||||||
onChange(parsedValue);
|
control.value = parsedValue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,8 +54,8 @@ const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
|
|||||||
* Handle slider value change.
|
* Handle slider value change.
|
||||||
* The Slider component passes the value as a number directly.
|
* The Slider component passes the value as a number directly.
|
||||||
*/
|
*/
|
||||||
const handleSliderChange = (value: number) => {
|
const handleSliderChange = (newValue: number) => {
|
||||||
onChange(value);
|
control.value = newValue;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -103,8 +64,8 @@ const handleSliderChange = (value: number) => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label={decreaseLabel}
|
aria-label={decreaseLabel}
|
||||||
onclick={onDecrease}
|
onclick={control.decrease}
|
||||||
disabled={decreaseDisabled}
|
disabled={control.isAtMin}
|
||||||
>
|
>
|
||||||
<MinusIcon />
|
<MinusIcon />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -117,16 +78,16 @@ const handleSliderChange = (value: number) => {
|
|||||||
size="icon"
|
size="icon"
|
||||||
aria-label={controlLabel}
|
aria-label={controlLabel}
|
||||||
>
|
>
|
||||||
{value}
|
{control.value}
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
<Popover.Content class="w-auto p-4">
|
<Popover.Content class="w-auto p-4">
|
||||||
<div class="flex flex-col items-center gap-3">
|
<div class="flex flex-col items-center gap-3">
|
||||||
<Slider
|
<Slider
|
||||||
min={minValue}
|
min={control.min}
|
||||||
max={maxValue}
|
max={control.max}
|
||||||
step={step}
|
step={control.step}
|
||||||
value={sliderValue}
|
value={sliderValue}
|
||||||
onValueChange={handleSliderChange}
|
onValueChange={handleSliderChange}
|
||||||
type="single"
|
type="single"
|
||||||
@@ -134,10 +95,10 @@ const handleSliderChange = (value: number) => {
|
|||||||
class="h-48"
|
class="h-48"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={String(value)}
|
value={control.value}
|
||||||
min={minValue}
|
|
||||||
max={maxValue}
|
|
||||||
onchange={handleInputChange}
|
onchange={handleInputChange}
|
||||||
|
min={control.min}
|
||||||
|
max={control.max}
|
||||||
class="w-16 text-center"
|
class="w-16 text-center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,8 +108,8 @@ const handleSliderChange = (value: number) => {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label={increaseLabel}
|
aria-label={increaseLabel}
|
||||||
onclick={onIncrease}
|
onclick={control.increase}
|
||||||
disabled={increaseDisabled}
|
disabled={control.isAtMax}
|
||||||
>
|
>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
- ARIA listbox/option pattern with single tab stop
|
- ARIA listbox/option pattern with single tab stop
|
||||||
-->
|
-->
|
||||||
<script lang="ts" generics="T">
|
<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 { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import {
|
import {
|
||||||
type Snippet,
|
type Snippet,
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
* Exports all shared UI components and their types
|
* 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';
|
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:
|
* Buttons are equally sized (flex-1) for balanced layout. Note:
|
||||||
* Functionality not yet implemented - wire up to filter stores.
|
* 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';
|
import { Button } from '$shared/shadcn/ui/button';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-row gap-2">
|
<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
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
<Button class="flex-1 cursor-pointer">
|
<Button class="flex-1 cursor-pointer">
|
||||||
|
|||||||
@@ -12,31 +12,15 @@
|
|||||||
* Uses $derived for reactive access to filter states, ensuring UI updates
|
* Uses $derived for reactive access to filter states, ensuring UI updates
|
||||||
* when selections change through any means (sidebar, programmatically, etc.).
|
* when selections change through any means (sidebar, programmatically, etc.).
|
||||||
*/
|
*/
|
||||||
import { categoryFilterStore } from '$features/FilterFonts';
|
import { filterManager } from '$features/FilterFonts';
|
||||||
import { providersFilterStore } from '$features/FilterFonts';
|
import { CheckboxFilter } from '$shared/ui';
|
||||||
import { subsetsFilterStore } from '$features/FilterFonts';
|
|
||||||
import CheckboxFilter from '$shared/ui/CheckboxFilter/CheckboxFilter.svelte';
|
|
||||||
|
|
||||||
/** Reactive properties from providers filter store */
|
$inspect(filterManager.groups).with(console.trace);
|
||||||
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);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CheckboxFilter
|
{#each filterManager.groups as group (group.id)}
|
||||||
displayedLabel="Font provider"
|
<CheckboxFilter
|
||||||
properties={providers}
|
displayedLabel={group.label}
|
||||||
onPropertyToggle={providersFilterStore.toggleProperty}
|
filter={group.instance}
|
||||||
/>
|
/>
|
||||||
<CheckboxFilter
|
{/each}
|
||||||
displayedLabel="Font subset"
|
|
||||||
properties={subsets}
|
|
||||||
onPropertyToggle={subsetsFilterStore.toggleProperty}
|
|
||||||
/>
|
|
||||||
<CheckboxFilter
|
|
||||||
displayedLabel="Font category"
|
|
||||||
properties={categories}
|
|
||||||
onPropertyToggle={categoryFilterStore.toggleProperty}
|
|
||||||
/>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user