Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-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"
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,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",
|
||||
|
||||
+83
-22
@@ -1,29 +1,90 @@
|
||||
export * from './domain';
|
||||
export * from './lib';
|
||||
export * from './ui';
|
||||
export {
|
||||
computeLineRenderModel,
|
||||
DualFontLayout,
|
||||
findSplitIndex,
|
||||
} 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,
|
||||
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';
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
-3
@@ -302,9 +302,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;
|
||||
}
|
||||
|
||||
@@ -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 })}
|
||||
<Button variant="primary" {...props}>
|
||||
{#snippet icon()}
|
||||
<Settings2Icon class="size-4" />
|
||||
{/snippet}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<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.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}
|
||||
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>
|
||||
<button
|
||||
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>
|
||||
</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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
export * from './filters/filters';
|
||||
export { fetchProxyFilters } from './filters/filters';
|
||||
export type {
|
||||
FilterMetadata,
|
||||
FilterOption,
|
||||
ProxyFiltersResponse,
|
||||
} from './filters/filters';
|
||||
|
||||
-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) => ({
|
||||
|
||||
@@ -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,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'),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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];
|
||||
},
|
||||
[[], []],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -103,59 +103,55 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
|
||||
<!-- Trigger -->
|
||||
<div class="relative mx-1">
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class={cn(
|
||||
'flex flex-col flex-center w-14 py-1',
|
||||
'select-none rounded-none transition-all duration-fast',
|
||||
'border border-transparent',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
|
||||
open
|
||||
? 'surface-card-elevated'
|
||||
: 'hover:bg-paper/50 dark:hover:bg-dark-card/50',
|
||||
)}
|
||||
aria-label={controlLabel ? `${controlLabel}: ${formattedValue()}` : undefined}
|
||||
>
|
||||
<!-- Label row -->
|
||||
{#if displayLabel}
|
||||
<span
|
||||
class="
|
||||
text-3xs text-label-mono
|
||||
text-neutral-900 dark:text-neutral-100
|
||||
mb-0.5 leading-none
|
||||
"
|
||||
>
|
||||
{displayLabel}
|
||||
</span>
|
||||
{/if}
|
||||
<Popover bind:open side="top" align="center">
|
||||
{#snippet trigger(props)}
|
||||
<button
|
||||
{...props}
|
||||
class={cn(
|
||||
'flex flex-col flex-center w-14 py-1',
|
||||
'select-none rounded-none transition-all duration-fast',
|
||||
'border border-transparent',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
|
||||
open
|
||||
? 'surface-card-elevated'
|
||||
: 'hover:bg-paper/50 dark:hover:bg-dark-card/50',
|
||||
)}
|
||||
aria-label={controlLabel ? `${controlLabel}: ${formattedValue()}` : undefined}
|
||||
>
|
||||
<!-- Label row -->
|
||||
{#if displayLabel}
|
||||
<span
|
||||
class="
|
||||
text-3xs text-label-mono
|
||||
text-neutral-900 dark:text-neutral-100
|
||||
mb-0.5 leading-none
|
||||
"
|
||||
>
|
||||
{displayLabel}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Value row -->
|
||||
<TechText variant="muted" size="md">
|
||||
{formattedValue()}
|
||||
</TechText>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<!-- Value row -->
|
||||
<TechText variant="muted" size="md">
|
||||
{formattedValue()}
|
||||
</TechText>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<Slider
|
||||
class="h-full"
|
||||
bind:value={control.value}
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
orientation="vertical"
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
{#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}
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
orientation="vertical"
|
||||
/>
|
||||
</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 {
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
@@ -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,81 +208,101 @@ 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
|
||||
relative grow w-px overflow-visible
|
||||
group-hover:bg-neutral-300 dark:group-hover:bg-neutral-700
|
||||
transition-colors
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="
|
||||
bg-neutral-200 dark:bg-neutral-800
|
||||
relative grow w-px overflow-visible
|
||||
group-hover:bg-neutral-300 dark:group-hover:bg-neutral-700
|
||||
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}
|
||||
aria-label="Value"
|
||||
/>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</Slider.Root>
|
||||
<span
|
||||
role="slider"
|
||||
bind:this={thumbEl}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
aria-label="Value"
|
||||
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
|
||||
relative grow h-px overflow-visible
|
||||
group-hover:bg-neutral-300 dark:group-hover:bg-neutral-700
|
||||
transition-colors
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="
|
||||
bg-neutral-200 dark:bg-neutral-800
|
||||
relative grow h-px overflow-visible
|
||||
group-hover:bg-neutral-300 dark:group-hover:bg-neutral-700
|
||||
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}
|
||||
aria-label="Value"
|
||||
/>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</Slider.Root>
|
||||
<span
|
||||
role="slider"
|
||||
bind:this={thumbEl}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
aria-label="Value"
|
||||
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;
|
||||
|
||||
@@ -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,7 @@
|
||||
export * from './utils/dotTransition';
|
||||
export * from './utils/ensureCanvasFonts/ensureCanvasFonts';
|
||||
export * from './utils/getPretextFontString/getPretextFontString';
|
||||
export {
|
||||
createDotCrossfade,
|
||||
getDotTransitionParams,
|
||||
} from './utils/dotTransition';
|
||||
export type { DotTransitionParams } from './utils/dotTransition';
|
||||
export { ensureCanvasFonts } from './utils/ensureCanvasFonts/ensureCanvasFonts';
|
||||
export { getPretextFontString } from './utils/getPretextFontString/getPretextFontString';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { VIRTUAL_INDEX_NOT_LOADED } from '$entities/Font';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import {
|
||||
type CrossfadeParams,
|
||||
|
||||
@@ -13,18 +13,16 @@
|
||||
* - Slider position for character-by-character morphing
|
||||
*/
|
||||
|
||||
import {
|
||||
type FontLoadRequestConfig,
|
||||
type UnifiedFont,
|
||||
getFontUrl,
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
type FontCatalogStore,
|
||||
type FontLifecycleManager,
|
||||
type FontLoadRequestConfig,
|
||||
FontsByIdsStore,
|
||||
type UnifiedFont,
|
||||
getFontCatalog,
|
||||
getFontLifecycleManager,
|
||||
} from '$entities/Font/model';
|
||||
getFontUrl,
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
type TypographySettingsStore,
|
||||
getTypographySettingsStore,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -146,10 +146,20 @@ class LayoutManager {
|
||||
}
|
||||
}
|
||||
|
||||
let _layoutManager: LayoutManager | undefined;
|
||||
|
||||
/**
|
||||
* 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();
|
||||
export function getLayoutManager(): LayoutManager {
|
||||
return (_layoutManager ??= new LayoutManager());
|
||||
}
|
||||
|
||||
// test-only reset, so specs don't share persisted layout state
|
||||
export function __resetLayoutManager() {
|
||||
_layoutManager = undefined;
|
||||
}
|
||||
|
||||
// Export class for testing purposes
|
||||
export { LayoutManager };
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
|
||||
// Helper to flush Svelte effects (they run in microtasks)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ButtonGroup } from '$shared/ui';
|
||||
import { IconButton } from '$shared/ui';
|
||||
import GridIcon from '@lucide/svelte/icons/layout-grid';
|
||||
import ListIcon from '@lucide/svelte/icons/stretch-horizontal';
|
||||
import { layoutManager } from '../../model';
|
||||
import { getLayoutManager } from '../../model';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -18,18 +18,21 @@ interface Props {
|
||||
|
||||
const { class: className }: Props = $props();
|
||||
|
||||
const layoutManager = getLayoutManager();
|
||||
const mode = $derived(layoutManager.mode);
|
||||
|
||||
function handleClick() {
|
||||
layoutManager.toggleMode();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ButtonGroup class={className}>
|
||||
<IconButton active={layoutManager.mode === 'list'} onclick={handleClick}>
|
||||
<IconButton active={mode === 'list'} onclick={handleClick}>
|
||||
{#snippet icon()}
|
||||
<ListIcon class="size-4" />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
<IconButton active={layoutManager.mode === 'grid'} onclick={handleClick}>
|
||||
<IconButton active={mode === 'grid'} onclick={handleClick}>
|
||||
{#snippet icon()}
|
||||
<GridIcon class="size-4" />
|
||||
{/snippet}
|
||||
|
||||
@@ -4,14 +4,23 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/svelte';
|
||||
import { layoutManager } from '../../model';
|
||||
import { afterEach } from 'vitest';
|
||||
import { getLayoutManager } from '../../model';
|
||||
import { __resetLayoutManager } from '../../model/stores/layoutStore/layoutStore.svelte';
|
||||
import LayoutSwitch from './LayoutSwitch.svelte';
|
||||
|
||||
describe('LayoutSwitch', () => {
|
||||
let layoutManager: ReturnType<typeof getLayoutManager>;
|
||||
|
||||
beforeEach(() => {
|
||||
layoutManager = getLayoutManager();
|
||||
layoutManager.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetLayoutManager();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders two icon buttons', () => {
|
||||
render(LayoutSwitch);
|
||||
|
||||
@@ -8,11 +8,9 @@
|
||||
import {
|
||||
FontVirtualList,
|
||||
createFontRowSizeResolver,
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
getFontCatalog,
|
||||
getFontLifecycleManager,
|
||||
} from '$entities/Font/model';
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
TypographyMenu,
|
||||
getTypographySettingsStore,
|
||||
@@ -20,11 +18,15 @@ import {
|
||||
import { FontSampler } from '$features/DisplayFont';
|
||||
import { throttle } from '$shared/lib/utils';
|
||||
import { Skeleton } from '$shared/ui';
|
||||
import { layoutManager } from '../../model';
|
||||
import { getLayoutManager } from '../../model';
|
||||
|
||||
const fontCatalog = getFontCatalog();
|
||||
const typographySettingsStore = getTypographySettingsStore();
|
||||
const fontLifecycleManager = getFontLifecycleManager();
|
||||
const layoutManager = getLayoutManager();
|
||||
|
||||
const columns = $derived(layoutManager.columns);
|
||||
const gap = $derived(layoutManager.gap);
|
||||
|
||||
// FontSampler chrome heights — derived from Tailwind classes in FontSampler.svelte.
|
||||
// Header: py-3 (12+12px padding) + ~32px content row ≈ 56px.
|
||||
@@ -114,8 +116,8 @@ const fontRowHeight = $derived.by(() =>
|
||||
itemHeight={fontRowHeight}
|
||||
useWindowScroll={true}
|
||||
weight={typographySettingsStore.weight}
|
||||
columns={layoutManager.columns}
|
||||
gap={layoutManager.gap}
|
||||
{columns}
|
||||
{gap}
|
||||
{skeleton}
|
||||
>
|
||||
{#snippet children({ item: font, index })}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Wraps SampleList with a Section component
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getFontCatalog } from '$entities/Font/model';
|
||||
import { getFontCatalog } from '$entities/Font';
|
||||
import { NavigationWrapper } from '$features/Breadcrumb';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Section,
|
||||
} from '$shared/ui';
|
||||
import { getContext } from 'svelte';
|
||||
import { layoutManager } from '../../model';
|
||||
import { getLayoutManager } from '../../model';
|
||||
import LayoutSwitch from '../LayoutSwitch/LayoutSwitch.svelte';
|
||||
import SampleList from '../SampleList/SampleList.svelte';
|
||||
|
||||
@@ -29,6 +29,9 @@ const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
const fontCatalog = getFontCatalog();
|
||||
const total = $derived<number>(fontCatalog?.pagination?.total);
|
||||
|
||||
const layoutManager = getLayoutManager();
|
||||
const mode = $derived(layoutManager.mode);
|
||||
</script>
|
||||
|
||||
<NavigationWrapper index={2} title="Samples">
|
||||
@@ -46,7 +49,7 @@ const total = $derived<number>(fontCatalog?.pagination?.total);
|
||||
<div class="flex items-center gap-3 md:gap-4">
|
||||
<div class="hidden md:flex items-center gap-2 mr-4">
|
||||
<Label variant="muted" size="sm">view_mode: </Label>
|
||||
<Label variant="default" size="sm" bold>{layoutManager.mode}</Label>
|
||||
<Label variant="default" size="sm" bold>{mode}</Label>
|
||||
</div>
|
||||
<LayoutSwitch />
|
||||
</div>
|
||||
|
||||
@@ -44,3 +44,65 @@ Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// jsdom lacks PointerEvent; back it with MouseEvent so clientX/clientY survive.
|
||||
if (typeof PointerEvent === 'undefined') {
|
||||
class PointerEventPolyfill extends MouseEvent {
|
||||
pointerId: number;
|
||||
constructor(type: string, params: PointerEventInit = {}) {
|
||||
super(type, params);
|
||||
this.pointerId = params.pointerId ?? 1;
|
||||
}
|
||||
}
|
||||
// @ts-expect-error assigning polyfill to the global scope
|
||||
global.PointerEvent = PointerEventPolyfill;
|
||||
}
|
||||
|
||||
// jsdom lacks pointer capture
|
||||
HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||
HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||
|
||||
// jsdom lacks the Popover API. Minimal shim: methods toggle an internal flag,
|
||||
// dispatch a `toggle` event ({ oldState, newState }), and make
|
||||
// matches(':popover-open') reflect the flag so components can sync state.
|
||||
if (typeof HTMLElement.prototype.showPopover !== 'function') {
|
||||
const openFlag = new WeakSet<HTMLElement>();
|
||||
|
||||
const fireToggle = (el: HTMLElement, oldState: string, newState: string) => {
|
||||
const event = new Event('toggle') as Event & { oldState: string; newState: string };
|
||||
event.oldState = oldState;
|
||||
event.newState = newState;
|
||||
el.dispatchEvent(event);
|
||||
};
|
||||
|
||||
HTMLElement.prototype.showPopover = function showPopover(this: HTMLElement) {
|
||||
if (openFlag.has(this)) {
|
||||
return;
|
||||
}
|
||||
openFlag.add(this);
|
||||
fireToggle(this, 'closed', 'open');
|
||||
};
|
||||
HTMLElement.prototype.hidePopover = function hidePopover(this: HTMLElement) {
|
||||
if (!openFlag.has(this)) {
|
||||
return;
|
||||
}
|
||||
openFlag.delete(this);
|
||||
fireToggle(this, 'open', 'closed');
|
||||
};
|
||||
HTMLElement.prototype.togglePopover = function togglePopover(this: HTMLElement) {
|
||||
if (openFlag.has(this)) {
|
||||
this.hidePopover();
|
||||
return !openFlag.has(this);
|
||||
}
|
||||
this.showPopover();
|
||||
return openFlag.has(this);
|
||||
};
|
||||
|
||||
const originalMatches = Element.prototype.matches;
|
||||
Element.prototype.matches = function matches(this: Element, selector: string): boolean {
|
||||
if (selector === ':popover-open') {
|
||||
return this instanceof HTMLElement && openFlag.has(this);
|
||||
}
|
||||
return originalMatches.call(this, selector);
|
||||
} as typeof Element.prototype.matches;
|
||||
}
|
||||
|
||||
@@ -565,32 +565,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@floating-ui/core@npm:^1.7.1, @floating-ui/core@npm:^1.7.3":
|
||||
version: 1.7.3
|
||||
resolution: "@floating-ui/core@npm:1.7.3"
|
||||
dependencies:
|
||||
"@floating-ui/utils": "npm:^0.2.10"
|
||||
checksum: 10c0/edfc23800122d81df0df0fb780b7328ae6c5f00efbb55bd48ea340f4af8c5b3b121ceb4bb81220966ab0f87b443204d37105abdd93d94846468be3243984144c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@floating-ui/dom@npm:^1.7.1":
|
||||
version: 1.7.4
|
||||
resolution: "@floating-ui/dom@npm:1.7.4"
|
||||
dependencies:
|
||||
"@floating-ui/core": "npm:^1.7.3"
|
||||
"@floating-ui/utils": "npm:^0.2.10"
|
||||
checksum: 10c0/da6166c25f9b0729caa9f498685a73a0e28251613b35d27db8de8014bc9d045158a23c092b405321a3d67c2064909b6e2a7e6c1c9cc0f62967dca5779f5aef30
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@floating-ui/utils@npm:^0.2.10":
|
||||
version: 0.2.10
|
||||
resolution: "@floating-ui/utils@npm:0.2.10"
|
||||
checksum: 10c0/e9bc2a1730ede1ee25843937e911ab6e846a733a4488623cd353f94721b05ec2c9ec6437613a2ac9379a94c2fd40c797a2ba6fa1df2716f5ce4aa6ddb1cf9ea4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@internationalized/date@npm:3.12.1":
|
||||
version: 3.12.1
|
||||
resolution: "@internationalized/date@npm:3.12.1"
|
||||
@@ -1891,23 +1865,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"bits-ui@npm:2.18.1":
|
||||
version: 2.18.1
|
||||
resolution: "bits-ui@npm:2.18.1"
|
||||
dependencies:
|
||||
"@floating-ui/core": "npm:^1.7.1"
|
||||
"@floating-ui/dom": "npm:^1.7.1"
|
||||
esm-env: "npm:^1.1.2"
|
||||
runed: "npm:^0.35.1"
|
||||
svelte-toolbelt: "npm:^0.10.6"
|
||||
tabbable: "npm:^6.2.0"
|
||||
peerDependencies:
|
||||
"@internationalized/date": ^3.8.1
|
||||
svelte: ^5.33.0
|
||||
checksum: 10c0/1ed513a994d66449ab00c091f70111de30182856a167110ec3a413317014e7b949c50a8501aaa8a7603829394e5499bcb5a2b7c4a38a541b3820aad03e01f3cf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"bundle-name@npm:^4.1.0":
|
||||
version: 4.1.0
|
||||
resolution: "bundle-name@npm:4.1.0"
|
||||
@@ -2382,7 +2339,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"esm-env@npm:^1.0.0, esm-env@npm:^1.1.2, esm-env@npm:^1.2.1, esm-env@npm:^1.2.2":
|
||||
"esm-env@npm:^1.2.1, esm-env@npm:^1.2.2":
|
||||
version: 1.2.2
|
||||
resolution: "esm-env@npm:1.2.2"
|
||||
checksum: 10c0/3d25c973f2fd69c25ffff29c964399cea573fe10795ecc1d26f6f957ce0483d3254e1cceddb34bf3296a0d7b0f1d53a28992f064ba509dfe6366751e752c4166
|
||||
@@ -2569,7 +2526,6 @@ __metadata:
|
||||
"@types/jsdom": "npm:28.0.1"
|
||||
"@vitest/browser-playwright": "npm:4.1.5"
|
||||
"@vitest/coverage-v8": "npm:4.1.5"
|
||||
bits-ui: "npm:2.18.1"
|
||||
clsx: "npm:^2.1.1"
|
||||
dprint: "npm:0.54.0"
|
||||
jsdom: "npm:29.1.1"
|
||||
@@ -2672,13 +2628,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"inline-style-parser@npm:0.2.7":
|
||||
version: 0.2.7
|
||||
resolution: "inline-style-parser@npm:0.2.7"
|
||||
checksum: 10c0/d884d76f84959517430ae6c22f0bda59bb3f58f539f99aac75a8d786199ec594ed648c6ab4640531f9fc244b0ed5cd8c458078e592d016ef06de793beb1debff
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ip-address@npm:^10.0.1":
|
||||
version: 10.1.0
|
||||
resolution: "ip-address@npm:10.1.0"
|
||||
@@ -3743,23 +3692,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"runed@npm:^0.35.1":
|
||||
version: 0.35.1
|
||||
resolution: "runed@npm:0.35.1"
|
||||
dependencies:
|
||||
dequal: "npm:^2.0.3"
|
||||
esm-env: "npm:^1.0.0"
|
||||
lz-string: "npm:^1.5.0"
|
||||
peerDependencies:
|
||||
"@sveltejs/kit": ^2.21.0
|
||||
svelte: ^5.7.0
|
||||
peerDependenciesMeta:
|
||||
"@sveltejs/kit":
|
||||
optional: true
|
||||
checksum: 10c0/ea6c6ba684b52075a5991a0b79d4c381d987f802eabe5689afd495589fdf6fa5aae7eae6843091364b8602643b342deda85f99267c2ff837c83c28d5d9e771ce
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"sade@npm:^1.7.4":
|
||||
version: 1.8.1
|
||||
resolution: "sade@npm:1.8.1"
|
||||
@@ -3949,15 +3881,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"style-to-object@npm:^1.0.8":
|
||||
version: 1.0.14
|
||||
resolution: "style-to-object@npm:1.0.14"
|
||||
dependencies:
|
||||
inline-style-parser: "npm:0.2.7"
|
||||
checksum: 10c0/854d9e9b77afc336e6d7b09348e7939f2617b34eb0895824b066d8cd1790284cb6d8b2ba36be88025b2595d715dba14b299ae76e4628a366541106f639e13679
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"supports-color@npm:^7.1.0":
|
||||
version: 7.2.0
|
||||
resolution: "supports-color@npm:7.2.0"
|
||||
@@ -4040,19 +3963,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"svelte-toolbelt@npm:^0.10.6":
|
||||
version: 0.10.6
|
||||
resolution: "svelte-toolbelt@npm:0.10.6"
|
||||
dependencies:
|
||||
clsx: "npm:^2.1.1"
|
||||
runed: "npm:^0.35.1"
|
||||
style-to-object: "npm:^1.0.8"
|
||||
peerDependencies:
|
||||
svelte: ^5.30.2
|
||||
checksum: 10c0/1d8edc5ba5daba4b97e427f1a324f86157b0e9efd98acdd88e852a3c901a7e0ad06170422376d24bf9dad8016ef06075f298778b37b91335ba51599b5ae9c8af
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"svelte2tsx@npm:^0.7.44":
|
||||
version: 0.7.46
|
||||
resolution: "svelte2tsx@npm:0.7.46"
|
||||
@@ -4132,13 +4042,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tabbable@npm:^6.2.0":
|
||||
version: 6.3.0
|
||||
resolution: "tabbable@npm:6.3.0"
|
||||
checksum: 10c0/57ba019d29b5cfa0c862248883bcec0e6d29d8f156ba52a1f425e7cfeca4a0fc701ab8d035c4c86ddf74ecdbd0e9f454a88d9b55d924a51f444038e9cd14d7a0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tailwind-merge@npm:3.5.0":
|
||||
version: 3.5.0
|
||||
resolution: "tailwind-merge@npm:3.5.0"
|
||||
|
||||
Reference in New Issue
Block a user