Compare commits

..

27 Commits

Author SHA1 Message Date
Ilia Mashkov b390efdabe refactor(entities/Font): named public API and expose stores via root barrel
Stores were only reachable by deep-importing $entities/Font/model, so
consumers reached past the slice public API (FSD anti-pattern D, B-3/D-2).

- convert the Font barrels (root, model, model/store, model/types) to
  explicit named exports with export/export type split (B-1)
- re-export the lazy store accessors/classes from the root barrel so the
  entity public API is complete and inert at import (construction stays
  lazy; the root already loads tanstack via ./ui)
- repoint all consumers (SampleList, SampleListSection, FontList,
  comparisonStore, bindings) from $entities/Font/model to $entities/Font
2026-06-02 23:02:30 +03:00
Ilia Mashkov 771bda745c refactor: replace export* barrels with explicit named exports
Wildcard re-exports obscure each slice public surface and weaken
tree-shaking. Convert to explicit named re-exports with export/export
type split (B-1) for ComparisonView, ChangeAppTheme, Breadcrumb/model,
and FilterAndSortFonts/api barrels.
2026-06-02 23:02:18 +03:00
Ilia Mashkov c6c8497906 fix: break import cycles
import/no-cycle (now active) flagged 17 cycles across 12 files:

- shared/ui self-barrel cycles (Logo/Stat/StatGroup/ComboControl/
  FilterGroup/SectionHeader): import siblings relatively instead of
  through the $shared/ui barrel that re-exports them
- shared/lib/utils: roundToStepPrecision imports getDecimalPlaces
  relatively instead of via the utils barrel
- routes: lazy-load Redirect in the router so it no longer statically
  imports a component that imports navigate back from it
2026-06-02 23:02:07 +03:00
Ilia Mashkov f3a10e38df refactor: clear remaining lint errors (comma operator, bind:this ref)
- splitArray: replace the comma-operator reduce body with an explicit
  block + return (no-sequences); behaviour unchanged
- BreadcrumbHeaderSeeded: declare the bind:this ref with $state() so it
  is not flagged as never-assigned (oxlint cannot see template bindings),
  matching the rest of the codebase; guard the onMount use
2026-06-02 23:01:59 +03:00
Ilia Mashkov 9788f07dec refactor: remove unused vars and dead code
Cleanup surfaced once the oxlint config actually loads (no-unused-vars).

- drop dead locals/imports/params (cachedOffsetTop, elasticOut, key,
  unused type imports, unused test imports; _-prefix unused mock params)
- createVirtualizer: keep the _version read (reactive subscription inside
  $derived.by) but bind it to _v so it is not flagged
- scrollBreadcrumbsStore.test: keep the removeEventListener mock side
  effect, drop the unread spy binding
2026-06-02 23:01:48 +03:00
Ilia Mashkov deefb51b57 chore(lint): repair oxlint config and enforce FSD boundaries
oxlint was never loading its config: the file was named oxlint.json but
oxlint only auto-discovers .oxlintrc.json/.jsonc, and the `ignore` field
was invalid (should be `ignorePatterns`). So import/no-cycle and every
other rule silently never ran.

- rename oxlint.json -> .oxlintrc.json, fix ignore -> ignorePatterns
- turn off the restriction/style category grab-bags (opt-in, partly
  contradictory); enable wanted rules individually
- add overrides enforcing FSD layer direction and the interior
  ui -> model -> domain law via no-restricted-imports (oxlint has no
  zone rule); import/no-cycle resolves $-aliases via tsconfig discovery
