Compare commits
51 Commits
b3bc40b76c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c78b8e032e | |||
| 11d5ba0e63 | |||
| 99e9a1fb2c | |||
| 5084df3914 | |||
| a2ec025a65 | |||
| 8dbea97a33 | |||
| 744cdc9d19 | |||
| 600b905e01 | |||
| 4ad0fe4cfa | |||
| eafe89b313 | |||
| 724b00d3d5 | |||
| c09ca93f4e | |||
| 99ab7e9e08 | |||
| ec488cf1ce | |||
| fe07c60dd4 | |||
| 0aae710e35 | |||
| ded9606c30 | |||
| f0736f4d35 | |||
| 5eb458eabb | |||
| a428eac309 | |||
| 09869aed00 | |||
| 028853aff5 | |||
| 1c6427c586 | |||
| 60e115309c | |||
| b390efdabe | |||
| 771bda745c | |||
| c6c8497906 | |||
| f3a10e38df | |||
| 9788f07dec | |||
| deefb51b57 | |||
| 431fb41a7f | |||
| db6384110e | |||
| cbd95350bb | |||
| a8a985ee6a | |||
| be073286dc | |||
| 7798c4bbdf | |||
| 3ae22ad515 | |||
| ffa897ee54 | |||
| 93c52dd132 | |||
| 9e0c8f740b | |||
| b1b5177e02 | |||
| ef9cd33e48 | |||
| f3c76df2c5 | |||
| ae2d0e3c2f | |||
| 3f5151efa0 | |||
| 19d9b07c55 | |||
| 1209358d40 | |||
| d7decd7a00 | |||
| 9d6220d2ec | |||
| 4756682863 | |||
| 7ddf232e3a |
+195
@@ -0,0 +1,195 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["import"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "warn",
|
||||
"perf": "warn",
|
||||
// style/restriction off: opt-in, contradictory grab-bags. Wanted rules enabled individually below.
|
||||
"style": "off",
|
||||
"restriction": "off"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
".svelte-kit",
|
||||
".vercel",
|
||||
"*.config.js",
|
||||
"*.config.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "warn",
|
||||
"no-debugger": "error",
|
||||
"no-alert": "warn",
|
||||
|
||||
// no-cycle resolves $-aliases via tsconfig auto-discovery (no resolver config in oxlint)
|
||||
"import/no-cycle": "error",
|
||||
"import/no-duplicates": "warn",
|
||||
"import/no-unassigned-import": "off", // CSS/side-effect imports are intentional
|
||||
|
||||
"no-sequences": "error",
|
||||
"no-underscore-dangle": "off",
|
||||
"no-shadow": "warn",
|
||||
"no-implicit-coercion": "warn",
|
||||
"no-await-in-loop": "warn",
|
||||
"no-return-assign": "warn",
|
||||
"no-new": "warn",
|
||||
"no-unneeded-ternary": "warn"
|
||||
},
|
||||
// FSD boundaries. oxlint has no zone rule, so layer/segment direction is enforced
|
||||
// with no-restricted-imports patterns scoped per glob. Layer order (high->low):
|
||||
// app(exempt top shell) > routes > widgets > features > entities > shared.
|
||||
// A layer bans imports from itself (cross-slice via alias) and every layer above.
|
||||
// Overrides are LAST-WINS, not merged: a file matching two overrides keeps only the
|
||||
// last rule config. So the domain override (below) is a self-contained superset, and
|
||||
// the test/story override (last) fully disables boundary checks for those files.
|
||||
"overrides": [
|
||||
// shared = lowest layer: imports nothing above it
|
||||
{
|
||||
"files": ["src/shared/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"$app",
|
||||
"$app/*",
|
||||
"$routes",
|
||||
"$routes/*",
|
||||
"$widgets",
|
||||
"$widgets/*",
|
||||
"$features",
|
||||
"$features/*",
|
||||
"$entities",
|
||||
"$entities/*"
|
||||
],
|
||||
"message": "FSD layer violation: `shared` is the lowest layer and may not import from any layer above it."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// entities: import shared only; no other entity via alias; interior ui<-only-ui
|
||||
{
|
||||
"files": ["src/entities/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["$app", "$app/*", "$routes", "$routes/*", "$widgets", "$widgets/*", "$features", "$features/*"],
|
||||
"message": "FSD layer violation: `entities` may only import from `shared`."
|
||||
},
|
||||
{
|
||||
"group": ["$entities", "$entities/*"],
|
||||
"message": "FSD cross-slice violation: do not import another entity via its alias. Use relative imports inside your own slice; invert the dependency through a higher layer for cross-slice needs."
|
||||
},
|
||||
{
|
||||
"group": ["../ui", "../ui/*", "../../ui/*"],
|
||||
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// features: import entities/shared only; no other feature via alias
|
||||
{
|
||||
"files": ["src/features/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["$app", "$app/*", "$routes", "$routes/*", "$widgets", "$widgets/*"],
|
||||
"message": "FSD layer violation: `features` may only import from `entities` and `shared`."
|
||||
},
|
||||
{
|
||||
"group": ["$features", "$features/*"],
|
||||
"message": "FSD cross-slice violation: do not import another feature via its alias. Invert the dependency through a higher layer (widget/route)."
|
||||
},
|
||||
{
|
||||
"group": ["../ui", "../ui/*", "../../ui/*"],
|
||||
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// widgets: import features/entities/shared only; no other widget via alias
|
||||
{
|
||||
"files": ["src/widgets/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["$app", "$app/*", "$routes", "$routes/*"],
|
||||
"message": "FSD layer violation: `widgets` may only import from `features`, `entities`, and `shared`."
|
||||
},
|
||||
{
|
||||
"group": ["$widgets", "$widgets/*"],
|
||||
"message": "FSD cross-slice violation: do not import another widget via its alias. Invert the dependency through the route layer."
|
||||
},
|
||||
{
|
||||
"group": ["../ui", "../ui/*", "../../ui/*"],
|
||||
"message": "FSD segment violation: only `ui` may import `ui`. Interior direction is ui -> model -> domain."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// routes: top of the FSD list, imports any layer below; only app is above it
|
||||
{
|
||||
"files": ["src/routes/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{ "group": ["$app", "$app/*"], "message": "FSD layer violation: `routes` may not import from `app`." }
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// domain (FSD+): pure logic. Imports NO layer (not even shared) and no sibling
|
||||
// model/ui segment. Superset: wins over the layer override above for these files.
|
||||
{
|
||||
"files": ["src/**/domain/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": ["error", {
|
||||
"patterns": [
|
||||
{
|
||||
"group": [
|
||||
"$app",
|
||||
"$app/*",
|
||||
"$routes",
|
||||
"$routes/*",
|
||||
"$widgets",
|
||||
"$widgets/*",
|
||||
"$features",
|
||||
"$features/*",
|
||||
"$entities",
|
||||
"$entities/*",
|
||||
"$shared",
|
||||
"$shared/*"
|
||||
],
|
||||
"message": "FSD+ domain isolation: `domain` is pure business logic and may not import any layer (including `shared`). Allowed: relative imports within `domain` and framework-agnostic npm packages."
|
||||
},
|
||||
{
|
||||
"group": ["../model", "../model/*", "../../model/*", "../ui", "../ui/*", "../../ui/*"],
|
||||
"message": "FSD+ domain isolation: `domain` may not import sibling `model` or `ui` segments. Dependency flows ui -> model -> domain, never back."
|
||||
}
|
||||
]
|
||||
}]
|
||||
}
|
||||
},
|
||||
// tests/stories/fixtures legitimately cross-import (e.g. $entities/Font/testing).
|
||||
// Must be LAST so last-wins disables boundary checks for them.
|
||||
{
|
||||
"files": ["**/*.test.ts", "**/*.spec.ts", "**/*.stories.svelte", "src/**/testing/**"],
|
||||
"rules": {
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { windowSizeForLine } from '../src/entities/Font/domain/windowSizeForLine/windowSizeForLine';
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
@@ -5,12 +6,22 @@ import {
|
||||
|
||||
test.describe('preview text', () => {
|
||||
test('drives the slider character rendering', async ({ comparison }) => {
|
||||
/**
|
||||
* Must stay a single unwrapped line of ASCII: the assertion feeds
|
||||
* `text.length` (UTF-16 code units) to `windowSizeForLine`, but the
|
||||
* renderer feeds it the line's grapheme count. They match only for
|
||||
* plain ASCII — emoji/combining marks (length > graphemes) or wrapping
|
||||
* (one input string splitting into several lines) silently desync them.
|
||||
*/
|
||||
const text = 'Sphinx';
|
||||
await comparison.pickPair('Inter', 'Roboto');
|
||||
await comparison.setPreviewText('Sphinx');
|
||||
await comparison.setPreviewText(text);
|
||||
|
||||
// Each grapheme renders as a `.char-wrap` cell in the slider once
|
||||
// both fonts are loaded. Six glyphs → six cells.
|
||||
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(6);
|
||||
// Window chars render as `.char-wrap` cells for crossfade. The window
|
||||
// size is a pure function of the line's grapheme count — assert against
|
||||
// the rule, not a hardcoded constant, so tuning the policy can't silently
|
||||
// break this. "Sphinx" is one unwrapped line of 6 graphemes.
|
||||
await expect(comparison.slider.locator('.char-wrap')).toHaveCount(windowSizeForLine(text.length));
|
||||
});
|
||||
|
||||
test('preserves the typed value in the input', async ({ comparison }) => {
|
||||
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"plugins": ["import"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "warn",
|
||||
"perf": "warn",
|
||||
"style": "warn",
|
||||
"restriction": "error"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
".svelte-kit",
|
||||
".vercel",
|
||||
"*.config.js",
|
||||
"*.config.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"no-debugger": "error",
|
||||
"no-alert": "warn",
|
||||
"import/no-cycle": "error"
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -6,8 +6,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"*.css",
|
||||
"**/router.ts",
|
||||
"**/bindings.svelte.ts"
|
||||
"**/router.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -49,7 +48,6 @@
|
||||
"@types/jsdom": "28.0.1",
|
||||
"@vitest/browser-playwright": "4.1.5",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"bits-ui": "2.18.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dprint": "0.54.0",
|
||||
"jsdom": "29.1.1",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
descendants of this provider.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
@@ -18,6 +18,9 @@ interface Props {
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
// First call to the lazy singleton — constructs the shared client for the app.
|
||||
const queryClient = getQueryClient();
|
||||
</script>
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -19,7 +19,9 @@ vi.mock('$shared/api/api', () => ({
|
||||
}));
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import { FontResponseError } from '../../lib/errors/errors';
|
||||
import {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import { buildQueryString } from '$shared/lib/utils';
|
||||
import type { QueryParams } from '$shared/lib/utils';
|
||||
@@ -26,7 +26,7 @@ import type { UnifiedFont } from '../../model/types';
|
||||
*/
|
||||
export function seedFontCache(fonts: UnifiedFont[]): void {
|
||||
fonts.forEach(font => {
|
||||
queryClient.setQueryData(fontKeys.detail(font.id), font);
|
||||
getQueryClient().setQueryData(fontKeys.detail(font.id), font);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,3 +8,4 @@ export {
|
||||
findSplitIndex,
|
||||
type LineRenderModel,
|
||||
} from './computeLineRenderModel/computeLineRenderModel';
|
||||
export { windowSizeForLine } from './windowSizeForLine/windowSizeForLine';
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { windowSizeForLine } from './windowSizeForLine';
|
||||
|
||||
describe('windowSizeForLine', () => {
|
||||
it('returns 0 for an empty or non-positive line', () => {
|
||||
expect(windowSizeForLine(0)).toBe(0);
|
||||
expect(windowSizeForLine(-3)).toBe(0);
|
||||
});
|
||||
|
||||
it('floors non-empty short lines at the minimum window of 1', () => {
|
||||
expect(windowSizeForLine(1)).toBe(1);
|
||||
expect(windowSizeForLine(2)).toBe(1);
|
||||
expect(windowSizeForLine(3)).toBe(1);
|
||||
});
|
||||
|
||||
it('scales with round(n / 3) in the mid range', () => {
|
||||
expect(windowSizeForLine(6)).toBe(2);
|
||||
expect(windowSizeForLine(12)).toBe(4);
|
||||
});
|
||||
|
||||
it('caps at the maximum window of 5', () => {
|
||||
expect(windowSizeForLine(15)).toBe(5);
|
||||
expect(windowSizeForLine(16)).toBe(5);
|
||||
expect(windowSizeForLine(100)).toBe(5);
|
||||
});
|
||||
|
||||
it('rounds to nearest at fractional boundaries', () => {
|
||||
// round(4/3)=1, round(5/3)=2, round(13/3)=4, round(14/3)=5
|
||||
expect(windowSizeForLine(4)).toBe(1);
|
||||
expect(windowSizeForLine(5)).toBe(2);
|
||||
expect(windowSizeForLine(13)).toBe(4);
|
||||
expect(windowSizeForLine(14)).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Crossfade-window sizing policy for the dual-font slider.
|
||||
*
|
||||
* The slider renders a band of per-char `Character` cells that opacity-crossfade
|
||||
* between the two fonts; everything outside the band is committed native bulk
|
||||
* text. A fixed band looked wrong on short lines — a 6-grapheme line left almost
|
||||
* no bulk, so nearly the whole line shimmered as per-char DOM. The band size
|
||||
* therefore scales with the line's grapheme count and caps so long lines don't
|
||||
* pay for an oversized per-char DOM band.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fraction of a line's graphemes that sit in the crossfade band.
|
||||
*/
|
||||
const WINDOW_RATIO = 1 / 3;
|
||||
/**
|
||||
* Smallest band for a non-empty line — guarantees at least one crossfading char.
|
||||
*
|
||||
* Accepted tradeoff: short lines now get a band of 1–2, so a fast slider drag
|
||||
* can unmount a char before its ~100ms opacity crossfade finishes, a slight pop.
|
||||
* Worth it for the "bulk committed, small band shimmering" look on short lines;
|
||||
* raising this trades that pop back for less committed bulk.
|
||||
*/
|
||||
const WINDOW_MIN = 1;
|
||||
/**
|
||||
* Largest band regardless of line length — bounds per-char DOM cost.
|
||||
*/
|
||||
const WINDOW_MAX = 5;
|
||||
|
||||
/**
|
||||
* Crossfade window size, in graphemes, for a line of `n` graphemes.
|
||||
* `clamp(round(n / 3), 1, 5)`; an empty/non-positive line gets no window.
|
||||
*/
|
||||
export function windowSizeForLine(n: number): number {
|
||||
if (n <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(WINDOW_MAX, Math.max(WINDOW_MIN, Math.round(n * WINDOW_RATIO)));
|
||||
}
|
||||
+85
-22
@@ -1,29 +1,92 @@
|
||||
export * from './domain';
|
||||
export * from './lib';
|
||||
export * from './ui';
|
||||
export {
|
||||
computeLineRenderModel,
|
||||
DualFontLayout,
|
||||
findSplitIndex,
|
||||
windowSizeForLine,
|
||||
} from './domain';
|
||||
export type {
|
||||
ComparisonLine,
|
||||
ComparisonResult,
|
||||
LineRenderModel,
|
||||
} from './domain';
|
||||
|
||||
// Pure model surface (types + constants) is part of the convenient top-level
|
||||
// API. Stateful stores are deliberately excluded — see below.
|
||||
export * from './model/const/const';
|
||||
export * from './model/types';
|
||||
export {
|
||||
createFontRowSizeResolver,
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
getFontUrl,
|
||||
} from './lib';
|
||||
export type { FontRowSizeResolverOptions } from './lib';
|
||||
|
||||
export {
|
||||
FontApplicator,
|
||||
FontSampler,
|
||||
FontVirtualList,
|
||||
} from './ui';
|
||||
|
||||
// Pure model surface (types + constants).
|
||||
export {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LETTER_SPACING_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LETTER_SPACING,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LETTER_SPACING,
|
||||
MIN_LINE_HEIGHT,
|
||||
VIRTUAL_INDEX_NOT_LOADED,
|
||||
} from './model/const/const';
|
||||
export type {
|
||||
FilterGroup,
|
||||
FilterType,
|
||||
FontCategory,
|
||||
FontCollectionFilters,
|
||||
FontCollectionSort,
|
||||
FontCollectionState,
|
||||
FontFeatures,
|
||||
FontFilters,
|
||||
FontLoadRequestConfig,
|
||||
FontLoadStatus,
|
||||
FontMetadata,
|
||||
FontProvider,
|
||||
FontStyleUrls,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
FontWeight,
|
||||
FontWeightItalic,
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from './model/types';
|
||||
|
||||
/*
|
||||
* Stores are exposed as lazy accessors / classes (not eager singletons): the
|
||||
* entity's public API is complete, so consumers go through this barrel instead
|
||||
* of deep-importing `./model` (FSD public-API boundary). Construction happens on
|
||||
* first call, so this is inert at import. The slice root already transitively
|
||||
* loads `@tanstack/query-core` via `./ui` (FontVirtualList), so surfacing the
|
||||
* stores here adds no new eager cost.
|
||||
*/
|
||||
export {
|
||||
FontLifecycleManager,
|
||||
FontsByIdsStore,
|
||||
getFontCatalog,
|
||||
getFontLifecycleManager,
|
||||
} from './model';
|
||||
export type { FontCatalogStore } from './model';
|
||||
|
||||
/*
|
||||
* `./api` (proxy clients: `fetchProxyFonts`, `seedFontCache`, …) is intentionally
|
||||
* NOT re-exported here. Those clients import `$shared/api/queryClient`, whose
|
||||
* module eval runs `new QueryClient()` and loads `@tanstack/query-core`. Funneling
|
||||
* them through this barrel made every consumer of `$entities/Font` — including
|
||||
* pure-domain and type-only importers — eager-load TanStack and construct the
|
||||
* client (notably in unit specs). Import API clients via the segment:
|
||||
* import { fetchProxyFonts } from '$entities/Font/api';
|
||||
*/
|
||||
|
||||
/*
|
||||
* Stores (`fontCatalogStore`, `fontLifecycleManager`, `FontsByIdsStore`) are
|
||||
* intentionally NOT re-exported here. They instantiate module-level singletons
|
||||
* and pull `@tanstack/query-core`, so funneling them through this barrel would
|
||||
* make every consumer of `$entities/Font` eager-instantiate stores (and break
|
||||
* tree-shaking / test init-order). Import them via the model segment:
|
||||
* import { fontCatalogStore } from '$entities/Font/model';
|
||||
* NOT re-exported here — those are not part of the entity's consumed surface and
|
||||
* importing them eagerly constructs the TanStack `queryClient`. Import via the
|
||||
* segment: `import { fetchProxyFonts } from '$entities/Font/api'`.
|
||||
*/
|
||||
|
||||
// `./testing` is intentionally not re-exported: fixtures must not leak into the
|
||||
|
||||
@@ -1,6 +1,51 @@
|
||||
export * from './const/const';
|
||||
export {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LETTER_SPACING_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LETTER_SPACING,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LETTER_SPACING,
|
||||
MIN_LINE_HEIGHT,
|
||||
VIRTUAL_INDEX_NOT_LOADED,
|
||||
} from './const/const';
|
||||
|
||||
export { getFontCatalog } from './store';
|
||||
// Stores (lazy accessors + classes)
|
||||
export {
|
||||
__resetFontLifecycleManager,
|
||||
FontLifecycleManager,
|
||||
FontsByIdsStore,
|
||||
getFontCatalog,
|
||||
getFontLifecycleManager,
|
||||
} from './store';
|
||||
export type { FontCatalogStore } from './store';
|
||||
|
||||
export * from './store';
|
||||
export * from './types';
|
||||
export type {
|
||||
FilterGroup,
|
||||
FilterType,
|
||||
FontCategory,
|
||||
FontCollectionFilters,
|
||||
FontCollectionSort,
|
||||
FontCollectionState,
|
||||
FontFeatures,
|
||||
FontFilters,
|
||||
FontLoadRequestConfig,
|
||||
FontLoadStatus,
|
||||
FontMetadata,
|
||||
FontProvider,
|
||||
FontStyleUrls,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
FontWeight,
|
||||
FontWeightItalic,
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from './types';
|
||||
|
||||
@@ -27,18 +27,21 @@ vi.mock('$shared/api/queryClient', async importOriginal => {
|
||||
*/
|
||||
const { QueryClient } = await import('@tanstack/query-core');
|
||||
const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
|
||||
const mockClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||
});
|
||||
return {
|
||||
...actual,
|
||||
queryClient: new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||
}),
|
||||
getQueryClient: () => mockClient,
|
||||
};
|
||||
});
|
||||
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
||||
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
import { fetchProxyFonts } from '../../../api';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
||||
|
||||
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {
|
||||
DEFAULT_QUERY_GC_TIME_MS,
|
||||
DEFAULT_QUERY_STALE_TIME_MS,
|
||||
queryClient,
|
||||
getQueryClient,
|
||||
} from '$shared/api/queryClient';
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
import {
|
||||
type InfiniteData,
|
||||
InfiniteQueryObserver,
|
||||
@@ -46,7 +47,7 @@ export class FontCatalogStore {
|
||||
readonly unknown[],
|
||||
PageParam
|
||||
>;
|
||||
#qc = queryClient;
|
||||
#qc = getQueryClient();
|
||||
#unsubscribe: () => void;
|
||||
|
||||
constructor(params: FontStoreParams = {}) {
|
||||
@@ -483,14 +484,12 @@ export class FontCatalogStore {
|
||||
}
|
||||
}
|
||||
|
||||
let _catalog: FontCatalogStore | undefined;
|
||||
const catalog = createSingleton(
|
||||
() => new FontCatalogStore({ limit: 50 }),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export function getFontCatalog(): FontCatalogStore {
|
||||
return (_catalog ??= new FontCatalogStore({ limit: 50 }));
|
||||
}
|
||||
export const getFontCatalog = catalog.get;
|
||||
|
||||
// test-only reset, so specs don't share a live observer
|
||||
export function __resetFontCatalog() {
|
||||
_catalog?.destroy();
|
||||
_catalog = undefined;
|
||||
}
|
||||
export const __resetFontCatalog = catalog.reset;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import {
|
||||
type FontLoadRequestConfig,
|
||||
@@ -419,18 +420,16 @@ export class FontLifecycleManager {
|
||||
}
|
||||
}
|
||||
|
||||
let _fontLifecycleManager: FontLifecycleManager | undefined;
|
||||
|
||||
/**
|
||||
* App-wide font lifecycle manager, created on first access. Lazy so its
|
||||
* AbortController / FontFace bookkeeping isn't set up at module load.
|
||||
*/
|
||||
export function getFontLifecycleManager(): FontLifecycleManager {
|
||||
return (_fontLifecycleManager ??= new FontLifecycleManager());
|
||||
}
|
||||
const fontLifecycleManager = createSingleton(
|
||||
() => new FontLifecycleManager(),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getFontLifecycleManager = fontLifecycleManager.get;
|
||||
|
||||
// test-only reset, so specs don't share loaded-font/eviction state
|
||||
export function __resetFontLifecycleManager() {
|
||||
_fontLifecycleManager?.destroy();
|
||||
_fontLifecycleManager = undefined;
|
||||
}
|
||||
export const __resetFontLifecycleManager = fontLifecycleManager.reset;
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('loadFont', () => {
|
||||
it('throws FontParseError when font.load() rejects', async () => {
|
||||
const loadError = new Error('parse failed');
|
||||
const MockFontFace = vi.fn(
|
||||
function(this: any, name: string, buffer: BufferSource, options: FontFaceDescriptors) {
|
||||
function(this: any, _name: string, _buffer: BufferSource, _options: FontFaceDescriptors) {
|
||||
this.load = vi.fn().mockRejectedValue(loadError);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import { fontKeys } from '$shared/api/queryKeys';
|
||||
import {
|
||||
beforeEach,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// Font lifecycle manager (browser-side load + cache + eviction)
|
||||
export * from './fontLifecycleManager/fontLifecycleManager.svelte';
|
||||
export {
|
||||
__resetFontLifecycleManager,
|
||||
FontLifecycleManager,
|
||||
getFontLifecycleManager,
|
||||
} from './fontLifecycleManager/fontLifecycleManager.svelte';
|
||||
|
||||
// Paginated catalog
|
||||
export { getFontCatalog } from './fontCatalogStore/fontCatalogStore.svelte';
|
||||
|
||||
export type { FontCatalogStore } from './fontCatalogStore/fontCatalogStore.svelte';
|
||||
|
||||
// Batch fetch by IDs (detail-cache seeding)
|
||||
|
||||
@@ -23,4 +23,7 @@ export type {
|
||||
FontCollectionState,
|
||||
} from './store';
|
||||
|
||||
export * from './store/fontLifecycle';
|
||||
export type {
|
||||
FontLoadRequestConfig,
|
||||
FontLoadStatus,
|
||||
} from './store/fontLifecycle';
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* MOCK FONT DATA
|
||||
* ============================================================================
|
||||
*
|
||||
* Factory functions and preset mock data for fonts.
|
||||
* Mock font data: factory functions and preset fixtures.
|
||||
* Used in Storybook stories, tests, and development.
|
||||
*
|
||||
* ## Usage
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* MOCK DATA HELPERS - MAIN EXPORT
|
||||
* ============================================================================
|
||||
*
|
||||
* Mock data helpers (main export).
|
||||
* Comprehensive mock data for Storybook stories, tests, and development.
|
||||
*
|
||||
* ## Quick Start
|
||||
|
||||
@@ -21,11 +21,7 @@
|
||||
*/
|
||||
|
||||
import type { UnifiedFont } from '$entities/Font/model/types';
|
||||
import type {
|
||||
QueryKey,
|
||||
QueryObserverResult,
|
||||
QueryStatus,
|
||||
} from '@tanstack/svelte-query';
|
||||
import type { QueryStatus } from '@tanstack/svelte-query';
|
||||
import {
|
||||
UNIFIED_FONTS,
|
||||
generateMockFonts,
|
||||
|
||||
+12
-2
@@ -4,7 +4,7 @@ import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import FontSampler from './FontSampler.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Features/FontSampler',
|
||||
title: 'Entities/Font/FontSampler',
|
||||
component: FontSampler,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
@@ -39,8 +39,8 @@ const { Story } = defineMeta({
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { UnifiedFont } from '$entities/Font';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import type { UnifiedFont } from '../../model/types';
|
||||
|
||||
// Mock fonts for testing
|
||||
const mockArial: UnifiedFont = {
|
||||
@@ -84,6 +84,14 @@ const mockGeorgia: UnifiedFont = {
|
||||
isVariable: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Stand-in for the AdjustTypography store the composing widget injects.
|
||||
const mockTypography = {
|
||||
renderedSize: 48,
|
||||
weight: 400,
|
||||
height: 1.5,
|
||||
spacing: 0,
|
||||
};
|
||||
</script>
|
||||
|
||||
<Story
|
||||
@@ -93,6 +101,7 @@ const mockGeorgia: UnifiedFont = {
|
||||
status: 'loaded',
|
||||
text: 'The quick brown fox jumps over the lazy dog',
|
||||
index: 0,
|
||||
typography: mockTypography,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||
@@ -111,6 +120,7 @@ const mockGeorgia: UnifiedFont = {
|
||||
text:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
|
||||
index: 1,
|
||||
typography: mockTypography,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||
+45
-21
@@ -4,12 +4,6 @@
|
||||
Visual design matches FontCard: sharp corners, red hover accent, header stats.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontApplicator,
|
||||
type FontLoadStatus,
|
||||
type UnifiedFont,
|
||||
} from '$entities/Font';
|
||||
import { getTypographySettingsStore } from '$features/AdjustTypography/model';
|
||||
import {
|
||||
Badge,
|
||||
ContentEditable,
|
||||
@@ -18,6 +12,35 @@ import {
|
||||
Stat,
|
||||
} from '$shared/ui';
|
||||
import { fly } from 'svelte/transition';
|
||||
import type {
|
||||
FontLoadStatus,
|
||||
UnifiedFont,
|
||||
} from '../../model/types';
|
||||
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
|
||||
|
||||
/**
|
||||
* Minimal typography contract this view renders with. The AdjustTypography
|
||||
* store satisfies it structurally; defining it here keeps the entity decoupled
|
||||
* from that feature (no entity -> feature import).
|
||||
*/
|
||||
interface FontSampleTypography {
|
||||
/**
|
||||
* Rendered font size in px
|
||||
*/
|
||||
renderedSize: number;
|
||||
/**
|
||||
* Numeric font weight
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Line-height multiplier
|
||||
*/
|
||||
height: number;
|
||||
/**
|
||||
* Letter spacing
|
||||
*/
|
||||
spacing: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -39,11 +62,15 @@ interface Props {
|
||||
* @default 0
|
||||
*/
|
||||
index?: number;
|
||||
/**
|
||||
* Typography settings to render the sample with. Injected by the composing
|
||||
* widget (which owns the AdjustTypography store) so this entity view stays
|
||||
* decoupled from that feature — the same inversion as `status`.
|
||||
*/
|
||||
typography: FontSampleTypography;
|
||||
}
|
||||
|
||||
let { font, status, text = $bindable(), index = 0 }: Props = $props();
|
||||
|
||||
const typographySettingsStore = getTypographySettingsStore();
|
||||
let { font, status, text = $bindable(), index = 0, typography }: Props = $props();
|
||||
|
||||
// Extract provider badge with fallback
|
||||
const providerBadge = $derived(
|
||||
@@ -52,10 +79,10 @@ const providerBadge = $derived(
|
||||
);
|
||||
|
||||
const stats = $derived([
|
||||
{ label: 'SZ', value: `${typographySettingsStore.renderedSize}PX` },
|
||||
{ label: 'WGT', value: `${typographySettingsStore.weight}` },
|
||||
{ label: 'LH', value: typographySettingsStore.height?.toFixed(2) },
|
||||
{ label: 'LTR', value: `${typographySettingsStore.spacing}` },
|
||||
{ label: 'SZ', value: `${typography.renderedSize}PX` },
|
||||
{ label: 'WGT', value: `${typography.weight}` },
|
||||
{ label: 'LH', value: typography.height.toFixed(2) },
|
||||
{ label: 'LTR', value: `${typography.spacing}` },
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -73,9 +100,8 @@ const stats = $derived([
|
||||
min-h-60
|
||||
rounded-none
|
||||
"
|
||||
style:font-weight={typographySettingsStore.weight}
|
||||
style:font-weight={typography.weight}
|
||||
>
|
||||
<!-- ── Header bar ─────────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="
|
||||
flex items-center justify-between
|
||||
@@ -136,19 +162,18 @@ const stats = $derived([
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Main content area ──────────────────────────────────────────── -->
|
||||
<div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10">
|
||||
<FontApplicator {font} {status}>
|
||||
<ContentEditable
|
||||
bind:text
|
||||
fontSize={typographySettingsStore.renderedSize}
|
||||
lineHeight={typographySettingsStore.height}
|
||||
letterSpacing={typographySettingsStore.spacing}
|
||||
fontSize={typography.renderedSize}
|
||||
lineHeight={typography.height}
|
||||
letterSpacing={typography.spacing}
|
||||
/>
|
||||
</FontApplicator>
|
||||
</div>
|
||||
|
||||
<!-- ── Mobile stats footer (md:hidden — header stats take over above) -->
|
||||
<!-- Mobile stats footer; md:hidden because the header stats take over above -->
|
||||
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-subtle flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
|
||||
{#each stats as stat, i}
|
||||
<Footnote class="text-5xs sm:text-4xs tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
||||
@@ -160,7 +185,6 @@ const stats = $derived([
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- ── Red hover line ─────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="
|
||||
absolute bottom-0 left-0 right-0
|
||||
@@ -1,7 +1,9 @@
|
||||
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
||||
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
||||
|
||||
export {
|
||||
FontApplicator,
|
||||
FontSampler,
|
||||
FontVirtualList,
|
||||
};
|
||||
|
||||
+13
-16
@@ -15,10 +15,14 @@ import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
} from '$entities/Font';
|
||||
// Deep path (not the root barrel) on purpose: pulls only these pure
|
||||
// constants, not the entity's UI/store graph (+ @tanstack) — keeps this
|
||||
// feature store and its spec light at import. See audit D-1.
|
||||
} from '$entities/Font/model/const/const';
|
||||
import {
|
||||
type PersistentStore,
|
||||
createPersistentStore,
|
||||
createSingleton,
|
||||
} from '$shared/lib';
|
||||
import type { NumericControl } from '$shared/ui';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
@@ -166,6 +170,7 @@ export class TypographySettingsStore {
|
||||
*/
|
||||
destroy(): void {
|
||||
this.#disposeEffects();
|
||||
this.#storage.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,9 +307,6 @@ export class TypographySettingsStore {
|
||||
if (c.id === 'font_size') {
|
||||
c.instance.value = defaults.fontSize * this.#multiplier;
|
||||
} else {
|
||||
// Map storage key to control id
|
||||
const key = c.id.replace('_', '') as keyof TypographySettings;
|
||||
// Simplified for brevity, you'd map these properly:
|
||||
if (c.id === 'font_weight') {
|
||||
c.instance.value = defaults.fontWeight;
|
||||
}
|
||||
@@ -351,22 +353,17 @@ export function createTypographySettingsStore(
|
||||
|
||||
export type TypographySettingsStoreInstance = ReturnType<typeof createTypographySettingsStore>;
|
||||
|
||||
let _typographySettingsStore: TypographySettingsStoreInstance | undefined;
|
||||
|
||||
/**
|
||||
* App-wide typography settings store, keyed for the comparison view.
|
||||
* Created on first access so its persistent-store sync effects aren't set up
|
||||
* at module load.
|
||||
*/
|
||||
export function getTypographySettingsStore(): TypographySettingsStoreInstance {
|
||||
return (_typographySettingsStore ??= createTypographySettingsStore(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
COMPARISON_STORAGE_KEY,
|
||||
));
|
||||
}
|
||||
const typographySettingsStore = createSingleton(
|
||||
() => createTypographySettingsStore(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, COMPARISON_STORAGE_KEY),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getTypographySettingsStore = typographySettingsStore.get;
|
||||
|
||||
// test-only reset, so specs don't share persisted typography state or leak effects
|
||||
export function __resetTypographySettingsStore() {
|
||||
_typographySettingsStore?.destroy();
|
||||
_typographySettingsStore = undefined;
|
||||
}
|
||||
export const __resetTypographySettingsStore = typographySettingsStore.reset;
|
||||
|
||||
+4
-1
@@ -6,7 +6,7 @@ import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
} from '$entities/Font';
|
||||
} from '$entities/Font/model/const/const';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
@@ -51,6 +51,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
||||
let mockPersistentStore: {
|
||||
value: TypographySettings;
|
||||
clear: () => void;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
const createMockPersistentStore = (initialValue: TypographySettings) => {
|
||||
@@ -70,6 +71,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||
};
|
||||
},
|
||||
destroy() {},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -535,6 +537,7 @@ describe('TypographySettingsStore - Unit Tests', () => {
|
||||
mockStorage = v;
|
||||
},
|
||||
clear: clearSpy,
|
||||
destroy() {},
|
||||
};
|
||||
|
||||
const manager = new TypographySettingsStore(
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
Button,
|
||||
ComboControl,
|
||||
ControlGroup,
|
||||
Popover,
|
||||
Slider,
|
||||
} from '$shared/ui';
|
||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { Popover } from 'bits-ui';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
@@ -74,33 +74,21 @@ $effect(() => {
|
||||
{#if !hidden}
|
||||
{#if responsive.isMobileOrTablet}
|
||||
<div class={className}>
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Popover bind:open side="top" align="end" sideOffset={8}>
|
||||
{#snippet trigger(props)}
|
||||
<Button variant="primary" {...props}>
|
||||
{#snippet icon()}
|
||||
<Settings2Icon class="size-4" />
|
||||
{/snippet}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
{#snippet children({ close })}
|
||||
<div
|
||||
class={cn(
|
||||
'z-50 w-72 p-4 rounded-none',
|
||||
'w-72 p-4 rounded-none',
|
||||
'surface-popover',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=top]:slide-in-from-bottom-2',
|
||||
'data-[side=bottom]:slide-in-from-top-2',
|
||||
)}
|
||||
interactOutsideBehavior="close"
|
||||
escapeKeydownBehavior="close"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-3 pb-3 border-b border-subtle">
|
||||
@@ -112,17 +100,13 @@ $effect(() => {
|
||||
CONTROLS
|
||||
</span>
|
||||
</div>
|
||||
<Popover.Close>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
onclick={close}
|
||||
class="flex-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
aria-label="Close controls"
|
||||
>
|
||||
<XIcon class="size-3.5 text-neutral-500" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Popover.Close>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
@@ -136,9 +120,9 @@ $effect(() => {
|
||||
/>
|
||||
</ControlGroup>
|
||||
{/each}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
export * from './store/scrollBreadcrumbsStore.svelte';
|
||||
export * from './types/types.ts';
|
||||
export {
|
||||
__resetScrollBreadcrumbsStore,
|
||||
createScrollBreadcrumbsStore,
|
||||
getScrollBreadcrumbsStore,
|
||||
} from './store/scrollBreadcrumbsStore.svelte';
|
||||
export type { BreadcrumbItem } from './store/scrollBreadcrumbsStore.svelte';
|
||||
export type { NavigationAction } from './types/types.ts';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
|
||||
/**
|
||||
* Scroll-based breadcrumb tracking store
|
||||
*
|
||||
@@ -279,17 +281,15 @@ export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
|
||||
return new ScrollBreadcrumbsStore();
|
||||
}
|
||||
|
||||
let _scrollBreadcrumbsStore: ScrollBreadcrumbsStore | undefined;
|
||||
|
||||
/**
|
||||
* App-wide scroll breadcrumbs store, created on first access.
|
||||
*/
|
||||
export function getScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
|
||||
return (_scrollBreadcrumbsStore ??= createScrollBreadcrumbsStore());
|
||||
}
|
||||
const scrollBreadcrumbsStore = createSingleton(
|
||||
() => createScrollBreadcrumbsStore(),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getScrollBreadcrumbsStore = scrollBreadcrumbsStore.get;
|
||||
|
||||
// test-only reset, so specs don't share observer/scroll state
|
||||
export function __resetScrollBreadcrumbsStore() {
|
||||
_scrollBreadcrumbsStore?.destroy();
|
||||
_scrollBreadcrumbsStore = undefined;
|
||||
}
|
||||
export const __resetScrollBreadcrumbsStore = scrollBreadcrumbsStore.reset;
|
||||
|
||||
@@ -70,7 +70,6 @@ class MockIntersectionObserver implements IntersectionObserver {
|
||||
describe('ScrollBreadcrumbsStore', () => {
|
||||
let scrollListeners: Array<() => void> = [];
|
||||
let addEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
||||
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
||||
let scrollToSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
// Helper to create mock elements
|
||||
@@ -111,7 +110,7 @@ describe('ScrollBreadcrumbsStore', () => {
|
||||
|
||||
// Track scroll event listeners
|
||||
addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(
|
||||
(event: string, listener: EventListenerOrEventListenerObject, options?: any) => {
|
||||
(event: string, listener: EventListenerOrEventListenerObject, _options?: any) => {
|
||||
if (event === 'scroll') {
|
||||
scrollListeners.push(listener as () => void);
|
||||
}
|
||||
@@ -119,7 +118,7 @@ describe('ScrollBreadcrumbsStore', () => {
|
||||
},
|
||||
);
|
||||
|
||||
removeEventListenerSpy = vi.spyOn(window, 'removeEventListener').mockImplementation(
|
||||
vi.spyOn(window, 'removeEventListener').mockImplementation(
|
||||
(event: string, listener: EventListenerOrEventListenerObject) => {
|
||||
if (event === 'scroll') {
|
||||
const index = scrollListeners.indexOf(listener as () => void);
|
||||
|
||||
@@ -11,10 +11,14 @@ const sections = [
|
||||
{ index: 102, title: 'Spacing' },
|
||||
];
|
||||
|
||||
/** @type {HTMLDivElement} */
|
||||
let container;
|
||||
/** @type {HTMLDivElement | undefined} */
|
||||
let container = $state();
|
||||
|
||||
onMount(() => {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const section of sections) {
|
||||
const el = /** @type {HTMLElement} */ (container.querySelector(`[data-story-index="${section.index}"]`));
|
||||
scrollBreadcrumbsStore.add({ index: section.index, title: section.title, element: el }, 96);
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './model';
|
||||
export * from './ui';
|
||||
export { getThemeManager } from './model';
|
||||
export { ThemeSwitch } from './ui';
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
import {
|
||||
createPersistentStore,
|
||||
createSingleton,
|
||||
} from '$shared/lib';
|
||||
|
||||
export const STORAGE_KEY = 'glyphdiff:theme';
|
||||
|
||||
@@ -125,6 +128,7 @@ class ThemeManager {
|
||||
destroy(): void {
|
||||
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
|
||||
this.#mediaQuery = null;
|
||||
this.#store.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,23 +198,18 @@ class ThemeManager {
|
||||
}
|
||||
}
|
||||
|
||||
let _themeManager: ThemeManager | undefined;
|
||||
|
||||
/**
|
||||
* App-wide theme manager, created on first access.
|
||||
*
|
||||
* Lazy so its persistent-store subscription isn't set up at module load.
|
||||
* Call init() on mount and destroy() on unmount (see Layout).
|
||||
*/
|
||||
export function getThemeManager(): ThemeManager {
|
||||
return (_themeManager ??= new ThemeManager());
|
||||
}
|
||||
const themeManager = createSingleton(() => new ThemeManager(), instance => instance.destroy());
|
||||
|
||||
export const getThemeManager = themeManager.get;
|
||||
|
||||
// test-only reset, so specs don't share persisted theme state
|
||||
export function __resetThemeManager() {
|
||||
_themeManager?.destroy();
|
||||
_themeManager = undefined;
|
||||
}
|
||||
export const __resetThemeManager = themeManager.reset;
|
||||
|
||||
/**
|
||||
* ThemeManager class exported for testing purposes
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { FontSampler } from './ui';
|
||||
@@ -1,3 +0,0 @@
|
||||
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||
|
||||
export { FontSampler };
|
||||
@@ -1 +1,6 @@
|
||||
export * from './filters/filters';
|
||||
export { fetchProxyFilters } from './filters/filters';
|
||||
export type {
|
||||
FilterMetadata,
|
||||
FilterOption,
|
||||
ProxyFiltersResponse,
|
||||
} from './filters/filters';
|
||||
|
||||
+11
-10
@@ -23,7 +23,10 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createFilter } from '$shared/lib';
|
||||
import {
|
||||
createFilter,
|
||||
createSingleton,
|
||||
} from '$shared/lib';
|
||||
import { createDebouncedState } from '$shared/lib/helpers';
|
||||
import type {
|
||||
FilterConfig,
|
||||
@@ -129,8 +132,6 @@ export function createAppliedFilterStore<TValue extends string>(config: FilterCo
|
||||
|
||||
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
|
||||
|
||||
let _appliedFilterStore: AppliedFilterStore | undefined;
|
||||
|
||||
/**
|
||||
* App-wide filter manager, created on first access.
|
||||
*
|
||||
@@ -138,14 +139,14 @@ let _appliedFilterStore: AppliedFilterStore | undefined;
|
||||
* lives in `./bindings.svelte` and populates groups once backend filter
|
||||
* metadata arrives.
|
||||
*/
|
||||
export function getAppliedFilterStore(): AppliedFilterStore {
|
||||
return (_appliedFilterStore ??= createAppliedFilterStore<string>({
|
||||
const appliedFilterStore = createSingleton(() =>
|
||||
createAppliedFilterStore<string>({
|
||||
queryValue: '',
|
||||
groups: [],
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export const getAppliedFilterStore = appliedFilterStore.get;
|
||||
|
||||
// test-only reset, so specs don't share filter/selection state
|
||||
export function __resetAppliedFilterStore() {
|
||||
_appliedFilterStore = undefined;
|
||||
}
|
||||
export const __resetAppliedFilterStore = appliedFilterStore.reset;
|
||||
|
||||
-6
@@ -29,12 +29,6 @@ import { createAppliedFilterStore } from './appliedFilterStore.svelte';
|
||||
* testing Svelte 5 reactive code in Node.js.
|
||||
*/
|
||||
|
||||
// Helper to flush Svelte effects (they run in microtasks)
|
||||
async function flushEffects() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
// Helper to create test properties
|
||||
function createTestProperties(count: number, selectedIndices: number[] = []): Property<string>[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
|
||||
+10
-11
@@ -20,8 +20,9 @@ import type { FilterMetadata } from '$features/FilterAndSortFonts/api/filters/fi
|
||||
import {
|
||||
DEFAULT_QUERY_GC_TIME_MS,
|
||||
DEFAULT_QUERY_STALE_TIME_MS,
|
||||
queryClient,
|
||||
getQueryClient,
|
||||
} from '$shared/api/queryClient';
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
import {
|
||||
type QueryKey,
|
||||
QueryObserver,
|
||||
@@ -49,7 +50,7 @@ export class AvailableFilterStore {
|
||||
/**
|
||||
* Shared query client
|
||||
*/
|
||||
protected qc = queryClient;
|
||||
protected qc = getQueryClient();
|
||||
|
||||
/**
|
||||
* Creates a new filters store
|
||||
@@ -126,18 +127,16 @@ export class AvailableFilterStore {
|
||||
}
|
||||
}
|
||||
|
||||
let _availableFilterStore: AvailableFilterStore | undefined;
|
||||
|
||||
/**
|
||||
* App-wide filter-metadata store, created on first access. Lazy so the
|
||||
* QueryObserver isn't constructed at module load.
|
||||
*/
|
||||
export function getAvailableFilterStore(): AvailableFilterStore {
|
||||
return (_availableFilterStore ??= new AvailableFilterStore());
|
||||
}
|
||||
const availableFilterStore = createSingleton(
|
||||
() => new AvailableFilterStore(),
|
||||
instance => instance.destroy(),
|
||||
);
|
||||
|
||||
export const getAvailableFilterStore = availableFilterStore.get;
|
||||
|
||||
// test-only reset, so specs don't share a live observer
|
||||
export function __resetAvailableFilterStore() {
|
||||
_availableFilterStore?.destroy();
|
||||
_availableFilterStore = undefined;
|
||||
}
|
||||
export const __resetAvailableFilterStore = availableFilterStore.reset;
|
||||
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* observer, so it lives at module scope, not in any individual widget.
|
||||
*/
|
||||
|
||||
import { getFontCatalog } from '$entities/Font/model';
|
||||
import { getFontCatalog } from '$entities/Font';
|
||||
import { untrack } from 'svelte';
|
||||
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
|
||||
import { mapFilterMetadataToGroups } from '../../lib/mapper/mapFilterMetadataToGroups';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
||||
|
||||
/**
|
||||
* Sort store — manages the current sort option for font listings.
|
||||
*
|
||||
@@ -46,16 +48,12 @@ export function createSortStore(initial: SortOption = 'Popularity') {
|
||||
|
||||
export type SortStore = ReturnType<typeof createSortStore>;
|
||||
|
||||
let _sortStore: SortStore | undefined;
|
||||
|
||||
/**
|
||||
* App-wide sort store, created on first access.
|
||||
*/
|
||||
export function getSortStore(): SortStore {
|
||||
return (_sortStore ??= createSortStore());
|
||||
}
|
||||
const sortStore = createSingleton(() => createSortStore());
|
||||
|
||||
export const getSortStore = sortStore.get;
|
||||
|
||||
// test-only reset, so specs don't share selection state
|
||||
export function __resetSortStore() {
|
||||
_sortStore = undefined;
|
||||
}
|
||||
export const __resetSortStore = sortStore.reset;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createRouter } from 'sv-router';
|
||||
import Home from './Home.svelte';
|
||||
import Redirect from './Redirect.svelte';
|
||||
|
||||
/**
|
||||
* Single-page router for glyphdiff.
|
||||
@@ -18,6 +17,8 @@ export const {
|
||||
'/': Home,
|
||||
/**
|
||||
* Any unmatched path redirects to home until additional routes exist.
|
||||
* Lazy-loaded so `router` doesn't statically import `Redirect`, which
|
||||
* imports `navigate` from here — breaks the import cycle.
|
||||
*/
|
||||
'*notfound': Redirect,
|
||||
'*notfound': () => import('./Redirect.svelte'),
|
||||
});
|
||||
|
||||
@@ -27,11 +27,16 @@ export const QUERY_RETRY_BASE_DELAY_MS = 1000;
|
||||
*/
|
||||
export const QUERY_RETRY_MAX_DELAY_MS = 30000;
|
||||
|
||||
let queryClientInstance: QueryClient | undefined;
|
||||
|
||||
/**
|
||||
* TanStack Query client instance
|
||||
* Shared TanStack Query client (lazy singleton).
|
||||
*
|
||||
* Configured for optimal caching and refetching behavior.
|
||||
* Used by all font stores for data fetching and caching.
|
||||
* Construction is deferred to the first call so importing this module is inert:
|
||||
* module eval runs no `new QueryClient()`, so the module is genuinely
|
||||
* side-effect-free and needs no `sideEffects` allowlist exception. The
|
||||
* app-layer `QueryProvider` is the first caller; every store reuses the same
|
||||
* instance. Matches the lazy-accessor pattern used by the font stores.
|
||||
*
|
||||
* Cache behavior:
|
||||
* - Data stays fresh for 5 minutes (staleTime)
|
||||
@@ -39,7 +44,8 @@ export const QUERY_RETRY_MAX_DELAY_MS = 30000;
|
||||
* - No refetch on window focus (reduces unnecessary network requests)
|
||||
* - 3 retries with exponential backoff on failure
|
||||
*/
|
||||
export const queryClient = new QueryClient({
|
||||
export function getQueryClient(): QueryClient {
|
||||
return (queryClientInstance ??= new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
|
||||
@@ -65,4 +71,5 @@ export const queryClient = new QueryClient({
|
||||
Math.min(QUERY_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, QUERY_RETRY_MAX_DELAY_MS),
|
||||
},
|
||||
},
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
import {
|
||||
QueryObserver,
|
||||
type QueryObserverOptions,
|
||||
@@ -20,7 +20,7 @@ export abstract class BaseQueryStore<TData, TError = Error> {
|
||||
#unsubscribe: () => void;
|
||||
|
||||
constructor(options: QueryObserverOptions<TData, TError, TData, any, any>) {
|
||||
this.#observer = new QueryObserver(queryClient, options);
|
||||
this.#observer = new QueryObserver(getQueryClient(), options);
|
||||
this.#unsubscribe = this.#observer.subscribe(result => {
|
||||
this.#result = result;
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
|
||||
@@ -1,66 +1,15 @@
|
||||
/**
|
||||
* Persistent localStorage-backed reactive state
|
||||
* Reactive localStorage-backed state. Loads on init, saves on change via an
|
||||
* $effect.root. Falls back to the default on SSR (no localStorage) and on JSON
|
||||
* parse errors; swallows quota/write errors with a warning.
|
||||
*
|
||||
* Creates reactive state that automatically syncs with localStorage.
|
||||
* Values persist across browser sessions and are restored on page load.
|
||||
* Owners that create this outside a component must call destroy() to dispose
|
||||
* the save effect.
|
||||
*
|
||||
* Handles edge cases:
|
||||
* - SSR safety (no localStorage on server)
|
||||
* - JSON parse errors (falls back to default)
|
||||
* - Storage quota errors (logs warning, doesn't crash)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Store user preferences
|
||||
* const preferences = createPersistentStore('user-prefs', {
|
||||
* theme: 'dark',
|
||||
* fontSize: 16,
|
||||
* sidebarOpen: true
|
||||
* });
|
||||
*
|
||||
* // Access reactive state
|
||||
* $: currentTheme = preferences.value.theme;
|
||||
*
|
||||
* // Update (auto-saves to localStorage)
|
||||
* preferences.value.theme = 'light';
|
||||
*
|
||||
* // Clear stored value
|
||||
* preferences.clear();
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a reactive store backed by localStorage
|
||||
*
|
||||
* The value is loaded from localStorage on initialization and automatically
|
||||
* saved whenever it changes. Uses Svelte 5's $effect for reactive sync.
|
||||
*
|
||||
* @param key - localStorage key for storing the value
|
||||
* @param defaultValue - Default value if no stored value exists
|
||||
* @returns Persistent store with getter/setter and clear method
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Simple value
|
||||
* const counter = createPersistentStore('counter', 0);
|
||||
* counter.value++;
|
||||
*
|
||||
* // Complex object
|
||||
* interface Settings {
|
||||
* theme: 'light' | 'dark';
|
||||
* fontSize: number;
|
||||
* }
|
||||
* const settings = createPersistentStore<Settings>('app-settings', {
|
||||
* theme: 'light',
|
||||
* fontSize: 16
|
||||
* });
|
||||
* ```
|
||||
* @param key - localStorage key
|
||||
* @param defaultValue - value used when nothing is stored
|
||||
*/
|
||||
export function createPersistentStore<T>(key: string, defaultValue: T) {
|
||||
/**
|
||||
* Load value from localStorage or return default
|
||||
* Safely handles missing keys, parse errors, and SSR
|
||||
*/
|
||||
const loadFromStorage = (): T => {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultValue;
|
||||
@@ -76,9 +25,13 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
|
||||
|
||||
let value = $state<T>(loadFromStorage());
|
||||
|
||||
// Sync to storage whenever value changes
|
||||
// Wrapped in $effect.root to prevent memory leaks
|
||||
$effect.root(() => {
|
||||
/**
|
||||
* Sync to storage whenever value changes. The effect lives in an
|
||||
* $effect.root so it outlives any component; the returned disposer is kept
|
||||
* and run by destroy(), because an $effect.root with no disposer leaks for
|
||||
* the life of the process.
|
||||
*/
|
||||
const dispose = $effect.root(() => {
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
@@ -113,6 +66,15 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
|
||||
}
|
||||
value = defaultValue;
|
||||
},
|
||||
|
||||
/**
|
||||
* Dispose the storage-sync effect. Owners that create a store outside a
|
||||
* component (e.g. a singleton store class) must call this to avoid
|
||||
* leaking the underlying $effect.root.
|
||||
*/
|
||||
destroy() {
|
||||
dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { flushSync } from 'svelte';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
@@ -376,4 +377,39 @@ describe('createPersistentStore', () => {
|
||||
expect(store.value[0].name).toBe('First');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lifecycle', () => {
|
||||
it('persists value changes via the sync effect', () => {
|
||||
const store = createPersistentStore(testKey, 'a');
|
||||
const spy = vi.spyOn(mockLocalStorage, 'setItem');
|
||||
|
||||
store.value = 'b';
|
||||
flushSync();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(testKey, JSON.stringify('b'));
|
||||
});
|
||||
|
||||
it('stops persisting after destroy()', () => {
|
||||
const store = createPersistentStore(testKey, 'a');
|
||||
flushSync();
|
||||
store.destroy();
|
||||
|
||||
const spy = vi.spyOn(mockLocalStorage, 'setItem');
|
||||
store.value = 'c';
|
||||
flushSync();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
// reading still works after disposal
|
||||
expect(store.value).toBe('c');
|
||||
});
|
||||
|
||||
it('destroy() is safe to call repeatedly', () => {
|
||||
const store = createPersistentStore(testKey, 'a');
|
||||
|
||||
expect(() => {
|
||||
store.destroy();
|
||||
store.destroy();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { createSingleton } from './createSingleton';
|
||||
|
||||
describe('createSingleton', () => {
|
||||
it('does not call the factory until the first get (lazy)', () => {
|
||||
const factory = vi.fn(() => ({ id: 1 }));
|
||||
createSingleton(factory);
|
||||
expect(factory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('constructs on first get and memoizes the instance', () => {
|
||||
const factory = vi.fn(() => ({ id: 1 }));
|
||||
const singleton = createSingleton(factory);
|
||||
|
||||
const a = singleton.get();
|
||||
const b = singleton.get();
|
||||
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('rebuilds a fresh instance after reset', () => {
|
||||
let count = 0;
|
||||
const singleton = createSingleton(() => ({ id: ++count }));
|
||||
|
||||
const first = singleton.get();
|
||||
singleton.reset();
|
||||
const second = singleton.get();
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(second.id).toBe(2);
|
||||
});
|
||||
|
||||
it('runs teardown once, with the live instance, on reset', () => {
|
||||
const teardown = vi.fn();
|
||||
const singleton = createSingleton(() => ({ id: 1 }), teardown);
|
||||
|
||||
const instance = singleton.get();
|
||||
singleton.reset();
|
||||
|
||||
expect(teardown).toHaveBeenCalledTimes(1);
|
||||
expect(teardown).toHaveBeenCalledWith(instance);
|
||||
});
|
||||
|
||||
it('treats reset before any get as a no-op (no teardown, no throw)', () => {
|
||||
const teardown = vi.fn();
|
||||
const singleton = createSingleton(() => ({ id: 1 }), teardown);
|
||||
|
||||
expect(() => singleton.reset()).not.toThrow();
|
||||
expect(teardown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not run teardown again on a second consecutive reset', () => {
|
||||
const teardown = vi.fn();
|
||||
const singleton = createSingleton(() => ({ id: 1 }), teardown);
|
||||
|
||||
singleton.get();
|
||||
singleton.reset();
|
||||
singleton.reset();
|
||||
|
||||
expect(teardown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('works without a teardown', () => {
|
||||
const singleton = createSingleton(() => ({ id: 1 }));
|
||||
|
||||
singleton.get();
|
||||
expect(() => singleton.reset()).not.toThrow();
|
||||
expect(singleton.get().id).toBe(1);
|
||||
});
|
||||
|
||||
it('caches a falsy instance value without re-running the factory', () => {
|
||||
const factory = vi.fn(() => undefined);
|
||||
const singleton = createSingleton<undefined>(factory);
|
||||
|
||||
singleton.get();
|
||||
singleton.get();
|
||||
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* A lazily-constructed singleton accessor pair.
|
||||
*/
|
||||
export interface Singleton<T> {
|
||||
/**
|
||||
* Returns the instance, constructing it on the first call and reusing it
|
||||
* thereafter.
|
||||
*/
|
||||
get: () => T;
|
||||
/**
|
||||
* Tears down the current instance (if built) and clears it, so the next
|
||||
* `get()` rebuilds. Used by specs to avoid shared state between tests.
|
||||
*/
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardizes the lazy `getX()` / `__resetX()` singleton pattern used by the
|
||||
* app's stores.
|
||||
*
|
||||
* The instance is built on the first `get()` and reused afterwards; `reset()`
|
||||
* runs the optional teardown against the live instance and clears it. Building
|
||||
* lazily keeps the owning module inert at import — construction happens only on
|
||||
* first access, never at module eval.
|
||||
*
|
||||
* @param factory - Builds the instance on first access.
|
||||
* @param teardown - Optional cleanup run against the live instance on reset
|
||||
* (e.g. disposing an `$effect.root` via the instance's `destroy()`).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const catalog = createSingleton(() => new FontCatalogStore({ limit: 50 }), c => c.destroy());
|
||||
* export const getFontCatalog = catalog.get;
|
||||
* export const __resetFontCatalog = catalog.reset;
|
||||
* ```
|
||||
*/
|
||||
export function createSingleton<T>(factory: () => T, teardown?: (instance: T) => void): Singleton<T> {
|
||||
let instance: T | undefined;
|
||||
let initialized = false;
|
||||
|
||||
return {
|
||||
get: () => {
|
||||
if (!initialized) {
|
||||
instance = factory();
|
||||
initialized = true;
|
||||
}
|
||||
return instance as T;
|
||||
},
|
||||
reset: () => {
|
||||
if (initialized) {
|
||||
teardown?.(instance as T);
|
||||
}
|
||||
instance = undefined;
|
||||
initialized = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -156,7 +156,7 @@ export function createVirtualizer<T>(
|
||||
const offsets = $derived.by(() => {
|
||||
const count = options.count;
|
||||
// Implicit dependency on version signal
|
||||
const v = _version;
|
||||
const _v = _version;
|
||||
const result = new Float64Array(count);
|
||||
let accumulated = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
@@ -180,7 +180,7 @@ export function createVirtualizer<T>(
|
||||
// this derivation when the items array is replaced!
|
||||
const { count, data } = options;
|
||||
// Implicit dependency
|
||||
const v = _version;
|
||||
const _v = _version;
|
||||
if (count === 0 || containerHeight === 0 || !data) {
|
||||
return [];
|
||||
}
|
||||
@@ -268,7 +268,6 @@ export function createVirtualizer<T>(
|
||||
return rect.top + scrollY;
|
||||
};
|
||||
|
||||
let cachedOffsetTop = 0;
|
||||
let rafId: number | null = null;
|
||||
containerHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
|
||||
|
||||
@@ -292,14 +291,12 @@ export function createVirtualizer<T>(
|
||||
const handleResize = () => {
|
||||
containerHeight = window.innerHeight;
|
||||
elementOffsetTop = getElementOffset();
|
||||
cachedOffsetTop = elementOffsetTop;
|
||||
handleScroll();
|
||||
};
|
||||
|
||||
// Initial setup
|
||||
requestAnimationFrame(() => {
|
||||
elementOffsetTop = getElementOffset();
|
||||
cachedOffsetTop = elementOffsetTop;
|
||||
handleScroll();
|
||||
});
|
||||
|
||||
|
||||
@@ -137,6 +137,20 @@ export {
|
||||
type PerspectiveManager,
|
||||
} from './createPerspectiveManager/createPerspectiveManager.svelte';
|
||||
|
||||
/**
|
||||
* Lazy singletons
|
||||
*/
|
||||
export {
|
||||
/**
|
||||
* Lazy `getX()` / `__resetX()` singleton accessor factory
|
||||
*/
|
||||
createSingleton,
|
||||
/**
|
||||
* Singleton accessor pair type
|
||||
*/
|
||||
type Singleton,
|
||||
} from './createSingleton/createSingleton';
|
||||
|
||||
/*
|
||||
* BaseQueryStore is intentionally NOT re-exported here.
|
||||
* It pulls @tanstack/query-core, so routing it through this leaf barrel would
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
createPersistentStore,
|
||||
createPerspectiveManager,
|
||||
createResponsiveManager,
|
||||
createSingleton,
|
||||
createVirtualizer,
|
||||
type Entity,
|
||||
type EntityStore,
|
||||
@@ -21,6 +22,7 @@ export {
|
||||
type Property,
|
||||
type ResponsiveManager,
|
||||
responsiveManager,
|
||||
type Singleton,
|
||||
type VirtualItem,
|
||||
type Virtualizer,
|
||||
type VirtualizerOptions,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* STORYBOOK HELPERS
|
||||
* ============================================================================
|
||||
*
|
||||
* Helper components and utilities for Storybook stories.
|
||||
* Storybook helpers: components and utilities for stories.
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
|
||||
@@ -3,10 +3,6 @@ import type {
|
||||
TransitionConfig,
|
||||
} from 'svelte/transition';
|
||||
|
||||
function elasticOut(t: number) {
|
||||
return Math.pow(2, -10 * t) * Math.sin((t - 0.075) * (2 * Math.PI) / 0.3) + 1;
|
||||
}
|
||||
|
||||
function gentleSpring(t: number) {
|
||||
return 1 - Math.pow(1 - t, 3) * Math.cos(t * Math.PI * 2);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDecimalPlaces } from '$shared/lib/utils';
|
||||
import { getDecimalPlaces } from '../getDecimalPlaces/getDecimalPlaces';
|
||||
|
||||
/**
|
||||
* Rounds a value to match the precision of a given step
|
||||
|
||||
@@ -24,9 +24,14 @@
|
||||
*/
|
||||
export function splitArray<T>(array: T[], callback: (item: T) => boolean) {
|
||||
return array.reduce<[T[], T[]]>(
|
||||
([pass, fail], item) => (
|
||||
callback(item) ? pass.push(item) : fail.push(item), [pass, fail]
|
||||
),
|
||||
([pass, fail], item) => {
|
||||
if (callback(item)) {
|
||||
pass.push(item);
|
||||
} else {
|
||||
fail.push(item);
|
||||
}
|
||||
return [pass, fail];
|
||||
},
|
||||
[[], []],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import {
|
||||
type LabelSize,
|
||||
labelSizeConfig,
|
||||
} from '$shared/ui/Label/config';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
} from '../labelConfig';
|
||||
|
||||
type BadgeVariant = 'default' | 'accent' | 'success' | 'warning' | 'info';
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import { Slider } from '$shared/ui';
|
||||
import { Button } from '$shared/ui/Button';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import { Popover } from 'bits-ui';
|
||||
import { Button } from '../Button';
|
||||
import Popover from '../Popover/Popover.svelte';
|
||||
import Slider from '../Slider/Slider.svelte';
|
||||
import TechText from '../TechText/TechText.svelte';
|
||||
import type {
|
||||
ControlLabels,
|
||||
@@ -84,9 +84,11 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
{formattedValue()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ── FULL MODE ──────────────────────────────────────────────────────────────── -->
|
||||
{:else}
|
||||
<!--
|
||||
FULL MODE
|
||||
+/- buttons flanking a slider popover.
|
||||
-->
|
||||
<div class={cn('flex items-center px-1 relative', className)}>
|
||||
<!-- Decrease button -->
|
||||
<Button
|
||||
@@ -103,9 +105,8 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
|
||||
<!-- Trigger -->
|
||||
<div class="relative mx-1">
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Popover bind:open side="top" align="center">
|
||||
{#snippet trigger(props)}
|
||||
<button
|
||||
{...props}
|
||||
class={cn(
|
||||
@@ -138,14 +139,10 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
</TechText>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<!-- Vertical slider popover -->
|
||||
<Popover.Content
|
||||
class="w-auto py-4 px-3 h-64 flex-center rounded-none surface-card-elevated"
|
||||
align="center"
|
||||
side="top"
|
||||
>
|
||||
{#snippet children()}
|
||||
<div class="w-auto py-4 px-3 h-64 flex-center rounded-none surface-card-elevated">
|
||||
<Slider
|
||||
class="h-full"
|
||||
bind:value={control.value}
|
||||
@@ -154,8 +151,9 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
step={control.step}
|
||||
orientation="vertical"
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<!-- Increase button -->
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/svelte';
|
||||
import ComboControl from './ComboControl.svelte';
|
||||
import { createNumericControlMock } from './testing/createNumericControlMock.svelte';
|
||||
@@ -16,6 +17,16 @@ function makeControl(value: number, opts: { min?: number; max?: number; step?: n
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The trigger is the button wired to the popover (has popovertarget). The native
|
||||
* Popover always renders its content (the vertical slider, which also displays the
|
||||
* value) in the DOM, so value assertions must be scoped to the trigger to avoid
|
||||
* matching the slider's own value label.
|
||||
*/
|
||||
function getTrigger(): HTMLElement {
|
||||
return document.querySelector('button[popovertarget]') as HTMLElement;
|
||||
}
|
||||
|
||||
describe('ComboControl', () => {
|
||||
describe('Rendering', () => {
|
||||
it('renders decrease and increase buttons', () => {
|
||||
@@ -26,17 +37,17 @@ describe('ComboControl', () => {
|
||||
|
||||
it('renders the current integer value', () => {
|
||||
render(ComboControl, { control: makeControl(42) });
|
||||
expect(screen.getByText('42')).toBeInTheDocument();
|
||||
expect(within(getTrigger()).getByText('42')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats decimal value to 1 decimal place when step >= 0.1', () => {
|
||||
render(ComboControl, { control: makeControl(1.5, { step: 0.1 }) });
|
||||
expect(screen.getByText('1.5')).toBeInTheDocument();
|
||||
expect(within(getTrigger()).getByText('1.5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats decimal value to 2 decimal places when step < 0.1', () => {
|
||||
render(ComboControl, { control: makeControl(1.55, { step: 0.01 }) });
|
||||
expect(screen.getByText('1.55')).toBeInTheDocument();
|
||||
expect(within(getTrigger()).getByText('1.55')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders label when label prop is provided', () => {
|
||||
@@ -106,16 +117,32 @@ describe('ComboControl', () => {
|
||||
const control = makeControl(50);
|
||||
render(ComboControl, { control });
|
||||
await fireEvent.click(screen.getByLabelText('Increase'));
|
||||
await waitFor(() => expect(screen.getByText('51')).toBeInTheDocument());
|
||||
await waitFor(() => expect(within(getTrigger()).getByText('51')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Popover', () => {
|
||||
it('opens popover with vertical slider on trigger click', async () => {
|
||||
/**
|
||||
* The native Popover always renders its content; opening is driven by the
|
||||
* browser's declarative popovertarget invoker, which jsdom does not simulate
|
||||
* on click (mirrors Popover.svelte.test.ts). So assert the wired-but-closed
|
||||
* state, then drive the open through the API the browser would call.
|
||||
*/
|
||||
it('exposes a popover trigger with the vertical slider as its content', async () => {
|
||||
render(ComboControl, { control: makeControl(50), controlLabel: 'Size control' });
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||
await fireEvent.click(screen.getByText('Size control'));
|
||||
await waitFor(() => expect(screen.getByRole('slider')).toBeInTheDocument());
|
||||
|
||||
const trigger = getTrigger();
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
const content = document.getElementById(trigger.getAttribute('popovertarget')!) as HTMLElement;
|
||||
expect(content).toHaveAttribute('data-state', 'closed');
|
||||
// The vertical slider lives inside the popover content. While closed the
|
||||
// content is visibility:hidden, so query including hidden elements.
|
||||
expect(within(content).getByRole('slider', { hidden: true })).toBeInTheDocument();
|
||||
|
||||
content.showPopover();
|
||||
await waitFor(() => expect(content).toHaveAttribute('data-state', 'open'));
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
<script lang="ts">
|
||||
import type { Filter } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import { Button } from '$shared/ui';
|
||||
import { Label } from '$shared/ui';
|
||||
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
@@ -14,6 +12,8 @@ import {
|
||||
draw,
|
||||
fly,
|
||||
} from 'svelte/transition';
|
||||
import { Button } from '../Button';
|
||||
import Label from '../Label/Label.svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as Input } from './Input.svelte';
|
||||
export { inputIconSize } from './types';
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type LabelVariant,
|
||||
labelSizeConfig,
|
||||
labelVariantConfig,
|
||||
} from './config';
|
||||
} from '../labelConfig';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import { Badge } from '$shared/ui';
|
||||
import Badge from '../Badge/Badge.svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<script module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Popover from './Popover.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Shared/Popover',
|
||||
component: Popover,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Anchored popover on the native Popover API (top-layer, light-dismiss, ESC, focus return). Hand-rolled side/align/offset positioning with flip + shift.',
|
||||
},
|
||||
story: { inline: false }, // Render stories in iframe for state isolation
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
side: {
|
||||
control: 'select',
|
||||
options: ['top', 'bottom', 'left', 'right'],
|
||||
description: 'Preferred side',
|
||||
},
|
||||
align: {
|
||||
control: 'select',
|
||||
options: ['start', 'center', 'end'],
|
||||
description: 'Cross-axis alignment',
|
||||
},
|
||||
sideOffset: {
|
||||
control: 'number',
|
||||
description: 'Gap between trigger and content (px)',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Slider } from '$shared/ui';
|
||||
|
||||
let open = $state(false);
|
||||
let value = $state(50);
|
||||
</script>
|
||||
|
||||
<Story name="Bottom">
|
||||
{#snippet template()}
|
||||
<div class="p-32 flex-center min-h-screen">
|
||||
<Popover bind:open side="bottom" align="center" sideOffset={8}>
|
||||
{#snippet trigger(props)}
|
||||
<button {...props} class="surface-card-elevated px-4 py-2">Open popover</button>
|
||||
{/snippet}
|
||||
{#snippet children()}
|
||||
<div class="surface-popover p-4 w-56">Popover content</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="Top">
|
||||
{#snippet template()}
|
||||
<div class="p-32 flex-center min-h-screen">
|
||||
<Popover bind:open side="top" align="center" sideOffset={8}>
|
||||
{#snippet trigger(props)}
|
||||
<button {...props} class="surface-card-elevated px-4 py-2">Open popover</button>
|
||||
{/snippet}
|
||||
{#snippet children()}
|
||||
<div class="surface-popover p-4 w-56">Popover content</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<!--
|
||||
Mirrors TypographyMenu: top/end placement with a programmatic Close button
|
||||
wired to the `close()` param of the children snippet.
|
||||
-->
|
||||
<Story name="AlignedEnd">
|
||||
{#snippet template()}
|
||||
<div class="p-32 flex-center min-h-screen">
|
||||
<Popover bind:open side="top" align="end" sideOffset={8}>
|
||||
{#snippet trigger(props)}
|
||||
<button {...props} class="surface-card-elevated px-4 py-2">Open menu</button>
|
||||
{/snippet}
|
||||
{#snippet children({ close })}
|
||||
<div class="surface-popover p-4 w-72">
|
||||
<h3 class="text-sm font-medium mb-3">Menu header</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Aligned to the trigger's end edge.
|
||||
</p>
|
||||
<button class="surface-card-elevated px-3 py-1.5 text-sm" onclick={close}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<!-- Mirrors ComboControl: a vertical Slider lives inside the popover content. -->
|
||||
<Story name="WithSlider">
|
||||
{#snippet template()}
|
||||
<div class="p-32 flex-center min-h-screen">
|
||||
<Popover bind:open side="top" align="center" sideOffset={8}>
|
||||
{#snippet trigger(props)}
|
||||
<button {...props} class="surface-card-elevated px-4 py-2">Adjust value</button>
|
||||
{/snippet}
|
||||
{#snippet children()}
|
||||
<div class="surface-card-elevated p-3 h-64 flex-center">
|
||||
<Slider orientation="vertical" min={0} max={100} bind:value />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -0,0 +1,225 @@
|
||||
<!--
|
||||
Component: Popover
|
||||
Anchored popover on the native Popover API (top-layer, light-dismiss, ESC,
|
||||
focus return handled by the browser). Placement is computed by the pure
|
||||
`popover-position` module and applied as fixed coordinates; it repositions
|
||||
on scroll/resize/content-resize. `open` is two-way bindable. The trigger is
|
||||
consumer-rendered via the `trigger` snippet, which spreads a props object
|
||||
(an attachment captures the trigger element; `popovertarget` wires the
|
||||
native invoker). `children` receives `close()` to dismiss programmatically.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { createAttachmentKey } from 'svelte/attachments';
|
||||
import {
|
||||
type Align,
|
||||
type Side,
|
||||
computePosition,
|
||||
} from './popover-position';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Open state (two-way bindable)
|
||||
* @default false
|
||||
*/
|
||||
open?: boolean;
|
||||
/**
|
||||
* Preferred side
|
||||
* @default 'bottom'
|
||||
*/
|
||||
side?: Side;
|
||||
/**
|
||||
* Cross-axis alignment
|
||||
* @default 'center'
|
||||
*/
|
||||
align?: Align;
|
||||
/**
|
||||
* Gap between trigger and content (px)
|
||||
* @default 0
|
||||
*/
|
||||
sideOffset?: number;
|
||||
/**
|
||||
* CSS classes applied to the content element
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* ARIA role for the content
|
||||
* @default 'dialog'
|
||||
*/
|
||||
role?: 'dialog' | 'menu' | 'listbox';
|
||||
/**
|
||||
* Trigger snippet — spread the provided props onto your trigger element
|
||||
*/
|
||||
trigger: Snippet<[Record<string, unknown>]>;
|
||||
/**
|
||||
* Content snippet — receives `close()` for programmatic dismissal
|
||||
*/
|
||||
children: Snippet<[{ close: () => void }]>;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
side = 'bottom',
|
||||
align = 'center',
|
||||
sideOffset = 0,
|
||||
class: className,
|
||||
role = 'dialog',
|
||||
trigger,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
const uid = $props.id();
|
||||
const contentId = `popover-${uid}`;
|
||||
|
||||
let triggerEl: HTMLElement | undefined = $state();
|
||||
let contentEl: HTMLElement | undefined = $state();
|
||||
/**
|
||||
* Side actually used after flip. Seeded from the `side` prop; the authoritative
|
||||
* value is written by updatePosition() on every open, so the seed only matters
|
||||
* for the closed state (hence the intentional state_referenced_locally warning).
|
||||
*/
|
||||
let resolvedSide = $state(side);
|
||||
|
||||
/**
|
||||
* True once updatePosition has applied coordinates for the current open.
|
||||
* Gates visibility so the content never paints at its pre-positioned (0,0)
|
||||
* top-layer default before the first measurement.
|
||||
*/
|
||||
let positioned = $state(false);
|
||||
|
||||
/**
|
||||
* Resolved fixed-position coordinates. Applied through the reactive `style`
|
||||
* attribute (not imperatively) so they can't be wiped when the attribute
|
||||
* re-renders — mixing the two caused a one-frame top-left flash.
|
||||
*/
|
||||
let x = $state(0);
|
||||
let y = $state(0);
|
||||
|
||||
/**
|
||||
* Actual DOM open state, driven by the `toggle` event. Source of truth for
|
||||
* whether the browser currently shows the popover; `open` is the public binding.
|
||||
*/
|
||||
let shown = $state(false);
|
||||
|
||||
/**
|
||||
* Stable attachment that captures the consumer's trigger element for measuring.
|
||||
* Created once so spreading reactive `triggerProps` doesn't re-run it.
|
||||
*/
|
||||
const attachKey = createAttachmentKey();
|
||||
const attachTrigger = (node: HTMLElement) => {
|
||||
triggerEl = node;
|
||||
return () => {
|
||||
if (triggerEl === node) {
|
||||
triggerEl = undefined;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const triggerProps = $derived({
|
||||
popovertarget: contentId,
|
||||
'aria-haspopup': role,
|
||||
'aria-expanded': open,
|
||||
'aria-controls': contentId,
|
||||
[attachKey]: attachTrigger,
|
||||
});
|
||||
|
||||
/**
|
||||
* Recompute and apply the fixed-position coordinates.
|
||||
*/
|
||||
function updatePosition(): void {
|
||||
if (!triggerEl || !contentEl) {
|
||||
return;
|
||||
}
|
||||
const result = computePosition({
|
||||
triggerRect: triggerEl.getBoundingClientRect(),
|
||||
contentRect: { width: contentEl.offsetWidth, height: contentEl.offsetHeight },
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
side,
|
||||
align,
|
||||
sideOffset,
|
||||
});
|
||||
resolvedSide = result.side;
|
||||
x = result.x;
|
||||
y = result.y;
|
||||
positioned = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the `toggle` event into our state.
|
||||
*/
|
||||
function onToggle(event: ToggleEvent): void {
|
||||
shown = event.newState === 'open';
|
||||
open = shown;
|
||||
if (!shown) {
|
||||
positioned = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatic dismiss for the content snippet.
|
||||
*/
|
||||
function close(): void {
|
||||
open = false;
|
||||
}
|
||||
|
||||
// state -> browser: open the popover when `open` flips true and it isn't shown,
|
||||
// and close it when `open` flips false while shown. `shown` (from toggle) breaks
|
||||
// the loop so we never call show/hide redundantly.
|
||||
$effect(() => {
|
||||
const el = contentEl;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
if (open && !shown) {
|
||||
el.showPopover();
|
||||
} else if (!open && shown) {
|
||||
el.hidePopover();
|
||||
}
|
||||
});
|
||||
|
||||
// Position while shown; reposition on scroll/resize/content-resize; auto-clean.
|
||||
$effect(() => {
|
||||
if (!shown || !contentEl || !triggerEl) {
|
||||
return;
|
||||
}
|
||||
updatePosition();
|
||||
const observer = new ResizeObserver(() => updatePosition());
|
||||
observer.observe(contentEl);
|
||||
const onScroll = () => updatePosition();
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onScroll);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
window.removeEventListener('resize', onScroll);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render trigger(triggerProps)}
|
||||
|
||||
<!--
|
||||
inset:auto + margin:0 neutralize the UA popover stylesheet (which sets
|
||||
inset:0; margin:auto to center it) so the JS-applied left/top win.
|
||||
visibility is hidden until updatePosition runs (see `positioned`).
|
||||
-->
|
||||
<div
|
||||
bind:this={contentEl}
|
||||
id={contentId}
|
||||
popover="auto"
|
||||
{role}
|
||||
data-side={resolvedSide}
|
||||
data-state={shown ? 'open' : 'closed'}
|
||||
ontoggle={onToggle}
|
||||
style={`position: fixed; inset: auto; left: ${x}px; top: ${y}px; margin: 0;${positioned ? '' : ' visibility: hidden;'}`}
|
||||
class={cn(
|
||||
'opacity-0 scale-95 transition-discrete transition-[opacity,transform] duration-fast',
|
||||
'starting:opacity-0 starting:scale-95',
|
||||
'[&:popover-open]:opacity-100 [&:popover-open]:scale-100',
|
||||
'data-[side=top]:origin-bottom data-[side=bottom]:origin-top',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{@render children({ close })}
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/svelte';
|
||||
import Harness from './PopoverHarness.svelte';
|
||||
|
||||
/**
|
||||
* Resolve the popover content element (the [popover] ancestor of the test content).
|
||||
*/
|
||||
function getContent(): HTMLElement {
|
||||
return screen.getByTestId('content').closest('[popover]') as HTMLElement;
|
||||
}
|
||||
|
||||
describe('Popover', () => {
|
||||
it('renders the trigger with aria wiring, closed by default', () => {
|
||||
render(Harness);
|
||||
const trigger = screen.getByRole('button', { name: 'Open' });
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
expect(trigger).toHaveAttribute('popovertarget');
|
||||
expect(getContent()).toHaveAttribute('data-state', 'closed');
|
||||
});
|
||||
|
||||
it('opens via the popover toggle and syncs aria-expanded + data-state', async () => {
|
||||
render(Harness);
|
||||
const trigger = screen.getByRole('button', { name: 'Open' });
|
||||
// jsdom does not auto-invoke popovertarget; call the API the browser would.
|
||||
getContent().showPopover();
|
||||
await Promise.resolve();
|
||||
expect(getContent()).toHaveAttribute('data-state', 'open');
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('opens when the parent sets open=true (state -> browser)', async () => {
|
||||
render(Harness, { open: true });
|
||||
await Promise.resolve();
|
||||
expect(getContent()).toHaveAttribute('data-state', 'open');
|
||||
});
|
||||
|
||||
it('close() hides the popover and resets aria-expanded', async () => {
|
||||
render(Harness, { open: true });
|
||||
await Promise.resolve();
|
||||
const trigger = screen.getByRole('button', { name: 'Open' });
|
||||
await fireEvent.click(screen.getByTestId('close'));
|
||||
expect(getContent()).toHaveAttribute('data-state', 'closed');
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
Component: PopoverHarness
|
||||
Test-only fixture: renders Popover with a button trigger and simple content
|
||||
exposing the close() callback.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Popover from './Popover.svelte';
|
||||
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<Popover bind:open>
|
||||
{#snippet trigger(props)}
|
||||
<button {...props}>Open</button>
|
||||
{/snippet}
|
||||
{#snippet children({ close })}
|
||||
<div data-testid="content">
|
||||
<button onclick={close} data-testid="close">Close</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
type Align,
|
||||
type Side,
|
||||
computePosition,
|
||||
} from './popover-position';
|
||||
|
||||
/**
|
||||
* Build a DOMRect-like object (jsdom/node has no layout).
|
||||
*/
|
||||
function rect(x: number, y: number, width: number, height: number): DOMRect {
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
top: y,
|
||||
left: x,
|
||||
right: x + width,
|
||||
bottom: y + height,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
const viewport = { width: 1000, height: 800 };
|
||||
const content = { width: 200, height: 100 };
|
||||
|
||||
function compute(side: Side, align: Align, sideOffset = 0, trigger = rect(400, 400, 100, 40)) {
|
||||
return computePosition({ triggerRect: trigger, contentRect: content, viewport, side, align, sideOffset });
|
||||
}
|
||||
|
||||
describe('computePosition', () => {
|
||||
it('places below the trigger for side="bottom"', () => {
|
||||
const r = compute('bottom', 'center');
|
||||
expect(r.side).toBe('bottom');
|
||||
expect(r.y).toBe(440); // trigger.bottom (400+40)
|
||||
});
|
||||
|
||||
it('places above the trigger for side="top"', () => {
|
||||
const r = compute('top', 'center');
|
||||
expect(r.side).toBe('top');
|
||||
expect(r.y).toBe(300); // trigger.top (400) - content.height (100)
|
||||
});
|
||||
|
||||
it('applies sideOffset on the main axis', () => {
|
||||
const r = compute('bottom', 'center', 8);
|
||||
expect(r.y).toBe(448);
|
||||
});
|
||||
|
||||
it('aligns center on the cross axis (vertical side)', () => {
|
||||
const r = compute('bottom', 'center');
|
||||
// trigger center x = 450; content half = 100 -> 350
|
||||
expect(r.x).toBe(350);
|
||||
});
|
||||
|
||||
it('aligns start and end on the cross axis (vertical side)', () => {
|
||||
expect(compute('bottom', 'start').x).toBe(400); // trigger.left
|
||||
expect(compute('bottom', 'end').x).toBe(300); // trigger.right(500) - content.width(200)
|
||||
});
|
||||
|
||||
it('places left/right with vertical cross-axis alignment', () => {
|
||||
const right = compute('right', 'start');
|
||||
expect(right.side).toBe('right');
|
||||
expect(right.x).toBe(500); // trigger.right
|
||||
expect(right.y).toBe(400); // trigger.top (align start)
|
||||
const left = compute('left', 'center');
|
||||
expect(left.side).toBe('left');
|
||||
expect(left.x).toBe(200); // trigger.left(400) - content.width(200)
|
||||
});
|
||||
|
||||
it('flips top->bottom when there is no room above', () => {
|
||||
const nearTop = rect(400, 20, 100, 40); // only 20px above, content needs 100
|
||||
const r = computePosition({
|
||||
triggerRect: nearTop,
|
||||
contentRect: content,
|
||||
viewport,
|
||||
side: 'top',
|
||||
align: 'center',
|
||||
sideOffset: 0,
|
||||
});
|
||||
expect(r.side).toBe('bottom');
|
||||
expect(r.y).toBe(60); // nearTop.bottom
|
||||
});
|
||||
|
||||
it('does NOT flip when neither side fits (keeps requested side)', () => {
|
||||
const tall = { width: 200, height: 700 };
|
||||
const r = computePosition({
|
||||
triggerRect: rect(400, 400, 100, 40),
|
||||
contentRect: tall,
|
||||
viewport,
|
||||
side: 'top',
|
||||
align: 'center',
|
||||
sideOffset: 0,
|
||||
});
|
||||
expect(r.side).toBe('top');
|
||||
});
|
||||
|
||||
it('shifts on the cross axis to stay within the viewport', () => {
|
||||
const nearRight = rect(950, 400, 40, 40); // center x ~970, content 200 would overflow right
|
||||
const r = computePosition({
|
||||
triggerRect: nearRight,
|
||||
contentRect: content,
|
||||
viewport,
|
||||
side: 'bottom',
|
||||
align: 'center',
|
||||
sideOffset: 0,
|
||||
});
|
||||
expect(r.x).toBe(800); // clamped to viewport.width(1000) - content.width(200)
|
||||
const nearLeft = rect(10, 400, 40, 40);
|
||||
const r2 = computePosition({
|
||||
triggerRect: nearLeft,
|
||||
contentRect: content,
|
||||
viewport,
|
||||
side: 'bottom',
|
||||
align: 'center',
|
||||
sideOffset: 0,
|
||||
});
|
||||
expect(r2.x).toBe(0); // clamped to 0
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { clampNumber } from '$shared/lib/utils';
|
||||
|
||||
/**
|
||||
* Side of the trigger the content prefers to open toward.
|
||||
*/
|
||||
export type Side = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
/**
|
||||
* Cross-axis alignment of the content relative to the trigger.
|
||||
*/
|
||||
export type Align = 'start' | 'center' | 'end';
|
||||
|
||||
/**
|
||||
* Inputs for a single placement computation. All geometry is injected
|
||||
* (no DOM reads) so the function stays pure and unit-testable.
|
||||
*/
|
||||
type ComputeArgs = {
|
||||
/**
|
||||
* Trigger bounding rect (viewport coordinates).
|
||||
*/
|
||||
triggerRect: DOMRect;
|
||||
/**
|
||||
* Measured content size.
|
||||
*/
|
||||
contentRect: { width: number; height: number };
|
||||
/**
|
||||
* Viewport size.
|
||||
*/
|
||||
viewport: { width: number; height: number };
|
||||
/**
|
||||
* Preferred side.
|
||||
*/
|
||||
side: Side;
|
||||
/**
|
||||
* Cross-axis alignment.
|
||||
*/
|
||||
align: Align;
|
||||
/**
|
||||
* Gap between trigger and content on the main axis.
|
||||
*/
|
||||
sideOffset: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolved placement: fixed-position coordinates plus the side actually used
|
||||
* (may differ from the requested side after a flip).
|
||||
*/
|
||||
type ComputeResult = { x: number; y: number; side: Side };
|
||||
|
||||
const OPPOSITE: Record<Side, Side> = {
|
||||
top: 'bottom',
|
||||
bottom: 'top',
|
||||
left: 'right',
|
||||
right: 'left',
|
||||
};
|
||||
|
||||
/**
|
||||
* True for sides whose main axis is vertical (content sits above/below).
|
||||
*/
|
||||
function isVertical(side: Side): boolean {
|
||||
return side === 'top' || side === 'bottom';
|
||||
}
|
||||
|
||||
/**
|
||||
* Main-axis coordinate (top for vertical sides, left for horizontal sides).
|
||||
*/
|
||||
function mainAxisCoord(side: Side, t: DOMRect, c: { width: number; height: number }, offset: number): number {
|
||||
switch (side) {
|
||||
case 'top':
|
||||
return t.top - c.height - offset;
|
||||
case 'bottom':
|
||||
return t.bottom + offset;
|
||||
case 'left':
|
||||
return t.left - c.width - offset;
|
||||
case 'right':
|
||||
return t.right + offset;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the content fits on the given side within the viewport.
|
||||
*/
|
||||
function fitsOnSide(
|
||||
side: Side,
|
||||
t: DOMRect,
|
||||
c: { width: number; height: number },
|
||||
v: { width: number; height: number },
|
||||
offset: number,
|
||||
): boolean {
|
||||
const coord = mainAxisCoord(side, t, c, offset);
|
||||
switch (side) {
|
||||
case 'top':
|
||||
return coord >= 0;
|
||||
case 'left':
|
||||
return coord >= 0;
|
||||
case 'bottom':
|
||||
return coord + c.height <= v.height;
|
||||
case 'right':
|
||||
return coord + c.width <= v.width;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-axis coordinate for the requested alignment.
|
||||
*/
|
||||
function crossAxisCoord(side: Side, align: Align, t: DOMRect, c: { width: number; height: number }): number {
|
||||
if (isVertical(side)) {
|
||||
if (align === 'start') {
|
||||
return t.left;
|
||||
}
|
||||
if (align === 'end') {
|
||||
return t.right - c.width;
|
||||
}
|
||||
return t.left + t.width / 2 - c.width / 2;
|
||||
}
|
||||
if (align === 'start') {
|
||||
return t.top;
|
||||
}
|
||||
if (align === 'end') {
|
||||
return t.bottom - c.height;
|
||||
}
|
||||
return t.top + t.height / 2 - c.height / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute an anchored placement with flip (to the opposite side when the
|
||||
* preferred side doesn't fit but the opposite does) and shift (clamp the
|
||||
* cross axis so the content stays within the viewport).
|
||||
*/
|
||||
export function computePosition(args: ComputeArgs): ComputeResult {
|
||||
const { triggerRect: t, contentRect: c, viewport: v, align, sideOffset } = args;
|
||||
let side = args.side;
|
||||
|
||||
if (!fitsOnSide(side, t, c, v, sideOffset) && fitsOnSide(OPPOSITE[side], t, c, v, sideOffset)) {
|
||||
side = OPPOSITE[side];
|
||||
}
|
||||
|
||||
let x: number;
|
||||
let y: number;
|
||||
if (isVertical(side)) {
|
||||
y = mainAxisCoord(side, t, c, sideOffset);
|
||||
x = clampNumber(crossAxisCoord(side, align, t, c), 0, Math.max(0, v.width - c.width));
|
||||
} else {
|
||||
x = mainAxisCoord(side, t, c, sideOffset);
|
||||
y = clampNumber(crossAxisCoord(side, align, t, c), 0, Math.max(0, v.height - c.height));
|
||||
}
|
||||
|
||||
return { x, y, side };
|
||||
}
|
||||
@@ -7,8 +7,10 @@
|
||||
They cannot pass leftIcon — it's owned by this wrapper.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Input } from '$shared/ui/Input';
|
||||
import { inputIconSize } from '$shared/ui/Input/types';
|
||||
import {
|
||||
Input,
|
||||
inputIconSize,
|
||||
} from '$shared/ui/Input';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import { Label } from '$shared/ui';
|
||||
import Label from '../../Label/Label.svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
|
||||
@@ -43,7 +43,7 @@ function close() {
|
||||
|
||||
{#if responsive.isMobile}
|
||||
<!--
|
||||
── MOBILE: fixed overlay ─────────────────────────────────────────────
|
||||
MOBILE: fixed overlay.
|
||||
Only rendered when open. Both backdrop and panel use Svelte transitions
|
||||
so they animate in and out independently.
|
||||
-->
|
||||
@@ -70,7 +70,7 @@ function close() {
|
||||
{/if}
|
||||
{:else}
|
||||
<!--
|
||||
── DESKTOP: collapsible column ───────────────────────────────────────
|
||||
DESKTOP: collapsible column.
|
||||
Always in the DOM — width transitions between 320px and 0.
|
||||
overflow-hidden clips the w-80 inner div during the collapse.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const { Story } = defineMeta({
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Styled bits-ui slider component with red accent (#ff3b30). Thumb is a 45° rotated square with hover/active scale animations.',
|
||||
'Single-value slider (native, no bits-ui) with brand accent. Diamond thumb (45° rotated square) with hover/active scale. Supports pointer drag, click-to-seek, touch, and keyboard (arrows, Home/End, PageUp/Down).',
|
||||
},
|
||||
story: { inline: false }, // Render stories in iframe for state isolation
|
||||
},
|
||||
@@ -39,8 +39,6 @@ const { Story } = defineMeta({
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from 'svelte';
|
||||
let value = $state(50);
|
||||
let valueLow = $state(25);
|
||||
let valueHigh = $state(75);
|
||||
</script>
|
||||
|
||||
<Story
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<!--
|
||||
Component: Slider
|
||||
Single-value slider using bits-ui Slider primitive.
|
||||
Single-value slider built on a native role="slider" element (no bits-ui).
|
||||
Supports pointer drag, click-to-seek, touch, and full keyboard nav.
|
||||
Swiss design: 1px track, diamond thumb (rotate-45), brand accent.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
type Orientation,
|
||||
Slider,
|
||||
} from 'bits-ui';
|
||||
pointerToValue,
|
||||
snapToStep,
|
||||
} from './slider-math';
|
||||
|
||||
type Orientation = 'horizontal' | 'vertical';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -67,8 +70,122 @@ let {
|
||||
class: className,
|
||||
}: Props = $props();
|
||||
|
||||
/**
|
||||
* PageUp/PageDown move by this multiple of `step`.
|
||||
*/
|
||||
const LARGE_STEP_MULTIPLIER = 10;
|
||||
|
||||
const isVertical = $derived(orientation === 'vertical');
|
||||
|
||||
/**
|
||||
* Thumb/range offset as a clamped percentage of the track.
|
||||
*/
|
||||
const percent = $derived.by(() => {
|
||||
if (max <= min) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(Math.max(((value - min) / (max - min)) * 100, 0), 100);
|
||||
});
|
||||
|
||||
let trackEl: HTMLElement | undefined = $state();
|
||||
let thumbEl: HTMLElement | undefined = $state();
|
||||
let dragging = $state(false);
|
||||
|
||||
/**
|
||||
* Apply a candidate value: snap, clamp, store, and notify only on change.
|
||||
*/
|
||||
function commit(raw: number): void {
|
||||
const next = snapToStep(raw, { min, max, step });
|
||||
if (next !== value) {
|
||||
value = next;
|
||||
onValueChange?.(next);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep an externally-supplied value normalized to the step grid and range.
|
||||
* Mirrors the bits-ui primitive's behavior so out-of-range or off-grid
|
||||
* props don't desync the thumb position from aria-valuenow / the label.
|
||||
* Converges in one pass: once snapped, the value equals its own snap.
|
||||
*/
|
||||
$effect(() => {
|
||||
const normalized = snapToStep(value, { min, max, step });
|
||||
if (normalized !== value) {
|
||||
value = normalized;
|
||||
onValueChange?.(normalized);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve a pointer event to a value using the live track rect.
|
||||
*/
|
||||
function seek(event: PointerEvent): void {
|
||||
if (!trackEl) {
|
||||
return;
|
||||
}
|
||||
const rect = trackEl.getBoundingClientRect();
|
||||
commit(pointerToValue(event, rect, { min, max, step, orientation }));
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent): void {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
dragging = true;
|
||||
thumbEl?.focus();
|
||||
(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
|
||||
seek(event);
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent): void {
|
||||
if (!dragging || disabled) {
|
||||
return;
|
||||
}
|
||||
seek(event);
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent): void {
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
dragging = false;
|
||||
(event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent): void {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
const large = step * LARGE_STEP_MULTIPLIER;
|
||||
let next: number | undefined;
|
||||
switch (event.key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
next = value + step;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowDown':
|
||||
next = value - step;
|
||||
break;
|
||||
case 'PageUp':
|
||||
next = value + large;
|
||||
break;
|
||||
case 'PageDown':
|
||||
next = value - large;
|
||||
break;
|
||||
case 'Home':
|
||||
next = min;
|
||||
break;
|
||||
case 'End':
|
||||
next = max;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
commit(next);
|
||||
}
|
||||
|
||||
const labelClasses = `font-mono text-2xs tabular-nums shrink-0
|
||||
text-subtle
|
||||
group-hover:text-neutral-700 dark:group-hover:text-neutral-300
|
||||
@@ -91,22 +208,21 @@ const thumbClasses = `block w-2.5 h-2.5 bg-brand
|
||||
{format(value)}
|
||||
</span>
|
||||
|
||||
<Slider.Root
|
||||
type="single"
|
||||
orientation="vertical"
|
||||
bind:value
|
||||
{min}
|
||||
{max}
|
||||
{step}
|
||||
{disabled}
|
||||
onValueChange={(v => onValueChange?.(v))}
|
||||
<div
|
||||
bind:this={trackEl}
|
||||
role="presentation"
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
onpointercancel={handlePointerUp}
|
||||
class="
|
||||
relative flex flex-col items-center select-none touch-none
|
||||
w-5 h-full grow cursor-row-resize
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
"
|
||||
class:opacity-50={disabled}
|
||||
class:cursor-not-allowed={disabled}
|
||||
>
|
||||
{#snippet children({ thumbItems })}
|
||||
<span
|
||||
class="
|
||||
bg-neutral-200 dark:bg-neutral-800
|
||||
@@ -115,37 +231,47 @@ const thumbClasses = `block w-2.5 h-2.5 bg-brand
|
||||
transition-colors
|
||||
"
|
||||
>
|
||||
<Slider.Range class="absolute bg-brand w-full" />
|
||||
<span
|
||||
class="absolute bottom-0 left-0 bg-brand w-full"
|
||||
style="height: {percent}%"
|
||||
></span>
|
||||
</span>
|
||||
|
||||
{#each thumbItems as thumb (thumb)}
|
||||
<Slider.Thumb
|
||||
index={thumb.index}
|
||||
class={thumbClasses}
|
||||
<span
|
||||
role="slider"
|
||||
bind:this={thumbEl}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
aria-label="Value"
|
||||
/>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</Slider.Root>
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
aria-valuetext={String(format(value))}
|
||||
aria-disabled={disabled ? 'true' : undefined}
|
||||
data-active={dragging ? '' : undefined}
|
||||
onkeydown={handleKeyDown}
|
||||
class="{thumbClasses} absolute left-1/2 -translate-x-1/2 translate-y-1/2"
|
||||
style="bottom: {percent}%"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-4 group w-full {className ?? ''}">
|
||||
<Slider.Root
|
||||
type="single"
|
||||
orientation="horizontal"
|
||||
bind:value
|
||||
{min}
|
||||
{max}
|
||||
{step}
|
||||
{disabled}
|
||||
onValueChange={(v => onValueChange?.(v))}
|
||||
<div
|
||||
bind:this={trackEl}
|
||||
role="presentation"
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
onpointercancel={handlePointerUp}
|
||||
class="
|
||||
relative flex items-center select-none touch-none
|
||||
w-full h-5 cursor-col-resize
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
"
|
||||
class:opacity-50={disabled}
|
||||
class:cursor-not-allowed={disabled}
|
||||
>
|
||||
{#snippet children({ thumbItems })}
|
||||
<span
|
||||
class="
|
||||
bg-neutral-200 dark:bg-neutral-800
|
||||
@@ -154,18 +280,29 @@ const thumbClasses = `block w-2.5 h-2.5 bg-brand
|
||||
transition-colors
|
||||
"
|
||||
>
|
||||
<Slider.Range class="absolute bg-brand h-full" />
|
||||
<span
|
||||
class="absolute top-0 left-0 bg-brand h-full"
|
||||
style="width: {percent}%"
|
||||
></span>
|
||||
</span>
|
||||
|
||||
{#each thumbItems as thumb (thumb)}
|
||||
<Slider.Thumb
|
||||
index={thumb.index}
|
||||
class={thumbClasses}
|
||||
<span
|
||||
role="slider"
|
||||
bind:this={thumbEl}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
aria-label="Value"
|
||||
/>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</Slider.Root>
|
||||
aria-orientation="horizontal"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
aria-valuetext={String(format(value))}
|
||||
aria-disabled={disabled ? 'true' : undefined}
|
||||
data-active={dragging ? '' : undefined}
|
||||
onkeydown={handleKeyDown}
|
||||
class="{thumbClasses} absolute top-1/2 -translate-y-1/2 -translate-x-1/2"
|
||||
style="left: {percent}%"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- Label: right of slider -->
|
||||
<span class="{labelClasses} w-12 text-right">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/svelte';
|
||||
@@ -60,3 +61,109 @@ describe('Slider', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard', () => {
|
||||
it('increments by step on ArrowRight / ArrowUp', async () => {
|
||||
const onValueChange = vi.fn();
|
||||
render(Slider, { value: 50, step: 5, onValueChange });
|
||||
const thumb = screen.getByRole('slider');
|
||||
await fireEvent.keyDown(thumb, { key: 'ArrowRight' });
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '55');
|
||||
expect(onValueChange).toHaveBeenCalledWith(55);
|
||||
});
|
||||
|
||||
it('decrements by step on ArrowLeft / ArrowDown', async () => {
|
||||
render(Slider, { value: 50, step: 5 });
|
||||
const thumb = screen.getByRole('slider');
|
||||
await fireEvent.keyDown(thumb, { key: 'ArrowDown' });
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '45');
|
||||
});
|
||||
|
||||
it('jumps to min on Home and max on End', async () => {
|
||||
render(Slider, { value: 50, min: 10, max: 90 });
|
||||
const thumb = screen.getByRole('slider');
|
||||
await fireEvent.keyDown(thumb, { key: 'Home' });
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '10');
|
||||
await fireEvent.keyDown(thumb, { key: 'End' });
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '90');
|
||||
});
|
||||
|
||||
it('moves by step*10 on PageUp / PageDown', async () => {
|
||||
render(Slider, { value: 50, step: 2 });
|
||||
const thumb = screen.getByRole('slider');
|
||||
await fireEvent.keyDown(thumb, { key: 'PageUp' });
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '70');
|
||||
await fireEvent.keyDown(thumb, { key: 'PageDown' });
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '50');
|
||||
});
|
||||
|
||||
it('clamps at the bounds', async () => {
|
||||
render(Slider, { value: 98, max: 100, step: 5 });
|
||||
const thumb = screen.getByRole('slider');
|
||||
await fireEvent.keyDown(thumb, { key: 'End' });
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '100');
|
||||
});
|
||||
|
||||
it('does nothing when disabled', async () => {
|
||||
const onValueChange = vi.fn();
|
||||
render(Slider, { value: 50, disabled: true, onValueChange });
|
||||
const thumb = screen.getByRole('slider');
|
||||
await fireEvent.keyDown(thumb, { key: 'ArrowRight' });
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '50');
|
||||
expect(onValueChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pointer', () => {
|
||||
/**
|
||||
* Force a deterministic track rect since jsdom has no layout.
|
||||
*/
|
||||
function mockTrackRect(container: HTMLElement) {
|
||||
const track = container.querySelector('[role="presentation"]') as HTMLElement;
|
||||
track.getBoundingClientRect = () =>
|
||||
({ left: 0, right: 200, top: 0, bottom: 20, width: 200, height: 20 }) as DOMRect;
|
||||
return track;
|
||||
}
|
||||
|
||||
it('seeks to the clicked position (click-to-seek)', async () => {
|
||||
const onValueChange = vi.fn();
|
||||
const { container } = render(Slider, { value: 0, min: 0, max: 100, onValueChange });
|
||||
const track = mockTrackRect(container);
|
||||
await fireEvent.pointerDown(track, { clientX: 100, clientY: 10, pointerId: 1 });
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '50');
|
||||
expect(onValueChange).toHaveBeenCalledWith(50);
|
||||
});
|
||||
|
||||
it('updates while dragging after pointerdown', async () => {
|
||||
const { container } = render(Slider, { value: 0, min: 0, max: 100 });
|
||||
const track = mockTrackRect(container);
|
||||
await fireEvent.pointerDown(track, { clientX: 50, clientY: 10, pointerId: 1 });
|
||||
await fireEvent.pointerMove(track, { clientX: 150, clientY: 10, pointerId: 1 });
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '75');
|
||||
});
|
||||
|
||||
it('ignores pointer when disabled', async () => {
|
||||
const { container } = render(Slider, { value: 0, disabled: true });
|
||||
const track = mockTrackRect(container);
|
||||
await fireEvent.pointerDown(track, { clientX: 100, clientY: 10, pointerId: 1 });
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0');
|
||||
});
|
||||
|
||||
it('focuses the thumb on pointerdown so arrow keys work immediately', async () => {
|
||||
const { container } = render(Slider, { value: 0, min: 0, max: 100 });
|
||||
const track = container.querySelector('[role="presentation"]') as HTMLElement;
|
||||
track.getBoundingClientRect = () =>
|
||||
({ left: 0, right: 200, top: 0, bottom: 20, width: 200, height: 20 }) as DOMRect;
|
||||
await fireEvent.pointerDown(track, { clientX: 100, clientY: 10, pointerId: 1 });
|
||||
expect(screen.getByRole('slider')).toBe(document.activeElement);
|
||||
});
|
||||
|
||||
it('maps a vertical drag with the inverted axis (bottom→min, top→max)', async () => {
|
||||
const { container } = render(Slider, { value: 0, min: 0, max: 100, orientation: 'vertical' });
|
||||
const track = container.querySelector('[role="presentation"]') as HTMLElement;
|
||||
track.getBoundingClientRect = () =>
|
||||
({ left: 0, right: 20, top: 0, bottom: 200, width: 20, height: 200 }) as DOMRect;
|
||||
await fireEvent.pointerDown(track, { clientX: 10, clientY: 50, pointerId: 1 });
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '75');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
pointerToValue,
|
||||
snapToStep,
|
||||
} from './slider-math';
|
||||
|
||||
describe('snapToStep', () => {
|
||||
it('snaps a raw value to the nearest step on the grid', () => {
|
||||
expect(snapToStep(53, { min: 0, max: 100, step: 10 })).toBe(50);
|
||||
expect(snapToStep(56, { min: 0, max: 100, step: 10 })).toBe(60);
|
||||
});
|
||||
|
||||
it('clamps below min and above max', () => {
|
||||
expect(snapToStep(-20, { min: 0, max: 100, step: 1 })).toBe(0);
|
||||
expect(snapToStep(200, { min: 0, max: 100, step: 1 })).toBe(100);
|
||||
});
|
||||
|
||||
it('respects a non-zero min when snapping', () => {
|
||||
expect(snapToStep(13, { min: 10, max: 90, step: 5 })).toBe(15);
|
||||
});
|
||||
|
||||
it('preserves fractional step precision', () => {
|
||||
expect(snapToStep(1.34, { min: 0, max: 2, step: 0.05 })).toBe(1.35);
|
||||
expect(snapToStep(0.31, { min: 0, max: 1, step: 0.1 })).toBe(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointerToValue', () => {
|
||||
const rect = { left: 100, right: 300, top: 50, bottom: 250, width: 200, height: 200 } as DOMRect;
|
||||
|
||||
it('maps horizontal pointer position left→min, right→max', () => {
|
||||
const opts = { min: 0, max: 100, step: 1, orientation: 'horizontal' as const };
|
||||
expect(pointerToValue({ clientX: 100, clientY: 0 }, rect, opts)).toBe(0);
|
||||
expect(pointerToValue({ clientX: 200, clientY: 0 }, rect, opts)).toBe(50);
|
||||
expect(pointerToValue({ clientX: 300, clientY: 0 }, rect, opts)).toBe(100);
|
||||
});
|
||||
|
||||
it('inverts vertical: bottom→min, top→max', () => {
|
||||
const opts = { min: 0, max: 100, step: 1, orientation: 'vertical' as const };
|
||||
expect(pointerToValue({ clientX: 0, clientY: 250 }, rect, opts)).toBe(0);
|
||||
expect(pointerToValue({ clientX: 0, clientY: 150 }, rect, opts)).toBe(50);
|
||||
expect(pointerToValue({ clientX: 0, clientY: 50 }, rect, opts)).toBe(100);
|
||||
});
|
||||
|
||||
it('clamps when pointer is outside the track', () => {
|
||||
const opts = { min: 0, max: 100, step: 1, orientation: 'horizontal' as const };
|
||||
expect(pointerToValue({ clientX: 0, clientY: 0 }, rect, opts)).toBe(0);
|
||||
expect(pointerToValue({ clientX: 9999, clientY: 0 }, rect, opts)).toBe(100);
|
||||
});
|
||||
|
||||
it('returns min for a zero-size track without NaN', () => {
|
||||
const zero = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0 } as DOMRect;
|
||||
const opts = { min: 5, max: 95, step: 1, orientation: 'horizontal' as const };
|
||||
expect(pointerToValue({ clientX: 0, clientY: 0 }, zero, opts)).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
clampNumber,
|
||||
roundToStepPrecision,
|
||||
} from '$shared/lib/utils';
|
||||
|
||||
/**
|
||||
* Geometry/range options shared by the math helpers.
|
||||
*/
|
||||
type SliderMathOpts = {
|
||||
/**
|
||||
* Minimum value (inclusive)
|
||||
*/
|
||||
min: number;
|
||||
/**
|
||||
* Maximum value (inclusive)
|
||||
*/
|
||||
max: number;
|
||||
/**
|
||||
* Step increment
|
||||
*/
|
||||
step: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Snap a raw value onto the step grid, then clamp to [min, max].
|
||||
*
|
||||
* Snapping is anchored to `min` so non-zero ranges land on valid stops.
|
||||
* `roundToStepPrecision` removes IEEE-754 drift from fractional steps.
|
||||
*/
|
||||
export function snapToStep(raw: number, { min, max, step }: SliderMathOpts): number {
|
||||
if (step <= 0) {
|
||||
return clampNumber(raw, min, max);
|
||||
}
|
||||
const snapped = min + Math.round((raw - min) / step) * step;
|
||||
return clampNumber(roundToStepPrecision(snapped, step), min, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a pointer coordinate into a slider value.
|
||||
*
|
||||
* Horizontal maps left→min, right→max. Vertical is inverted so that
|
||||
* up→max, matching natural slider expectations. The DOMRect is passed in
|
||||
* to keep this pure and unit-testable without layout.
|
||||
*/
|
||||
export function pointerToValue(
|
||||
point: { clientX: number; clientY: number },
|
||||
rect: DOMRect,
|
||||
opts: SliderMathOpts & { orientation: 'horizontal' | 'vertical' },
|
||||
): number {
|
||||
const { min, max, orientation } = opts;
|
||||
const size = orientation === 'vertical' ? rect.height : rect.width;
|
||||
if (size <= 0) {
|
||||
return snapToStep(min, opts);
|
||||
}
|
||||
const ratio = orientation === 'vertical'
|
||||
? (rect.bottom - point.clientY) / size
|
||||
: (point.clientX - rect.left) / size;
|
||||
return snapToStep(min + clampNumber(ratio, 0, 1) * (max - min), opts);
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import { Label } from '$shared/ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import Label from '../Label/Label.svelte';
|
||||
|
||||
interface Props extends Pick<ComponentProps<typeof Label>, 'variant'> {
|
||||
/**
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import { Stat } from '$shared/ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import Stat from './Stat.svelte';
|
||||
|
||||
interface StatItem extends Partial<Pick<ComponentProps<typeof Stat>, 'variant'>> {
|
||||
label: string;
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import {
|
||||
type LabelSize,
|
||||
type LabelVariant,
|
||||
labelSizeConfig,
|
||||
labelVariantConfig,
|
||||
} from '$shared/ui/Label/config';
|
||||
import type { Snippet } from 'svelte';
|
||||
} from '../labelConfig';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
|
||||
@@ -104,6 +104,12 @@ export {
|
||||
*/
|
||||
default as PerspectivePlan,
|
||||
} from './PerspectivePlan/PerspectivePlan.svelte';
|
||||
export {
|
||||
/**
|
||||
* Anchored popover on the native Popover API
|
||||
*/
|
||||
default as Popover,
|
||||
} from './Popover/Popover.svelte';
|
||||
export {
|
||||
/**
|
||||
* Specialized input with search icon and clear state
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './model';
|
||||
export { getComparisonStore } from './model';
|
||||
export type { Side } from './model';
|
||||
export { ComparisonView } from './ui';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
export * from './utils/dotTransition';
|
||||
export * from './utils/ensureCanvasFonts/ensureCanvasFonts';
|
||||
export * from './utils/getPretextFontString/getPretextFontString';
|
||||
export { computeStrutHeight } from './utils/computeStrutHeight/computeStrutHeight';
|
||||
export {
|
||||
createDotCrossfade,
|
||||
getDotTransitionParams,
|
||||
} from './utils/dotTransition';
|
||||
export type { DotTransitionParams } from './utils/dotTransition';
|
||||
export { ensureCanvasFonts } from './utils/ensureCanvasFonts/ensureCanvasFonts';
|
||||
export { getPretextFontString } from './utils/getPretextFontString/getPretextFontString';
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { computeStrutHeight } from './computeStrutHeight';
|
||||
|
||||
describe('computeStrutHeight', () => {
|
||||
it('uses the centering height when the line-height is generous', () => {
|
||||
// centering = 40/2 + 16*0.34 = 25.44; floor = 16*1.1 = 17.6 → centering wins.
|
||||
expect(computeStrutHeight(40, 16)).toBeCloseTo(25.44, 5);
|
||||
});
|
||||
|
||||
it('falls back to the ascent floor when the line-height is tight', () => {
|
||||
// centering = 16/2 + 16*0.34 = 13.44; floor = 16*1.1 = 17.6 → floor wins.
|
||||
expect(computeStrutHeight(16, 16)).toBeCloseTo(17.6, 5);
|
||||
});
|
||||
|
||||
it('treats the floor and centering height as equal at the crossover line-height', () => {
|
||||
// centering == floor when lineHeight = 1.52 * fontSize → 24.32 for 16px.
|
||||
expect(computeStrutHeight(24.32, 16)).toBeCloseTo(17.6, 5);
|
||||
});
|
||||
|
||||
it('scales with font size', () => {
|
||||
// centering = 60/2 + 32*0.34 = 40.88; floor = 32*1.1 = 35.2 → centering wins.
|
||||
expect(computeStrutHeight(60, 32)).toBeCloseTo(40.88, 5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Fraction of the font size added to half the line height to drop the strut's
|
||||
* baseline from the line box's vertical middle down to the text's optical
|
||||
* center. Empirical: ~0.34em approximates a Latin font's midline-to-baseline
|
||||
* offset, so glyphs sit centered rather than riding high in the line.
|
||||
*/
|
||||
const BASELINE_OFFSET_RATIO = 0.34;
|
||||
|
||||
/**
|
||||
* Minimum strut height as a multiple of the font size. Floors the strut above
|
||||
* the fonts' ascent (~1em) so that at tight line-heights it stays the tallest
|
||||
* inline box and keeps ownership of the line baseline. Empirical: 1.1 clears the
|
||||
* tallest ascenders in the catalog's Latin fonts.
|
||||
*/
|
||||
const MIN_HEIGHT_RATIO = 1.1;
|
||||
|
||||
/**
|
||||
* Pixel height for a slider line's invisible baseline strut.
|
||||
*
|
||||
* The slider renders each line with a zero-width strut span whose box is
|
||||
* deliberately the tallest inline box on the line. The browser pins a line box's
|
||||
* baseline to its tallest inline box; fixing the strut's height independent of
|
||||
* which bulk runs or window chars are currently mounted keeps the baseline (and
|
||||
* every glyph) from jumping as the slider sweeps runs in and out. With
|
||||
* `overflow: hidden` the strut's baseline sits at its bottom edge, so this height
|
||||
* also sets the text's vertical position within the line box.
|
||||
*
|
||||
* The result is `max(centeringHeight, ascentFloor)`:
|
||||
* - `centeringHeight = lineHeightPx / 2 + fontSizePx * BASELINE_OFFSET_RATIO`
|
||||
* centers the text — half the line box places the strut's bottom edge at the
|
||||
* vertical middle, and the offset term nudges the baseline down to the glyphs'
|
||||
* optical center.
|
||||
* - `ascentFloor = fontSizePx * MIN_HEIGHT_RATIO` keeps the strut taller than the
|
||||
* fonts' ascent when the line-height is tight (where `centeringHeight` would
|
||||
* shrink below a real glyph box and let another box steal the baseline).
|
||||
*
|
||||
* @param lineHeightPx Line height in pixels (typography line-height × font size).
|
||||
* @param fontSizePx Rendered font size in pixels.
|
||||
* @returns Strut height in pixels.
|
||||
*/
|
||||
export function computeStrutHeight(lineHeightPx: number, fontSizePx: number): number {
|
||||
const centeringHeight = lineHeightPx / 2 + fontSizePx * BASELINE_OFFSET_RATIO;
|
||||
const ascentFloor = fontSizePx * MIN_HEIGHT_RATIO;
|
||||
return Math.max(centeringHeight, ascentFloor);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { VIRTUAL_INDEX_NOT_LOADED } from '$entities/Font';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import {
|
||||
type CrossfadeParams,
|
||||
|
||||
@@ -14,22 +14,23 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
type FontCatalogStore,
|
||||
type FontLifecycleManager,
|
||||
type FontLoadRequestConfig,
|
||||
FontsByIdsStore,
|
||||
type UnifiedFont,
|
||||
getFontCatalog,
|
||||
getFontLifecycleManager,
|
||||
getFontUrl,
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
type FontCatalogStore,
|
||||
type FontLifecycleManager,
|
||||
FontsByIdsStore,
|
||||
getFontCatalog,
|
||||
getFontLifecycleManager,
|
||||
} from '$entities/Font/model';
|
||||
import {
|
||||
type TypographySettingsStore,
|
||||
getTypographySettingsStore,
|
||||
} from '$features/AdjustTypography/model';
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
} from '$features/AdjustTypography';
|
||||
import {
|
||||
createPersistentStore,
|
||||
createSingleton,
|
||||
} from '$shared/lib';
|
||||
import { untrack } from 'svelte';
|
||||
import { getPretextFontString } from '../../lib';
|
||||
|
||||
@@ -58,12 +59,6 @@ const STORAGE_KEY = 'glyphdiff:comparison';
|
||||
*/
|
||||
const FONT_READY_FALLBACK_MS = 1000;
|
||||
|
||||
// Persistent storage for selected comparison fonts
|
||||
const storage = createPersistentStore<ComparisonState>(STORAGE_KEY, {
|
||||
fontAId: null,
|
||||
fontBId: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Store for managing font comparison state.
|
||||
*
|
||||
@@ -102,22 +97,39 @@ export class ComparisonStore {
|
||||
* TanStack Query-backed store for efficient batch font retrieval
|
||||
*/
|
||||
#fontsByIdsStore: FontsByIdsStore;
|
||||
|
||||
/**
|
||||
* Paginated font catalog — source of fonts for default seeding.
|
||||
*/
|
||||
#fontCatalog: FontCatalogStore;
|
||||
|
||||
/**
|
||||
* Typography settings applied to the rendered comparison.
|
||||
*/
|
||||
#typography: TypographySettingsStore;
|
||||
|
||||
/**
|
||||
* Font load/cache/eviction manager; pinned to keep compared fonts resident.
|
||||
*/
|
||||
#lifecycle: FontLifecycleManager;
|
||||
/**
|
||||
* Per-instance persistent storage for the selected comparison fonts.
|
||||
*/
|
||||
#storage = createPersistentStore<ComparisonState>(STORAGE_KEY, {
|
||||
fontAId: null,
|
||||
fontBId: null,
|
||||
});
|
||||
/**
|
||||
* Disposes the constructor's $effect.root. Must be run on teardown.
|
||||
*/
|
||||
#disposeEffects: () => void;
|
||||
|
||||
constructor() {
|
||||
// Synchronously seed the batch store with any IDs already in storage
|
||||
const { fontAId, fontBId } = storage.value;
|
||||
const { fontAId, fontBId } = this.#storage.value;
|
||||
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
|
||||
this.#fontCatalog = getFontCatalog();
|
||||
this.#typography = getTypographySettingsStore();
|
||||
this.#lifecycle = getFontLifecycleManager();
|
||||
|
||||
$effect.root(() => {
|
||||
this.#disposeEffects = $effect.root(() => {
|
||||
// Sync batch results → fontA / fontB
|
||||
$effect(() => {
|
||||
const fonts = this.#fontsByIdsStore.fonts;
|
||||
@@ -125,7 +137,7 @@ export class ComparisonStore {
|
||||
return;
|
||||
}
|
||||
|
||||
const { fontAId: aId, fontBId: bId } = storage.value;
|
||||
const { fontAId: aId, fontBId: bId } = this.#storage.value;
|
||||
if (aId) {
|
||||
const fa = fonts.find(f => f.id === aId);
|
||||
if (fa) {
|
||||
@@ -180,7 +192,7 @@ export class ComparisonStore {
|
||||
// Untracked: only the catalog load should drive this effect, not the
|
||||
// user's storage writes that happen as a result of normal selection.
|
||||
const hasStoredSelection = untrack(() => {
|
||||
return storage.value.fontAId !== null || storage.value.fontBId !== null;
|
||||
return this.#storage.value.fontAId !== null || this.#storage.value.fontBId !== null;
|
||||
});
|
||||
|
||||
if (hasStoredSelection) {
|
||||
@@ -196,7 +208,7 @@ export class ComparisonStore {
|
||||
untrack(() => {
|
||||
const id1 = fonts[0].id;
|
||||
const id2 = fonts[fonts.length - 1].id;
|
||||
storage.value = { fontAId: id1, fontBId: id2 };
|
||||
this.#storage.value = { fontAId: id1, fontBId: id2 };
|
||||
this.#fontsByIdsStore.setIds([id1, id2]);
|
||||
});
|
||||
});
|
||||
@@ -281,7 +293,7 @@ export class ComparisonStore {
|
||||
* Updates persistent storage with the current font selection.
|
||||
*/
|
||||
private updateStorage() {
|
||||
storage.value = {
|
||||
this.#storage.value = {
|
||||
fontAId: this.#fontA?.id ?? null,
|
||||
fontBId: this.#fontB?.id ?? null,
|
||||
};
|
||||
@@ -365,19 +377,28 @@ export class ComparisonStore {
|
||||
this.#fontA = undefined;
|
||||
this.#fontB = undefined;
|
||||
this.#fontsByIdsStore.setIds([]);
|
||||
storage.clear();
|
||||
this.#storage.clear();
|
||||
this.#typography.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes reactive effects and the persistent store. Call on teardown.
|
||||
*/
|
||||
destroy() {
|
||||
this.#disposeEffects();
|
||||
this.#storage.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
let _comparisonStore: ComparisonStore | undefined;
|
||||
const comparisonStore = createSingleton(
|
||||
() => new ComparisonStore(),
|
||||
instance => {
|
||||
instance.resetAll();
|
||||
instance.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
export function getComparisonStore(): ComparisonStore {
|
||||
return (_comparisonStore ??= new ComparisonStore());
|
||||
}
|
||||
export const getComparisonStore = comparisonStore.get;
|
||||
|
||||
// test-only reset, so specs don't share a live observer
|
||||
export function __resetComparisonStore() {
|
||||
_comparisonStore?.resetAll();
|
||||
_comparisonStore = undefined;
|
||||
}
|
||||
// test-only reset, so specs don't share a live observer or persisted state
|
||||
export const __resetComparisonStore = comparisonStore.reset;
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
|
||||
import type { UnifiedFont } from '$entities/Font';
|
||||
import { UNIFIED_FONTS } from '$entities/Font/testing';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { getQueryClient } from '$shared/api/queryClient';
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
@@ -44,6 +46,8 @@ const mockStorage = vi.hoisted(() => {
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
storage.destroy = vi.fn();
|
||||
|
||||
return storage;
|
||||
});
|
||||
|
||||
@@ -82,6 +86,12 @@ vi.mock('$entities/Font/model', async importOriginal => {
|
||||
};
|
||||
});
|
||||
|
||||
const mockTypography = vi.hoisted(() => ({
|
||||
weight: 400,
|
||||
renderedSize: 48,
|
||||
reset: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$features/AdjustTypography', () => ({
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [],
|
||||
createTypographyControlManager: vi.fn(() => ({
|
||||
@@ -89,15 +99,6 @@ vi.mock('$features/AdjustTypography', () => ({
|
||||
renderedSize: 48,
|
||||
reset: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockTypography = vi.hoisted(() => ({
|
||||
weight: 400,
|
||||
renderedSize: 48,
|
||||
reset: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$features/AdjustTypography/model', () => ({
|
||||
getTypographySettingsStore: () => mockTypography,
|
||||
}));
|
||||
|
||||
|
||||
@@ -9,11 +9,9 @@ import {
|
||||
FontVirtualList,
|
||||
type UnifiedFont,
|
||||
VIRTUAL_INDEX_NOT_LOADED,
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
getFontCatalog,
|
||||
getFontLifecycleManager,
|
||||
} from '$entities/Font/model';
|
||||
} from '$entities/Font';
|
||||
import { getSkeletonWidth } from '$shared/lib/utils';
|
||||
import {
|
||||
Button,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<!--
|
||||
Component: Line
|
||||
Renders one laid-out line as three regions: a fontA bulk run (past the
|
||||
slider), an N-char crossfade window straddling it, and a fontB bulk run (not
|
||||
slider), a crossfade window straddling it (its size derived per line from
|
||||
the line's grapheme count via `windowSizeForLine`), and a fontB bulk run (not
|
||||
yet past). Bulk runs are native shaped text (kerning, ligatures); only the
|
||||
window uses per-char DOM. `split` is a primitive so the render-model
|
||||
`$derived` skips recomputation on ticks that leave it unchanged.
|
||||
@@ -10,10 +11,13 @@
|
||||
import {
|
||||
type ComparisonLine,
|
||||
computeLineRenderModel,
|
||||
windowSizeForLine,
|
||||
} from '$entities/Font';
|
||||
import { getTypographySettingsStore } from '$features/AdjustTypography';
|
||||
import { computeStrutHeight } from '../../lib';
|
||||
import { getComparisonStore } from '../../model';
|
||||
import Character from '../Character/Character.svelte';
|
||||
import SettledText from '../SettledText/SettledText.svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -24,16 +28,14 @@ interface Props {
|
||||
* Count of chars the slider has passed, from `findSplitIndex`.
|
||||
*/
|
||||
split: number;
|
||||
/**
|
||||
* Number of chars in the crossfade window around the split.
|
||||
*/
|
||||
windowSize: number;
|
||||
}
|
||||
|
||||
let { line, split, windowSize }: Props = $props();
|
||||
let { line, split }: Props = $props();
|
||||
|
||||
const comparisonStore = getComparisonStore();
|
||||
|
||||
const windowSize = $derived(windowSizeForLine(line.chars.length));
|
||||
|
||||
const model = $derived(computeLineRenderModel(line, split, windowSize));
|
||||
|
||||
const typography = getTypographySettingsStore();
|
||||
@@ -44,36 +46,9 @@ const fontSizePx = $derived(typography.renderedSize);
|
||||
const lineHeightPx = $derived(typography.height * typography.renderedSize);
|
||||
const letterSpacingPx = $derived(typography.spacing * typography.renderedSize);
|
||||
|
||||
/**
|
||||
* Class and style are single short bindings so the formatter keeps
|
||||
* `<span ...>{text}</span>` on one line. A wrapped text expression would leak
|
||||
* its indentation into the span content under `white-space: pre`.
|
||||
*/
|
||||
const BULK_LEFT_CLASS =
|
||||
'inline-block align-baseline leading-none text-swiss-black/75 dark:text-brand/75 transition-colors duration-300';
|
||||
const BULK_RIGHT_CLASS =
|
||||
'inline-block align-baseline leading-none text-neutral-950 dark:text-white transition-colors duration-300';
|
||||
|
||||
const leftStyle = $derived(`font-family:${fontA?.name ?? ''};font-size:${fontSizePx}px`);
|
||||
const rightStyle = $derived(`font-family:${fontB?.name ?? ''};font-size:${fontSizePx}px`);
|
||||
|
||||
/**
|
||||
* Stops the whole line from jumping up or down as the slider moves. The browser
|
||||
* pins a line box's baseline to its tallest inline box, so without a fixed
|
||||
* reference the baseline (and every glyph) shifts the moment a bulk run appears
|
||||
* or disappears, or the last window char morphs to a font with a taller ascent.
|
||||
* This invisible strut is always the tallest box — `overflow: hidden` puts its
|
||||
* baseline at its bottom edge — so it owns the line baseline and holds it still.
|
||||
* Its height also sets the text's vertical position (the container is block, so
|
||||
* nothing else centers it).
|
||||
*
|
||||
* Height factors are empirical: the first term centers the text, the `* 1.1`
|
||||
* floor keeps the strut above the fonts' ascent at tight line-heights.
|
||||
*/
|
||||
const strutHeightPx = $derived(Math.max(lineHeightPx / 2 + fontSizePx * 0.34, fontSizePx * 1.1));
|
||||
const strutStyle = $derived(
|
||||
`display:inline-block;width:0;overflow:hidden;vertical-align:baseline;height:${strutHeightPx}px`,
|
||||
);
|
||||
// Invisible strut that pins the line baseline so glyphs don't jump as the
|
||||
// slider moves; `computeStrutHeight` explains the why and the formula.
|
||||
const strutHeightPx = $derived(computeStrutHeight(lineHeightPx, fontSizePx));
|
||||
</script>
|
||||
|
||||
<!--
|
||||
@@ -91,15 +66,19 @@ const strutStyle = $derived(
|
||||
style:letter-spacing="{letterSpacingPx}px"
|
||||
style:font-weight={typography.weight}
|
||||
>
|
||||
<span style={strutStyle} aria-hidden="true"></span>
|
||||
<span
|
||||
class="inline-block w-0 overflow-hidden align-baseline"
|
||||
style:height="{strutHeightPx}px"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{#if model.leftText}
|
||||
<span class={BULK_LEFT_CLASS} style={leftStyle}>{model.leftText}</span>
|
||||
<SettledText text={model.leftText} fontFamily={fontA.name} fontSize={fontSizePx} side="left" />
|
||||
{/if}
|
||||
{#each model.windowChars as wc (wc.key)}
|
||||
<Character char={wc.char} {fontA} {fontB} isPast={wc.isPast} fontSize={fontSizePx} />
|
||||
{/each}
|
||||
{#if model.rightText}
|
||||
<span class={BULK_RIGHT_CLASS} style={rightStyle}>{model.rightText}</span>
|
||||
<SettledText text={model.rightText} fontFamily={fontB.name} fontSize={fontSizePx} side="right" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<!--
|
||||
Component: SettledText
|
||||
One side's text settled in a single font — left is fontA the slider has
|
||||
passed, right is fontB not yet reached. A native shaped run (kerning,
|
||||
ligatures); the crossfading middle uses per-char Character cells instead.
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
/**
|
||||
* Run text.
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* CSS font-family name.
|
||||
*/
|
||||
fontFamily: string;
|
||||
/**
|
||||
* Font size in px.
|
||||
*/
|
||||
fontSize: number;
|
||||
/**
|
||||
* Window side — selects the color treatment.
|
||||
*/
|
||||
side: 'left' | 'right';
|
||||
}
|
||||
|
||||
let { text, fontFamily, fontSize, side }: Props = $props();
|
||||
|
||||
// Left (fontA, passed) is dimmed; right (fontB, pending) is full-strength.
|
||||
const SIDE_CLASS: Record<Props['side'], string> = {
|
||||
left:
|
||||
'inline-block align-baseline leading-none text-swiss-black/75 dark:text-brand/75 transition-colors duration-300',
|
||||
right: 'inline-block align-baseline leading-none text-neutral-950 dark:text-white transition-colors duration-300',
|
||||
};
|
||||
</script>
|
||||
|
||||
<span class={SIDE_CLASS[side]} style:font-family="'{fontFamily}'" style:font-size="{fontSize}px">{text}</span>
|
||||
@@ -52,7 +52,6 @@ const side = $derived<Side>(comparisonStore.side);
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<!-- ── Header: title + A/B toggle ────────────────────────────────── -->
|
||||
<div
|
||||
class="
|
||||
p-6 shrink-0
|
||||
@@ -96,14 +95,13 @@ const side = $derived<Side>(comparisonStore.side);
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
<!-- ── Main: content area (no scroll - VirtualList handles scrolling) ─────────────────────────────── -->
|
||||
<!-- No scroll here; VirtualList handles scrolling -->
|
||||
<div class="flex-1 min-h-0 surface-canvas">
|
||||
{#if main}
|
||||
{@render main()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Bottom: fixed controls ─────────────────────────────────────── -->
|
||||
{#if controls}
|
||||
<div
|
||||
class="
|
||||
|
||||
@@ -107,12 +107,6 @@ const layout = new DualFontLayout();
|
||||
|
||||
let layoutResult = $state<ComparisonResult>({ lines: [], totalHeight: 0 });
|
||||
|
||||
/**
|
||||
* N-window size for the per-char crossfade zone around the slider split.
|
||||
* Tuned so chars complete their 100ms opacity crossfade before exiting the window.
|
||||
*/
|
||||
const WINDOW_SIZE = 5;
|
||||
|
||||
// Track container width changes (window resize, sidebar toggle, etc.)
|
||||
$effect(() => {
|
||||
if (!container) {
|
||||
@@ -344,7 +338,7 @@ $effect(() => {
|
||||
>
|
||||
{#each layoutResult.lines as line, lineIdx (lineIdx)}
|
||||
{@const split = findSplitIndex(line, sliderPos, containerWidth)}
|
||||
<Line {line} {split} windowSize={WINDOW_SIZE} />
|
||||
<Line {line} {split} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,12 +2,8 @@ import {
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/svelte';
|
||||
import { setContext } from 'svelte';
|
||||
import Footer from './Footer.svelte';
|
||||
|
||||
// Mock component to provide context
|
||||
import ContextWrapper from '$shared/lib/providers/ResponsiveProvider/ResponsiveProvider.svelte';
|
||||
|
||||
describe('Footer', () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
@@ -18,7 +14,7 @@ describe('Footer', () => {
|
||||
isDesktopLarge: false,
|
||||
};
|
||||
|
||||
const { container } = render(Footer, {
|
||||
render(Footer, {
|
||||
context: new Map([['responsive', mockResponsive]]),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { layoutManager } from './stores';
|
||||
export { getLayoutManager } from './stores';
|
||||
export type { LayoutMode } from './stores';
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { layoutManager } from './layoutStore/layoutStore.svelte';
|
||||
export { getLayoutManager } from './layoutStore/layoutStore.svelte';
|
||||
export type { LayoutMode } from './layoutStore/layoutStore.svelte';
|
||||
|
||||
@@ -16,8 +16,11 @@
|
||||
* - Desktop Large (>= 1280px): 4 columns
|
||||
*/
|
||||
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
import { responsiveManager } from '$shared/lib';
|
||||
import {
|
||||
createPersistentStore,
|
||||
createSingleton,
|
||||
responsiveManager,
|
||||
} from '$shared/lib';
|
||||
|
||||
export type LayoutMode = 'list' | 'grid';
|
||||
|
||||
@@ -144,12 +147,25 @@ class LayoutManager {
|
||||
this.#mode = DEFAULT_CONFIG.mode;
|
||||
this.#store.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the persistent store's save effect. Call on store disposal.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.#store.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton layout manager instance
|
||||
* App-wide layout manager, created on first access. Lazy so its persisted
|
||||
* layout preference isn't read at module load.
|
||||
*/
|
||||
export const layoutManager = new LayoutManager();
|
||||
const layoutManager = createSingleton(() => new LayoutManager(), instance => instance.destroy());
|
||||
|
||||
export const getLayoutManager = layoutManager.get;
|
||||
|
||||
// test-only reset, so specs don't share persisted layout state
|
||||
export const __resetLayoutManager = layoutManager.reset;
|
||||
|
||||
// Export class for testing purposes
|
||||
export { LayoutManager };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user