2026-06-02 23:01:33 +03:00
Ilia Mashkov 431fb41a7f chore: merged with main, conflict resolved 2026-06-02 21:52:33 +03:00
ilia db6384110e Merge pull request 'Feature/popover' (#48) from feature/popover into main
Workflow / build (push) Failing after 3m11s
Workflow / e2e (push) Has been skipped
Workflow / publish (push) Has been skipped
Reviewed-on: #48
2026-06-02 18:47:17 +00:00
Ilia Mashkov cbd95350bb fix(popover): stop animating left/top so first open doesn't slide from corner
Workflow / build (pull_request) Successful in 1m18s
Workflow / e2e (pull_request) Successful in 1m15s
Workflow / publish (pull_request) Has been skipped
2026-06-02 21:38:48 +03:00
Ilia Mashkov a8a985ee6a chore: remove bits-ui dependency 2026-06-02 17:08:58 +03:00
Ilia Mashkov be073286dc refactor(typography-menu): use native Popover instead of bits-ui 2026-06-02 16:28:05 +03:00
Ilia Mashkov 7798c4bbdf refactor(combo-control): use native Popover instead of bits-ui
The native Popover always renders its content (the vertical slider), so the
slider's value label is in the DOM even when closed, and opening is driven by
the browser's declarative popovertarget invoker (not simulated by jsdom on
click). Update the tests to scope value assertions to the trigger and drive
open via showPopover(), matching Popover.svelte.test.ts.
2026-06-02 16:21:32 +03:00
Ilia Mashkov 3ae22ad515 docs(popover): add storybook stories 2026-06-02 16:16:28 +03:00
Ilia Mashkov ffa897ee54 test(popover): cover open/close state and aria wiring 2026-06-02 16:13:40 +03:00
Ilia Mashkov 93c52dd132 fix(popover): gate visibility until positioned, tighten types 2026-06-02 16:12:11 +03:00
Ilia Mashkov 9e0c8f740b feat(popover): native Popover API component with anchored positioning 2026-06-02 16:06:20 +03:00
Ilia Mashkov b1b5177e02 test: add jsdom Popover API shim 2026-06-02 16:03:54 +03:00
Ilia Mashkov ef9cd33e48 feat(popover): add pure anchored-positioning math 2026-06-02 15:59:58 +03:00
ilia f3c76df2c5 Merge pull request 'Feature/slider' (#47) from feature/slider into main
Workflow / build (push) Successful in 1m24s
Workflow / e2e (push) Successful in 1m11s
Workflow / publish (push) Successful in 15s
Reviewed-on: #47
2026-06-02 12:10:42 +00:00
Ilia Mashkov ae2d0e3c2f fix(slider): focus thumb on pointerdown for keyboard parity
Workflow / build (pull_request) Successful in 1m33s
Workflow / e2e (pull_request) Successful in 1m21s
Workflow / publish (pull_request) Has been skipped
2026-06-02 11:14:10 +03:00
Ilia Mashkov 3f5151efa0 docs(slider): update story copy for native implementation 2026-06-02 11:08:58 +03:00
Ilia Mashkov 19d9b07c55 test(slider): centralize jsdom pointer shims and add vertical drag test 2026-06-02 11:08:07 +03:00
Ilia Mashkov 1209358d40 test(slider): cover keyboard and pointer interaction 2026-06-02 11:02:52 +03:00
Ilia Mashkov d7decd7a00 fix(slider): normalize value, reactive trackEl, aria-valuetext 2026-06-02 11:01:08 +03:00
Ilia Mashkov 9d6220d2ec feat(slider): reimplement natively without bits-ui 2026-06-02 10:54:54 +03:00
Ilia Mashkov 4756682863 feat(slider): add pure value/position math helpers 2026-06-02 10:50:46 +03:00
Ilia Mashkov 7ddf232e3a refactor(sample-list): replace layoutManager singleton with lazy accessor
Convert the eager layoutManager singleton to getLayoutManager() (+ __resetLayoutManager
for tests), so its persisted layout preference is read on first access rather than at
module load. Update the model barrels and consumers (LayoutSwitch, SampleListSection,
SampleList) with $derived reads; the LayoutSwitch test resolves via the accessor.
2026-06-02 09:09:20 +03:00
58 changed files with 1715 additions and 404 deletions
+195
View File
@@ -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
View File
@@ -1,29 +0,0 @@
{
"plugins": ["import"],
"categories": {
"correctness": "error",
"suspicious": "warn",
"perf": "warn",
"style": "warn",
"restriction": "error"
},
"env": {
"browser": true,
"es2021": true
},
"ignore": [
"node_modules",
"dist",
"build",
".svelte-kit",
".vercel",
"*.config.js",
"*.config.ts"
],
"rules": {
"no-console": "off",
"no-debugger": "error",
"no-alert": "warn",
"import/no-cycle": "error"
}
}
-1
View File
@@ -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
View File
@@ -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
+49 -4
View File
@@ -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);
},
);
+5 -2
View File
@@ -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)
+4 -1
View File
@@ -23,4 +23,7 @@ export type {
FontCollectionState,
} from './store';
export * from './store/fontLifecycle';
export type {
FontLoadRequestConfig,
FontLoadStatus,
} from './store/fontLifecycle';
+1 -5
View File
@@ -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,
@@ -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
+7 -2
View File
@@ -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);
+2 -2
View File
@@ -1,2 +1,2 @@
export * from './model';
export * from './ui';
export { getThemeManager } from './model';
export { ThemeSwitch } from './ui';
+6 -1
View File
@@ -1 +1,6 @@
export * from './filters/filters';
export { fetchProxyFilters } from './filters/filters';
export type {
FilterMetadata,
FilterOption,
ProxyFiltersResponse,
} from './filters/filters';
@@ -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';
+3 -2
View File
@@ -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];
},
[[], []],
);
}
+49 -53
View File
@@ -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');
});
});
+2 -2
View File
@@ -5,8 +5,6 @@
<script lang="ts">
import type { Filter } from '$shared/lib';
import { cn } from '$shared/lib';
import { Button } from '$shared/ui';
import { Label } from '$shared/ui';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import { cubicOut } from 'svelte/easing';
@@ -14,6 +12,8 @@ import {
draw,
fly,
} from 'svelte/transition';
import { Button } from '../Button';
import Label from '../Label/Label.svelte';
interface Props {
/**
+1 -1
View File
@@ -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>
+225
View File
@@ -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
});
});
+149
View File
@@ -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 {
/**
+1 -3
View File
@@ -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
+197 -60
View File
@@ -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">
+107
View File
@@ -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');
});
});
+55
View File
@@ -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);
});
});
+59
View File
@@ -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 leftmin, rightmax. Vertical is inverted so that
* upmax, 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);
}
+1 -1
View File
@@ -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'> {
/**
+1 -1
View File
@@ -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;
+6
View File
@@ -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
+2 -1
View File
@@ -1,2 +1,3 @@
export * from './model';
export { getComparisonStore } from './model';
export type { Side } from './model';
export { ComparisonView } from './ui';
+7 -3
View File
@@ -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 -1
View File
@@ -1,2 +1,2 @@
export { layoutManager } from './stores';
export { getLayoutManager } from './stores';
export type { LayoutMode } from './stores';
+1 -1
View File
@@ -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>
+62
View File
@@ -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;
}
+1 -98
View File
@@ -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"