Compare commits
197 Commits
a9aba10f09
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 30bbfa7e11 | |||
|
|
eff3979372 | ||
|
|
da79dd2e35 | ||
|
|
9d1f59d819 | ||
|
|
935b065843 | ||
|
|
d15b2ffe3f | ||
|
|
51ea8a9902 | ||
|
|
e81cadb32a | ||
|
|
1c3908f89e | ||
|
|
206e609a2d | ||
|
|
ff71d1c8c9 | ||
|
|
24ca2f6c41 | ||
|
|
3abe5723c7 | ||
| 4f181d1d92 | |||
|
|
aa4796079a | ||
|
|
f18454f9b3 | ||
|
|
e3924d43d8 | ||
|
|
0f6a4d6587 | ||
|
|
8f4faa3328 | ||
|
|
5867028be6 | ||
|
|
b8d019b824 | ||
|
|
45ed0d5601 | ||
|
|
9f91fed692 | ||
|
|
201280093f | ||
|
|
55b27973a2 | ||
|
|
5fa79e06e9 | ||
|
|
ee0749e828 | ||
|
|
5dae5fb7ea | ||
|
|
20f65ee396 | ||
|
|
010b8ad04b | ||
|
|
ce1dcd92ab | ||
|
|
ce609728c3 | ||
|
|
147df04c22 | ||
|
|
f356851d97 | ||
|
|
411dbfefcb | ||
|
|
a65d692139 | ||
|
|
3330f13228 | ||
|
|
ad6e1da292 | ||
|
|
ac8f0456b0 | ||
|
|
77668f507c | ||
|
|
23831efbe6 | ||
|
|
42854b4950 | ||
|
|
c45429f38d | ||
|
|
4d57f2084c | ||
|
|
bee529dff8 | ||
|
|
1f793278d1 | ||
|
|
4f76a03e33 | ||
|
|
940e20515b | ||
|
|
f15114a78b | ||
|
|
6ba37c9e4a | ||
|
|
858daff860 | ||
|
|
b7f54b503c | ||
|
|
17de544bdb | ||
|
|
a0ac52a348 | ||
|
|
99966d2de9 | ||
|
|
72334a3d05 | ||
|
|
8780b6932c | ||
|
|
5d2c05e192 | ||
|
|
1031b96ec5 | ||
|
|
4fdc99a15a | ||
|
|
9e74a2c2c6 | ||
|
|
aa3f467821 | ||
|
|
6001f50cf5 | ||
|
|
c2d0992015 | ||
|
|
bc56265717 | ||
|
|
2f45dc3620 | ||
|
|
d282448c53 | ||
|
|
f2e8de1d1d | ||
|
|
cee2a80c41 | ||
|
|
8b02333c01 | ||
|
|
0e85851cfd | ||
|
|
7dce7911c0 | ||
|
|
5e3929575d | ||
|
|
d3297d519f | ||
|
|
21d8273967 | ||
|
|
cdb2c355c0 | ||
|
|
3423eebf77 | ||
|
|
08d474289b | ||
|
|
2e6fc0e858 | ||
|
|
173816b5c0 | ||
|
|
d749f86edc | ||
|
|
8aad8942fc | ||
|
|
0eebe03bf8 | ||
|
|
2508168a3e | ||
|
|
a557e15759 | ||
|
|
a5b9238306 | ||
|
|
f01299f3d1 | ||
| 223dff2cda | |||
|
|
945132b6f5 | ||
|
|
e1117667d2 | ||
|
|
1c2fca784f | ||
|
|
3f0761aca7 | ||
|
|
0db13404e2 | ||
|
|
e39ed86a04 | ||
|
|
b43aa99f3e | ||
|
|
0a52bd6f6b | ||
|
|
4734b1120a | ||
|
|
7aa9fbd394 | ||
| 1eef9eff07 | |||
|
|
aefe03d811 | ||
|
|
e90b2bede5 | ||
|
|
bb8d2d685c | ||
|
|
c8d249d6ce | ||
| e3050097c6 | |||
|
|
faf9b8570b | ||
|
|
1fc9572f3d | ||
|
|
d006c662a9 | ||
|
|
422363d329 | ||
|
|
61c67acfb8 | ||
|
|
6945169279 | ||
|
|
055b02f720 | ||
|
|
7018b6a836 | ||
|
|
5d8869b3f2 | ||
|
|
cb740df1b2 | ||
|
|
d40170cfad | ||
|
|
3787ae260f | ||
|
|
a8858f6199 | ||
|
|
b1de03106f | ||
|
|
f3e9777267 | ||
|
|
c4abe84b0a | ||
|
|
1bd996659e | ||
|
|
e810135fc5 | ||
|
|
fc5a5c44e7 | ||
| d64de6f06b | |||
|
|
10788cf754 | ||
|
|
8eca240982 | ||
|
|
6f840fbad8 | ||
|
|
a7d08a9329 | ||
|
|
df2d6bae3b | ||
|
|
ce9665a842 | ||
|
|
b4e97da3a0 | ||
|
|
b3c0898735 | ||
|
|
f4875d7324 | ||
|
|
b16928ac80 | ||
|
|
7f01a9cc85 | ||
|
|
a1bc359c7f | ||
|
|
662d4ac626 | ||
|
|
4d7ae6c1c6 | ||
|
|
cb0e89b257 | ||
|
|
204aa75959 | ||
|
|
b72ec8afdf | ||
|
|
fa08986d60 | ||
|
|
359617212d | ||
|
|
beff194e5b | ||
|
|
f24c93c105 | ||
|
|
c16ef4acbf | ||
|
|
c91ced3617 | ||
|
|
a48c9bce0c | ||
|
|
152be85e34 | ||
|
|
b09b89f4fc | ||
|
|
1a23ec2f28 | ||
|
|
86ea9cd887 | ||
|
|
10919a9881 | ||
|
|
180abd150d | ||
|
|
c4bfb1db56 | ||
|
|
98a94e91ed | ||
|
|
a1b7f78fc4 | ||
|
|
41c5ceb848 | ||
|
|
780d76dced | ||
|
|
49f5564cc9 | ||
|
|
0ff8aec8f9 | ||
|
|
597ff7ec90 | ||
|
|
46a3c3e8fc | ||
|
|
4891cd3bbd | ||
|
|
70f2f82df0 | ||
|
|
0d572708c0 | ||
|
|
492c3573d0 | ||
|
|
a1080d3b34 | ||
|
|
fedf3f88e7 | ||
|
|
a26bcbecff | ||
|
|
352f30a558 | ||
|
|
8580884896 | ||
|
|
84417e440f | ||
| 8fda47ed57 | |||
|
|
1b9fe14f01 | ||
|
|
3537f6f62c | ||
|
|
88f4cd97f9 | ||
|
|
9167629616 | ||
|
|
b304e841de | ||
|
|
3ed63562b7 | ||
|
|
4b440496ba | ||
|
|
e4aacf609e | ||
|
|
51c2b6b5da | ||
|
|
195ae09fa2 | ||
|
|
b9eccbf627 | ||
|
|
63888e510c | ||
| cf8d3dffb9 | |||
|
|
1e2daa410c | ||
|
|
adf6dc93ea | ||
|
|
596a023d24 | ||
|
|
8195e9baa8 | ||
|
|
0554fcada7 | ||
|
|
9a794b626b | ||
|
|
40346aa9aa | ||
|
|
2b7f21711b | ||
|
|
69ae955131 | ||
|
|
12844432ac |
@@ -42,3 +42,19 @@ jobs:
|
|||||||
|
|
||||||
- name: Type Check
|
- name: Type Check
|
||||||
run: yarn check:shadcn-excluded
|
run: yarn check:shadcn-excluded
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: build # Only runs if tests/lint pass
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/main' # Only deploy from main branch
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
run: echo "${{ secrets.CI_DEPLOY_TOKEN }}" | docker login git.allmy.work -u ${{ gitea.repository_owner }} --password-stdin
|
||||||
|
|
||||||
|
- name: Build and Push Docker Image
|
||||||
|
run: |
|
||||||
|
docker build -t git.allmy.work/${{ gitea.repository }}:latest .
|
||||||
|
docker push git.allmy.work/${{ gitea.repository }}:latest
|
||||||
|
|||||||
29
.storybook/Decorator.svelte
Normal file
29
.storybook/Decorator.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!--
|
||||||
|
Component: Decorator
|
||||||
|
Global Storybook decorator that wraps all stories with necessary providers.
|
||||||
|
|
||||||
|
This provides:
|
||||||
|
- ResponsiveManager context for breakpoint tracking
|
||||||
|
- TooltipProvider for shadcn Tooltip components
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { createResponsiveManager } from '$shared/lib';
|
||||||
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
|
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||||
|
import { setContext } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: import('svelte').Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props();
|
||||||
|
|
||||||
|
// Create and provide responsive context
|
||||||
|
const responsiveManager = createResponsiveManager();
|
||||||
|
$effect(() => responsiveManager.init());
|
||||||
|
setContext<ResponsiveManager>('responsive', responsiveManager);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TooltipProvider delayDuration={200} skipDelayDuration={300}>
|
||||||
|
{@render children()}
|
||||||
|
</TooltipProvider>
|
||||||
@@ -7,9 +7,9 @@ interface Props {
|
|||||||
let { children, width = 'max-w-3xl' }: Props = $props();
|
let { children, width = 'max-w-3xl' }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-h-screen w-full items-center justify-center bg-slate-50 p-8">
|
<div class="flex min-h-screen w-full items-center justify-center bg-background p-8">
|
||||||
<div class="w-full bg-white shadow-xl ring-1 ring-slate-200 rounded-xl p-12 {width}">
|
<div class="w-full bg-card shadow-lg ring-1 ring-border rounded-xl p-12 {width}">
|
||||||
<div class="relative flex justify-center items-center">
|
<div class="relative flex justify-center items-center text-foreground">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ const config: StorybookConfig = {
|
|||||||
{
|
{
|
||||||
name: '@storybook/addon-svelte-csf',
|
name: '@storybook/addon-svelte-csf',
|
||||||
options: {
|
options: {
|
||||||
legacyTemplate: true, // Enables the legacy template syntax
|
// Use modern template syntax for better performance
|
||||||
|
legacyTemplate: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'@chromatic-com/storybook',
|
'@chromatic-com/storybook',
|
||||||
|
|||||||
13
.storybook/preview-head.html
Normal file
13
.storybook/preview-head.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||||
|
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Preview } from '@storybook/svelte-vite';
|
import type { Preview } from '@storybook/svelte-vite';
|
||||||
|
import Decorator from './Decorator.svelte';
|
||||||
import StoryStage from './StoryStage.svelte';
|
import StoryStage from './StoryStage.svelte';
|
||||||
import '../src/app/styles/app.css';
|
import '../src/app/styles/app.css';
|
||||||
|
|
||||||
@@ -23,25 +24,41 @@ const preview: Preview = {
|
|||||||
story: {
|
story: {
|
||||||
// This sets the default height for the iframe in Autodocs
|
// This sets the default height for the iframe in Autodocs
|
||||||
iframeHeight: '400px',
|
iframeHeight: '400px',
|
||||||
// Ensure the story isn't forced into a tiny inline box
|
|
||||||
// inline: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
head: `
|
||||||
|
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||||
|
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`,
|
||||||
},
|
},
|
||||||
|
|
||||||
decorators: [
|
decorators: [
|
||||||
(storyFn, { parameters }) => {
|
// Wrap with providers (TooltipProvider, ResponsiveManager)
|
||||||
const { Component, props } = storyFn();
|
story => ({
|
||||||
return {
|
Component: Decorator,
|
||||||
Component: StoryStage,
|
props: {
|
||||||
// We pass the actual story component into the Stage via a snippet/slot
|
children: story(),
|
||||||
// Svelte 5 Storybook handles this mapping internally when you return this structure
|
},
|
||||||
props: {
|
}),
|
||||||
children: Component,
|
// Wrap with StoryStage for presentation styling
|
||||||
width: parameters.stageWidth || 'max-w-3xl',
|
story => ({
|
||||||
...props,
|
Component: StoryStage,
|
||||||
},
|
props: {
|
||||||
};
|
children: story(),
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
5
Caddyfile
Normal file
5
Caddyfile
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
:3000 {
|
||||||
|
root * /usr/share/caddy
|
||||||
|
file_server
|
||||||
|
try_files {path} /index.html
|
||||||
|
}
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
# Enable Corepack so we can use Yarn v4
|
||||||
|
RUN corepack enable && corepack prepare yarn@stable --activate
|
||||||
|
# Force Yarn to use node_modules instead of PnP
|
||||||
|
ENV YARN_NODE_LINKER=node-modules
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN yarn install --immutable
|
||||||
|
COPY . .
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
# Production stage - Caddy
|
||||||
|
FROM caddy:2-alpine
|
||||||
|
|
||||||
|
WORKDIR /usr/share/caddy
|
||||||
|
|
||||||
|
# Copy built static files from the builder stage
|
||||||
|
COPY --from=builder /app/dist .
|
||||||
|
|
||||||
|
# Copy our local Caddyfile config
|
||||||
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Start caddy using the config file
|
||||||
|
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||||
140
README.md
140
README.md
@@ -1,37 +1,38 @@
|
|||||||
# GlyphDiff
|
# GlyphDiff
|
||||||
|
|
||||||
A modern, high-performance font exploration tool for browsing and comparing fonts from Google Fonts and Fontshare.
|
A modern font exploration and comparison tool for browsing fonts from Google Fonts and Fontshare with real-time visual comparisons, advanced filtering, and customizable typography.
|
||||||
|
|
||||||
## ✨ Features
|
## Features
|
||||||
|
|
||||||
- **Multi-Provider Support**: Access fonts from Google Fonts and Fontshare in one place
|
- **Multi-Provider Catalog**: Browse fonts from Google Fonts and Fontshare in one place
|
||||||
- **Fast Virtual Scrolling**: Handles thousands of fonts smoothly with custom virtualization
|
- **Side-by-Side Comparison**: Compare up to 4 fonts simultaneously with customizable text, size, and typography settings
|
||||||
- **Advanced Filtering**: Filter by category, provider, and character subsets
|
- **Advanced Filtering**: Filter by category, provider, character subsets, and weight
|
||||||
- **Responsive Design**: Beautiful UI built with shadcn components and Tailwind CSS
|
- **Virtual Scrolling**: Fast, smooth browsing of thousands of fonts
|
||||||
|
- **Responsive UI**: Beautiful interface built with shadcn components and Tailwind CSS
|
||||||
- **Type-Safe**: Full TypeScript coverage with strict mode enabled
|
- **Type-Safe**: Full TypeScript coverage with strict mode enabled
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Frontend**: Svelte 5 with runes (reactive primitives)
|
- **Framework**: Svelte 5 with reactive primitives (runes)
|
||||||
- **Styling**: Tailwind CSS v4 + shadcn-svelte components
|
- **Styling**: Tailwind CSS v4
|
||||||
- **Data Fetching**: TanStack Query for caching and state management
|
- **Components**: shadcn-svelte (via bits-ui)
|
||||||
- **Architecture**: Feature-Sliced Design (FSD) methodology
|
- **State Management**: TanStack Query for async data
|
||||||
- **Testing**: Playwright (E2E), Vitest (unit), Storybook (components)
|
- **Architecture**: Feature-Sliced Design (FSD)
|
||||||
- **Quality**: Oxlint (linting), Dprint (formatting), Lefthook (git hooks)
|
- **Quality**: oxlint (linting), dprint (formatting), lefthook (git hooks)
|
||||||
|
|
||||||
## 📁 Architecture
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── app/ # App shell, layout, providers
|
├── app/ # App shell, layout, providers
|
||||||
├── widgets/ # Composed UI blocks
|
├── widgets/ # Composed UI blocks (ComparisonSlider, SampleList, FontSearch)
|
||||||
├── features/ # Business features (filters, search)
|
├── features/ # Business features (filters, search, display)
|
||||||
├── entities/ # Domain entities (Font models, stores)
|
├── entities/ # Domain models and stores (Font, Breadcrumb)
|
||||||
├── shared/ # Reusable utilities, UI components, helpers
|
├── shared/ # Reusable utilities, UI components, helpers
|
||||||
└── routes/ # Page-level components
|
└── routes/ # Page-level components
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
@@ -40,81 +41,38 @@ yarn install
|
|||||||
# Start development server
|
# Start development server
|
||||||
yarn dev
|
yarn dev
|
||||||
|
|
||||||
# Open in browser
|
# Build for production
|
||||||
yarn dev -- --open
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📦 Available Scripts
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
| ---------------- | -------------------------- |
|
|
||||||
| `yarn dev` | Start development server |
|
|
||||||
| `yarn build` | Build for production |
|
|
||||||
| `yarn preview` | Preview production build |
|
|
||||||
| `yarn check` | Run Svelte type checking |
|
|
||||||
| `yarn lint` | Run oxlint |
|
|
||||||
| `yarn format` | Format with dprint |
|
|
||||||
| `yarn test` | Run all tests (E2E + unit) |
|
|
||||||
| `yarn test:e2e` | Run Playwright E2E tests |
|
|
||||||
| `yarn test:unit` | Run Vitest unit tests |
|
|
||||||
| `yarn storybook` | Start Storybook dev server |
|
|
||||||
|
|
||||||
## 🧪 Development
|
|
||||||
|
|
||||||
### Type Checking
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn check # Single run
|
|
||||||
yarn check:watch # Watch mode
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn test:unit # Unit tests
|
|
||||||
yarn test:unit:watch # Watch mode
|
|
||||||
yarn test:unit:ui # Vitest UI
|
|
||||||
yarn test:e2e # E2E tests with Playwright
|
|
||||||
yarn test:e2e --ui # Interactive test runner
|
|
||||||
```
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn lint # Lint code
|
|
||||||
yarn format # Format code
|
|
||||||
yarn format:check # Check formatting
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Key Components
|
|
||||||
|
|
||||||
- **VirtualList**: Custom high-performance list virtualization using Svelte 5 runes
|
|
||||||
- **FontList**: Displays fonts with loading, empty, and error states
|
|
||||||
- **FilterControls**: Multi-filter system for category, provider, and subsets
|
|
||||||
- **TypographyControl**: Dynamic typography adjustment controls
|
|
||||||
|
|
||||||
## 📝 Code Style
|
|
||||||
|
|
||||||
- **Path Aliases**: Use `$app/`, `$shared/`, `$features/`, `$entities/`, `$widgets/`, `$routes/`
|
|
||||||
- **Components**: PascalCase (e.g., `CheckboxFilter.svelte`)
|
|
||||||
- **Formatting**: 100 char line width, 4-space indent, single quotes
|
|
||||||
- **Imports**: Auto-sorted by dprint, separated by blank line
|
|
||||||
- **Type Safety**: Strict TypeScript, JSDoc comments for public APIs
|
|
||||||
|
|
||||||
## 🏗️ Building for Production
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn build
|
yarn build
|
||||||
|
|
||||||
|
# Preview production build
|
||||||
yarn preview
|
yarn preview
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📚 Learn More
|
## Available Scripts
|
||||||
|
|
||||||
- [Svelte 5 Documentation](https://svelte-5-preview.vercel.app/docs)
|
| Command | Description |
|
||||||
- [Feature-Sliced Design](https://feature-sliced.design)
|
| ------------------- | -------------------------- |
|
||||||
- [Tailwind CSS v4](https://tailwindcss.com/blog/tailwindcss-v4-alpha)
|
| `yarn dev` | Start development server |
|
||||||
- [shadcn-svelte](https://www.shadcn-svelte.com)
|
| `yarn build` | Build for production |
|
||||||
|
| `yarn preview` | Preview production build |
|
||||||
|
| `yarn check` | Run Svelte type checking |
|
||||||
|
| `yarn lint` | Run oxlint |
|
||||||
|
| `yarn format` | Format code with dprint |
|
||||||
|
| `yarn test:unit` | Run unit tests |
|
||||||
|
| `yarn test:unit:ui` | Run Vitest UI |
|
||||||
|
| `yarn storybook` | Start Storybook dev server |
|
||||||
|
|
||||||
## 📄 License
|
## Code Style
|
||||||
|
|
||||||
|
- **Path Aliases**: Use `$app/`, `$shared/`, `$features/`, `$entities/`, `$widgets/`, `$routes/`
|
||||||
|
- **Components**: PascalCase (e.g., `ComparisonSlider.svelte`)
|
||||||
|
- **Formatting**: 100 char line width, 4-space indent, single quotes
|
||||||
|
- **Type Safety**: Strict TypeScript with JSDoc comments for public APIs
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
This project follows the Feature-Sliced Design (FSD) methodology for clean separation of concerns. The application uses Svelte 5's new runes system (`$state`, `$derived`, `$effect`) for reactive state management.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"vaul-svelte": "^1.0.0-next.7",
|
||||||
"vite": "^7.2.6",
|
"vite": "^7.2.6",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "^4.0.16",
|
||||||
"vitest-browser-svelte": "^2.0.1"
|
"vitest-browser-svelte": "^2.0.1"
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
|
<!--
|
||||||
|
Component: QueryProvider
|
||||||
|
Provides a QueryClientProvider for child components.
|
||||||
|
|
||||||
|
All components that use useQueryClient() or createQuery() must be
|
||||||
|
descendants of this provider.
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
|
||||||
* Query Provider Component
|
|
||||||
*
|
|
||||||
* All components that use useQueryClient() or createQuery() must be
|
|
||||||
* descendants of this provider.
|
|
||||||
*/
|
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
/** Slot content for child components */
|
interface Props {
|
||||||
let { children } = $props();
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -37,6 +37,28 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||||
|
|
||||||
|
--background-20: oklch(1 0 0 / 20%);
|
||||||
|
--background-40: oklch(1 0 0 / 40%);
|
||||||
|
--background-60: oklch(1 0 0 / 60%);
|
||||||
|
--background-80: oklch(1 0 0 / 80%);
|
||||||
|
--background-95: oklch(1 0 0 / 95%);
|
||||||
|
--background-subtle: oklch(0.98 0 0);
|
||||||
|
--background-muted: oklch(0.97 0.002 286.375);
|
||||||
|
|
||||||
|
--text-muted: oklch(0.552 0.016 285.938);
|
||||||
|
--text-subtle: oklch(0.705 0.015 286.067);
|
||||||
|
--text-soft: oklch(0.5 0.01 286);
|
||||||
|
|
||||||
|
--border-subtle: oklch(0.95 0.003 286.32);
|
||||||
|
--border-muted: oklch(0.92 0.004 286.32);
|
||||||
|
--border-soft: oklch(0.88 0.005 286.32);
|
||||||
|
|
||||||
|
--gradient-from: oklch(0.98 0.002 286.32);
|
||||||
|
--gradient-via: oklch(1 0 0);
|
||||||
|
--gradient-to: oklch(0.98 0.002 286.32);
|
||||||
|
|
||||||
|
--font-mono: 'Major Mono Display';
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -71,6 +93,26 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||||
|
|
||||||
|
--background-20: oklch(0.21 0.006 285.885 / 20%);
|
||||||
|
--background-40: oklch(0.21 0.006 285.885 / 40%);
|
||||||
|
--background-60: oklch(0.21 0.006 285.885 / 60%);
|
||||||
|
--background-80: oklch(0.21 0.006 285.885 / 80%);
|
||||||
|
--background-95: oklch(0.21 0.006 285.885 / 95%);
|
||||||
|
--background-subtle: oklch(0.18 0.005 285.823);
|
||||||
|
--background-muted: oklch(0.274 0.006 286.033);
|
||||||
|
|
||||||
|
--text-muted: oklch(0.705 0.015 286.067);
|
||||||
|
--text-subtle: oklch(0.552 0.016 285.938);
|
||||||
|
--text-soft: oklch(0.8 0.01 286);
|
||||||
|
|
||||||
|
--border-subtle: oklch(1 0 0 / 8%);
|
||||||
|
--border-muted: oklch(1 0 0 / 10%);
|
||||||
|
--border-soft: oklch(1 0 0 / 15%);
|
||||||
|
|
||||||
|
--gradient-from: oklch(0.25 0.005 285.885);
|
||||||
|
--gradient-via: oklch(0.21 0.006 285.885);
|
||||||
|
--gradient-to: oklch(0.25 0.005 285.885);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -109,6 +151,24 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-background-20: var(--background-20);
|
||||||
|
--color-background-40: var(--background-40);
|
||||||
|
--color-background-60: var(--background-60);
|
||||||
|
--color-background-80: var(--background-80);
|
||||||
|
--color-background-95: var(--background-95);
|
||||||
|
--color-background-subtle: var(--background-subtle);
|
||||||
|
--color-background-muted: var(--background-muted);
|
||||||
|
--color-text-muted: var(--text-muted);
|
||||||
|
--color-text-subtle: var(--text-subtle);
|
||||||
|
--color-text-soft: var(--text-soft);
|
||||||
|
--color-border-subtle: var(--border-subtle);
|
||||||
|
--color-border-muted: var(--border-muted);
|
||||||
|
--color-border-soft: var(--border-soft);
|
||||||
|
--color-gradient-from: var(--gradient-from);
|
||||||
|
--color-gradient-via: var(--gradient-via);
|
||||||
|
--color-gradient-to: var(--gradient-to);
|
||||||
|
--font-mono: 'Major Mono Display', monospace;
|
||||||
|
--font-sans: 'Karla', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -117,7 +177,7 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: 'Karla', system-ui, sans-serif;
|
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||||
font-optical-sizing: auto;
|
font-optical-sizing: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,3 +222,86 @@
|
|||||||
.animate-nudge {
|
.animate-nudge {
|
||||||
animation: nudge 10s ease-in-out infinite;
|
animation: nudge 10s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.barlow {
|
||||||
|
font-family: "Barlow", system-ui, Inter, Roboto, "Segoe UI", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(0 0% 70% / 0.4) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark * {
|
||||||
|
scrollbar-color: hsl(0 0% 40% / 0.5) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Webkit / Blink ---- */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(0 0% 70% / 0);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show thumb when container is hovered or actively scrolling */
|
||||||
|
:hover > ::-webkit-scrollbar-thumb,
|
||||||
|
::-webkit-scrollbar-thumb:hover,
|
||||||
|
*:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(0 0% 70% / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(0 0% 50% / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:active {
|
||||||
|
background: hsl(0 0% 40% / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(0 0% 40% / 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark :hover > ::-webkit-scrollbar-thumb,
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover,
|
||||||
|
.dark *:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(0 0% 40% / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(0 0% 55% / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb:active {
|
||||||
|
background: hsl(0 0% 65% / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Behavior ---- */
|
||||||
|
* {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
}
|
||||||
|
|||||||
13
src/app/types/ambient.d.ts
vendored
13
src/app/types/ambient.d.ts
vendored
@@ -35,3 +35,16 @@ declare module '*.jpg' {
|
|||||||
const content: string;
|
const content: string;
|
||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly DEV: boolean;
|
||||||
|
readonly PROD: boolean;
|
||||||
|
readonly MODE: string;
|
||||||
|
// Add other env variables you use
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,43 +10,98 @@
|
|||||||
*
|
*
|
||||||
* - Footer area (currently empty, reserved for future use)
|
* - Footer area (currently empty, reserved for future use)
|
||||||
*/
|
*/
|
||||||
import favicon from '$shared/assets/favicon.svg';
|
import { BreadcrumbHeader } from '$entities/Breadcrumb';
|
||||||
|
import GD from '$shared/assets/GD.svg';
|
||||||
|
import { ResponsiveProvider } from '$shared/lib';
|
||||||
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
||||||
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||||
import { TypographyMenu } from '$widgets/TypographySettings';
|
import {
|
||||||
import type { Snippet } from 'svelte';
|
type Snippet,
|
||||||
|
onMount,
|
||||||
|
} from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Slot content for route pages to render */
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
|
let fontsReady = $state(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets fontsReady flag to true when font for the page logo is loaded.
|
||||||
|
*/
|
||||||
|
onMount(async () => {
|
||||||
|
if (!('fonts' in document)) {
|
||||||
|
fontsReady = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const required = ['100'];
|
||||||
|
|
||||||
|
const missing = required.filter(
|
||||||
|
w => !document.fonts.check(`${w} 1em Barlow`),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
missing.map(w => document.fonts.load(`${w} 1em Barlow`)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fontsReady = true;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={GD} />
|
||||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
|
||||||
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
|
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous">
|
<link
|
||||||
|
rel="preconnect"
|
||||||
|
href="https://cdn.fontshare.com"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link
|
||||||
|
rel="preconnect"
|
||||||
|
href="https://fonts.gstatic.com"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
as="style"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||||
|
/>
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Karla:ital,wght@0,200..800;1,200..800&display=swap"
|
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
>
|
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||||
|
media="print"
|
||||||
|
onload={(e => ((e.currentTarget as HTMLLinkElement).media = 'all'))}
|
||||||
|
/>
|
||||||
|
<noscript>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||||
|
/>
|
||||||
|
</noscript>
|
||||||
|
<title>Compare Typography & Typefaces | GlyphDiff</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div id="app-root" class="min-h-screen flex flex-col bg-background">
|
<ResponsiveProvider>
|
||||||
<header></header>
|
<div id="app-root" class="min-h-screen flex flex-col bg-background">
|
||||||
|
<header>
|
||||||
|
<BreadcrumbHeader />
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- <ScrollArea class="h-screen w-screen"> -->
|
<!-- <ScrollArea class="h-screen w-screen"> -->
|
||||||
<main class="flex-1 h-full w-full max-w-6xl mx-auto px-4 pt-6 pb-10 md:px-8 lg:pt-10 lg:pb-20 relative">
|
<main class="flex-1 w-full mx-auto px-4 pt-0 pb-10 sm:px-6 sm:pt-8 sm:pb-12 md:px-8 md:pt-10 md:pb-16 lg:px-10 lg:pt-12 lg:pb-20 xl:px-16 relative">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<TypographyMenu />
|
{#if fontsReady}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</TooltipProvider>
|
{/if}
|
||||||
</main>
|
</TooltipProvider>
|
||||||
<!-- </ScrollArea> -->
|
</main>
|
||||||
<footer></footer>
|
<!-- </ScrollArea> -->
|
||||||
</div>
|
<footer></footer>
|
||||||
|
</div>
|
||||||
|
</ResponsiveProvider>
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
export interface BreadcrumbItem {
|
export interface BreadcrumbItem {
|
||||||
|
/**
|
||||||
|
* Index of the item to display
|
||||||
|
*/
|
||||||
index: number;
|
index: number;
|
||||||
|
/**
|
||||||
|
* ID of the item to navigate to
|
||||||
|
*/
|
||||||
|
id?: string;
|
||||||
|
/**
|
||||||
|
* Title snippet to render
|
||||||
|
*/
|
||||||
title: Snippet<[{ className?: string }]>;
|
title: Snippet<[{ className?: string }]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
Fixed header for breadcrumbs navigation for sections in the page
|
Fixed header for breadcrumbs navigation for sections in the page
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@lucide/svelte/icons/align-vertical-justify-center';
|
import { smoothScroll } from '$shared/lib';
|
||||||
import { flip } from 'svelte/animate';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import { slide } from 'svelte/transition';
|
import {
|
||||||
|
fly,
|
||||||
|
slide,
|
||||||
|
} from 'svelte/transition';
|
||||||
import { scrollBreadcrumbsStore } from '../../model';
|
import { scrollBreadcrumbsStore } from '../../model';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -14,51 +17,48 @@ import { scrollBreadcrumbsStore } from '../../model';
|
|||||||
transition:slide={{ duration: 200 }}
|
transition:slide={{ duration: 200 }}
|
||||||
class="
|
class="
|
||||||
fixed top-0 left-0 right-0 z-100
|
fixed top-0 left-0 right-0 z-100
|
||||||
backdrop-blur-lg bg-white/20
|
backdrop-blur-lg bg-background-20
|
||||||
border-b border-gray-300/50
|
border-b border-border-muted
|
||||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||||
h-12
|
h-10 sm:h-12
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="max-w-8xl mx-auto px-6 h-full flex items-center gap-4">
|
<div class="max-w-8xl mx-auto px-4 sm:px-6 h-full flex items-center gap-2 sm:gap-4">
|
||||||
<div class="flex items-center gap-2.5 opacity-70">
|
<h1 class={cn('barlow font-extralight text-sm sm:text-base')}>
|
||||||
<Icon class="size-4 stroke-gray-900 stroke-1" />
|
GLYPHDIFF
|
||||||
<div class="w-px h-2.5 bg-gray-400/50"></div>
|
</h1>
|
||||||
<span class="font-mono text-[9px] uppercase tracking-[0.25em] text-gray-500 font-medium">
|
|
||||||
nav_trace
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="h-4 w-px bg-gray-300/60"></div>
|
<div class="h-3.5 sm:h-4 w-px bg-border-subtle hidden sm:block"></div>
|
||||||
|
|
||||||
<nav class="flex items-center gap-3 overflow-x-auto scrollbar-hide flex-1">
|
<nav class="flex items-center gap-2 sm:gap-3 overflow-x-auto scrollbar-hide flex-1">
|
||||||
{#each scrollBreadcrumbsStore.items as item, idx (item.index)}
|
{#each scrollBreadcrumbsStore.items as item, idx (item.index)}
|
||||||
<div
|
<div
|
||||||
animate:flip={{ duration: 200 }}
|
in:fly={{ duration: 300, y: -10, x: 100, opacity: 0 }}
|
||||||
class="flex items-center gap-3 whitespace-nowrap shrink-0"
|
out:fly={{ duration: 300, y: 10, x: 100, opacity: 0 }}
|
||||||
|
class="flex items-center gap-2 sm:gap-3 whitespace-nowrap shrink-0"
|
||||||
>
|
>
|
||||||
<span class="font-mono text-[9px] text-gray-400 tracking-wider">
|
<span class="font-mono text-[8px] sm:text-[9px] text-text-muted tracking-wider">
|
||||||
{String(item.index).padStart(2, '0')}
|
{String(item.index).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
|
<a href={`#${item.id}`} use:smoothScroll>
|
||||||
{@render item.title({
|
{@render item.title({
|
||||||
className: 'font-mono text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-gray-900',
|
className: 'text-[9px] sm:text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-foreground',
|
||||||
})}
|
})}</a>
|
||||||
|
|
||||||
{#if idx < scrollBreadcrumbsStore.items.length - 1}
|
{#if idx < scrollBreadcrumbsStore.items.length - 1}
|
||||||
<div class="flex items-center gap-0.5 opacity-40">
|
<div class="flex items-center gap-0.5 opacity-40">
|
||||||
<div class="w-1 h-px bg-gray-400"></div>
|
<div class="w-1 h-px bg-text-muted"></div>
|
||||||
<div class="w-1 h-px bg-gray-400"></div>
|
<div class="w-1 h-px bg-text-muted"></div>
|
||||||
<div class="w-1 h-px bg-gray-400"></div>
|
<div class="w-1 h-px bg-text-muted"></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 opacity-50 ml-auto">
|
<div class="flex items-center gap-1.5 sm:gap-2 opacity-50 ml-auto">
|
||||||
<div class="w-px h-2.5 bg-gray-300/60"></div>
|
<div class="w-px h-2 sm:h-2.5 bg-border-subtle hidden sm:block"></div>
|
||||||
<span class="font-mono text-[8px] text-gray-400 tracking-wider">
|
<span class="font-mono text-[7px] sm:text-[8px] text-text-muted tracking-wider">
|
||||||
[{scrollBreadcrumbsStore.items.length}]
|
[{scrollBreadcrumbsStore.items.length}]
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,10 +75,59 @@ export type {
|
|||||||
export {
|
export {
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
createUnifiedFontStore,
|
createUnifiedFontStore,
|
||||||
selectedFontsStore,
|
|
||||||
unifiedFontStore,
|
unifiedFontStore,
|
||||||
} from './model';
|
} from './model';
|
||||||
|
|
||||||
|
// Mock data helpers for Storybook and testing
|
||||||
|
export {
|
||||||
|
createCategoriesFilter,
|
||||||
|
createErrorState,
|
||||||
|
createGenericFilter,
|
||||||
|
createLoadingState,
|
||||||
|
createMockComparisonStore,
|
||||||
|
// Filter mocks
|
||||||
|
createMockFilter,
|
||||||
|
createMockFontApiResponse,
|
||||||
|
createMockFontStoreState,
|
||||||
|
// Store mocks
|
||||||
|
createMockQueryState,
|
||||||
|
createMockReactiveState,
|
||||||
|
createMockStore,
|
||||||
|
createProvidersFilter,
|
||||||
|
createSubsetsFilter,
|
||||||
|
createSuccessState,
|
||||||
|
FONTHARE_FONTS,
|
||||||
|
generateMixedCategoryFonts,
|
||||||
|
generateMockFonts,
|
||||||
|
generatePaginatedFonts,
|
||||||
|
generateSequentialFilter,
|
||||||
|
GENERIC_FILTERS,
|
||||||
|
getAllMockFonts,
|
||||||
|
getFontsByCategory,
|
||||||
|
getFontsByProvider,
|
||||||
|
GOOGLE_FONTS,
|
||||||
|
MOCK_FILTERS,
|
||||||
|
MOCK_FILTERS_ALL_SELECTED,
|
||||||
|
MOCK_FILTERS_EMPTY,
|
||||||
|
MOCK_FILTERS_SELECTED,
|
||||||
|
MOCK_FONT_STORE_STATES,
|
||||||
|
MOCK_STORES,
|
||||||
|
type MockFilterOptions,
|
||||||
|
type MockFilters,
|
||||||
|
mockFontshareFont,
|
||||||
|
type MockFontshareFontOptions,
|
||||||
|
type MockFontStoreState,
|
||||||
|
// Font mocks
|
||||||
|
mockGoogleFont,
|
||||||
|
// Types
|
||||||
|
type MockGoogleFontOptions,
|
||||||
|
type MockQueryObserverResult,
|
||||||
|
type MockQueryState,
|
||||||
|
mockUnifiedFont,
|
||||||
|
type MockUnifiedFontOptions,
|
||||||
|
UNIFIED_FONTS,
|
||||||
|
} from './lib/mocks';
|
||||||
|
|
||||||
// UI elements
|
// UI elements
|
||||||
export {
|
export {
|
||||||
FontApplicator,
|
FontApplicator,
|
||||||
|
|||||||
592
src/entities/Font/lib/getFontUrl/getFontUrl.test.ts
Normal file
592
src/entities/Font/lib/getFontUrl/getFontUrl.test.ts
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import type { UnifiedFont } from '../../model/types';
|
||||||
|
import { getFontUrl } from './getFontUrl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a minimal UnifiedFont mock for testing
|
||||||
|
*/
|
||||||
|
function createMockFont(
|
||||||
|
overrides: Partial<UnifiedFont> = {},
|
||||||
|
): UnifiedFont {
|
||||||
|
const baseFont: UnifiedFont = {
|
||||||
|
id: 'test-font',
|
||||||
|
name: 'Test Font',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin'],
|
||||||
|
variants: [],
|
||||||
|
styles: {},
|
||||||
|
metadata: {
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
isVariable: false,
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...baseFont, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getFontUrl', () => {
|
||||||
|
describe('basic logic', () => {
|
||||||
|
it('returns URL for exact weight match in variants', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
'700': 'https://example.com/font-700.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 400);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-400.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns URL for weight 700', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'700': 'https://example.com/font-700.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-700.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns URL for weight 100 (lightest)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'100': 'https://example.com/font-100.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 100);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-100.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns URL for weight 900 (boldest)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'900': 'https://example.com/font-900.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 900);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-900.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns URL for variable font (backend maps weight to VF URL)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-variable.woff2',
|
||||||
|
'700': 'https://example.com/font-variable.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result400 = getFontUrl(font, 400);
|
||||||
|
const result700 = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result400).toBe('https://example.com/font-variable.woff2');
|
||||||
|
expect(result700).toBe('https://example.com/font-variable.woff2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fallback logic', () => {
|
||||||
|
it('falls back to regular when exact weight not found', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
regular: 'https://example.com/font-regular.woff2',
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to variant 400 when exact weight and regular not found', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-400.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to variant regular when exact weight, regular, and 400 not found', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'700': 'https://example.com/font-700.woff2',
|
||||||
|
'regular': 'https://example.com/font-regular.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 400);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers regular over variants.400 for fallback', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
regular: 'https://example.com/font-regular.woff2',
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when no fallback options available', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'700': 'https://example.com/font-700.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 400);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for font with empty styles', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 400);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for font with undefined styles (invalid font data)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: undefined as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, 400)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('handles font with only regular URL (legacy format)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
regular: 'https://example.com/font-regular.woff2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles font with only variants object', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
'700': 'https://example.com/font-700.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result400 = getFontUrl(font, 400);
|
||||||
|
const result700 = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result400).toBe('https://example.com/font-400.woff2');
|
||||||
|
expect(result700).toBe('https://example.com/font-700.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles font with variants but no requested weight', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-400.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Google Fonts style with legacy URLs', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||||
|
bold: 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result).toBe('https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Fontshare fonts with multiple weights', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'100': 'https://cdn.fontshare.com/wf/font-100.woff2',
|
||||||
|
'200': 'https://cdn.fontshare.com/wf/font-200.woff2',
|
||||||
|
'300': 'https://cdn.fontshare.com/wf/font-300.woff2',
|
||||||
|
'400': 'https://cdn.fontshare.com/wf/font-400.woff2',
|
||||||
|
'500': 'https://cdn.fontshare.com/wf/font-500.woff2',
|
||||||
|
'600': 'https://cdn.fontshare.com/wf/font-600.woff2',
|
||||||
|
'700': 'https://cdn.fontshare.com/wf/font-700.woff2',
|
||||||
|
'800': 'https://cdn.fontshare.com/wf/font-800.woff2',
|
||||||
|
'900': 'https://cdn.fontshare.com/wf/font-900.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test all valid weights
|
||||||
|
for (const weight of [100, 200, 300, 400, 500, 600, 700, 800, 900]) {
|
||||||
|
const result = getFontUrl(font, weight);
|
||||||
|
expect(result).toBe(`https://cdn.fontshare.com/wf/font-${weight}.woff2`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles font with partial weight coverage', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-regular.woff2',
|
||||||
|
'700': 'https://example.com/font-bold.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result400 = getFontUrl(font, 400);
|
||||||
|
const result700 = getFontUrl(font, 700);
|
||||||
|
const result500 = getFontUrl(font, 500);
|
||||||
|
|
||||||
|
expect(result400).toBe('https://example.com/font-regular.woff2');
|
||||||
|
expect(result700).toBe('https://example.com/font-bold.woff2');
|
||||||
|
expect(result500).toBe('https://example.com/font-regular.woff2'); // Fallback
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles font with variants.regular as fallback', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'700': 'https://example.com/font-bold.woff2',
|
||||||
|
'regular': 'https://example.com/font-regular.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 400);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-regular.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty variants object', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 400);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when variant URL is null and no fallback available', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': null as any,
|
||||||
|
'700': 'https://example.com/font-bold.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 400);
|
||||||
|
|
||||||
|
// null is falsy, so it falls back to regular, 400, and then regular variant
|
||||||
|
// All are undefined, so returns undefined
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('boundary tests', () => {
|
||||||
|
it('handles lowest valid weight (100)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'100': 'https://example.com/font-100.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 100);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-100.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles highest valid weight (900)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'900': 'https://example.com/font-900.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 900);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-900.woff2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles middle weight (500)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'500': 'https://example.com/font-500.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, 500);
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/font-500.woff2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalid weights', () => {
|
||||||
|
it('throws error for weight below 100', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, 99)).toThrow('Invalid weight: 99');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for weight above 900', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, 901)).toThrow('Invalid weight: 901');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for weight 0', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, 0)).toThrow('Invalid weight: 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for negative weight', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, -100)).toThrow('Invalid weight: -100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for non-numeric weight', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-ignore - Testing invalid input type
|
||||||
|
expect(() => getFontUrl(font, '400' as any)).toThrow('Invalid weight: 400');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for decimal weight', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, 450.5)).toThrow('Invalid weight: 450.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for weight with step of 50 (not supported)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, 450)).toThrow('Invalid weight: 450');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for weight with step of 10 (not supported)', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, 410)).toThrow('Invalid weight: 410');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for NaN weight', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, NaN)).toThrow('Invalid weight: NaN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for Infinity weight', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => getFontUrl(font, Infinity)).toThrow('Invalid weight: Infinity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws descriptive error message', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://example.com/font-400.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
getFontUrl(font, 999);
|
||||||
|
expect.fail('Expected function to throw');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
expect((error as Error).message).toBe('Invalid weight: 999');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('provider-specific tests', () => {
|
||||||
|
it('handles Google Fonts with variable fonts', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
provider: 'google',
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||||
|
'700': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result400 = getFontUrl(font, 400);
|
||||||
|
const result700 = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
// Variable fonts return the same URL for all weights
|
||||||
|
expect(result400).toBe(result700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Fontshare fonts with static weights', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
provider: 'fontshare',
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
'400': 'https://cdn.fontshare.com/wf/satoshi-regular.woff2',
|
||||||
|
'700': 'https://cdn.fontshare.com/wf/satoshi-bold.woff2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result400 = getFontUrl(font, 400);
|
||||||
|
const result700 = getFontUrl(font, 700);
|
||||||
|
|
||||||
|
expect(result400).toBe('https://cdn.fontshare.com/wf/satoshi-regular.woff2');
|
||||||
|
expect(result700).toBe('https://cdn.fontshare.com/wf/satoshi-bold.woff2');
|
||||||
|
expect(result400).not.toBe(result700);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('all valid weights test', () => {
|
||||||
|
it('handles all valid weight values', () => {
|
||||||
|
const validWeights = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||||
|
|
||||||
|
validWeights.forEach(weight => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: {
|
||||||
|
variants: {
|
||||||
|
[weight.toString()]: `https://example.com/font-${weight}.woff2`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = getFontUrl(font, weight);
|
||||||
|
expect(result).toBe(`https://example.com/font-${weight}.woff2`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
29
src/entities/Font/lib/getFontUrl/getFontUrl.ts
Normal file
29
src/entities/Font/lib/getFontUrl/getFontUrl.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type {
|
||||||
|
FontWeight,
|
||||||
|
UnifiedFont,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
|
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a URL for a font based on the provided font and weight.
|
||||||
|
* @param font - The font object.
|
||||||
|
* @param weight - The weight of the font.
|
||||||
|
* @returns The URL for the font.
|
||||||
|
*/
|
||||||
|
export function getFontUrl(font: UnifiedFont, weight: number): string | undefined {
|
||||||
|
if (!SIZES.includes(weight)) {
|
||||||
|
throw new Error(`Invalid weight: ${weight}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weightKey = weight.toString() as FontWeight;
|
||||||
|
|
||||||
|
// 1. Try exact match (Backend now maps "100".."900" to VF URL if variable)
|
||||||
|
if (font.styles.variants?.[weightKey]) {
|
||||||
|
return font.styles.variants[weightKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallbacks for Static Fonts (if exact weight missing)
|
||||||
|
// Try 'regular' or '400' as safe defaults
|
||||||
|
return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular'];
|
||||||
|
}
|
||||||
@@ -4,3 +4,55 @@ export {
|
|||||||
normalizeGoogleFont,
|
normalizeGoogleFont,
|
||||||
normalizeGoogleFonts,
|
normalizeGoogleFonts,
|
||||||
} from './normalize/normalize';
|
} from './normalize/normalize';
|
||||||
|
|
||||||
|
export { getFontUrl } from './getFontUrl/getFontUrl';
|
||||||
|
|
||||||
|
// Mock data helpers for Storybook and testing
|
||||||
|
export {
|
||||||
|
createCategoriesFilter,
|
||||||
|
createErrorState,
|
||||||
|
createGenericFilter,
|
||||||
|
createLoadingState,
|
||||||
|
createMockComparisonStore,
|
||||||
|
// Filter mocks
|
||||||
|
createMockFilter,
|
||||||
|
createMockFontApiResponse,
|
||||||
|
createMockFontStoreState,
|
||||||
|
// Store mocks
|
||||||
|
createMockQueryState,
|
||||||
|
createMockReactiveState,
|
||||||
|
createMockStore,
|
||||||
|
createProvidersFilter,
|
||||||
|
createSubsetsFilter,
|
||||||
|
createSuccessState,
|
||||||
|
FONTHARE_FONTS,
|
||||||
|
generateMixedCategoryFonts,
|
||||||
|
generateMockFonts,
|
||||||
|
generatePaginatedFonts,
|
||||||
|
generateSequentialFilter,
|
||||||
|
GENERIC_FILTERS,
|
||||||
|
getAllMockFonts,
|
||||||
|
getFontsByCategory,
|
||||||
|
getFontsByProvider,
|
||||||
|
GOOGLE_FONTS,
|
||||||
|
MOCK_FILTERS,
|
||||||
|
MOCK_FILTERS_ALL_SELECTED,
|
||||||
|
MOCK_FILTERS_EMPTY,
|
||||||
|
MOCK_FILTERS_SELECTED,
|
||||||
|
MOCK_FONT_STORE_STATES,
|
||||||
|
MOCK_STORES,
|
||||||
|
type MockFilterOptions,
|
||||||
|
type MockFilters,
|
||||||
|
mockFontshareFont,
|
||||||
|
type MockFontshareFontOptions,
|
||||||
|
type MockFontStoreState,
|
||||||
|
// Font mocks
|
||||||
|
mockGoogleFont,
|
||||||
|
// Types
|
||||||
|
type MockGoogleFontOptions,
|
||||||
|
type MockQueryObserverResult,
|
||||||
|
type MockQueryState,
|
||||||
|
mockUnifiedFont,
|
||||||
|
type MockUnifiedFontOptions,
|
||||||
|
UNIFIED_FONTS,
|
||||||
|
} from './mocks';
|
||||||
|
|||||||
348
src/entities/Font/lib/mocks/filters.mock.ts
Normal file
348
src/entities/Font/lib/mocks/filters.mock.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* MOCK FONT FILTER DATA
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Factory functions and preset mock data for font-related filters.
|
||||||
|
* Used in Storybook stories for font filtering components.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import {
|
||||||
|
* createMockFilter,
|
||||||
|
* MOCK_FILTERS,
|
||||||
|
* } from '$entities/Font/lib/mocks';
|
||||||
|
*
|
||||||
|
* // Create a custom filter
|
||||||
|
* const customFilter = createMockFilter({
|
||||||
|
* properties: [
|
||||||
|
* { id: 'option1', name: 'Option 1', value: 'option1' },
|
||||||
|
* { id: 'option2', name: 'Option 2', value: 'option2', selected: true },
|
||||||
|
* ],
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Use preset filters
|
||||||
|
* const categoriesFilter = MOCK_FILTERS.categories;
|
||||||
|
* const subsetsFilter = MOCK_FILTERS.subsets;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FontCategory,
|
||||||
|
FontProvider,
|
||||||
|
FontSubset,
|
||||||
|
} from '$entities/Font/model/types';
|
||||||
|
import type { Property } from '$shared/lib';
|
||||||
|
import { createFilter } from '$shared/lib';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPE DEFINITIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a mock filter
|
||||||
|
*/
|
||||||
|
export interface MockFilterOptions {
|
||||||
|
/** Filter properties */
|
||||||
|
properties: Property<string>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset mock filters for font filtering
|
||||||
|
*/
|
||||||
|
export interface MockFilters {
|
||||||
|
/** Provider filter (Google, Fontshare) */
|
||||||
|
providers: ReturnType<typeof createFilter<'google' | 'fontshare'>>;
|
||||||
|
/** Category filter (sans-serif, serif, display, etc.) */
|
||||||
|
categories: ReturnType<typeof createFilter<FontCategory>>;
|
||||||
|
/** Subset filter (latin, latin-ext, cyrillic, etc.) */
|
||||||
|
subsets: ReturnType<typeof createFilter<FontSubset>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FONT CATEGORIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Fonts categories
|
||||||
|
*/
|
||||||
|
export const GOOGLE_CATEGORIES: Property<'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'>[] = [
|
||||||
|
{ id: 'sans-serif', name: 'Sans Serif', value: 'sans-serif' },
|
||||||
|
{ id: 'serif', name: 'Serif', value: 'serif' },
|
||||||
|
{ id: 'display', name: 'Display', value: 'display' },
|
||||||
|
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
|
||||||
|
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fontshare categories (mapped to common naming)
|
||||||
|
*/
|
||||||
|
export const FONTHARE_CATEGORIES: Property<'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script'>[] = [
|
||||||
|
{ id: 'sans', name: 'Sans', value: 'sans' },
|
||||||
|
{ id: 'serif', name: 'Serif', value: 'serif' },
|
||||||
|
{ id: 'slab', name: 'Slab', value: 'slab' },
|
||||||
|
{ id: 'display', name: 'Display', value: 'display' },
|
||||||
|
{ id: 'handwritten', name: 'Handwritten', value: 'handwritten' },
|
||||||
|
{ id: 'script', name: 'Script', value: 'script' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified categories (combines both providers)
|
||||||
|
*/
|
||||||
|
export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
|
||||||
|
{ id: 'sans-serif', name: 'Sans Serif', value: 'sans-serif' },
|
||||||
|
{ id: 'serif', name: 'Serif', value: 'serif' },
|
||||||
|
{ id: 'display', name: 'Display', value: 'display' },
|
||||||
|
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
|
||||||
|
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FONT SUBSETS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common font subsets
|
||||||
|
*/
|
||||||
|
export const FONT_SUBSETS: Property<FontSubset>[] = [
|
||||||
|
{ id: 'latin', name: 'Latin', value: 'latin' },
|
||||||
|
{ id: 'latin-ext', name: 'Latin Extended', value: 'latin-ext' },
|
||||||
|
{ id: 'cyrillic', name: 'Cyrillic', value: 'cyrillic' },
|
||||||
|
{ id: 'greek', name: 'Greek', value: 'greek' },
|
||||||
|
{ id: 'arabic', name: 'Arabic', value: 'arabic' },
|
||||||
|
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FONT PROVIDERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font providers
|
||||||
|
*/
|
||||||
|
export const FONT_PROVIDERS: Property<FontProvider>[] = [
|
||||||
|
{ id: 'google', name: 'Google Fonts', value: 'google' },
|
||||||
|
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FILTER FACTORIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock filter from properties
|
||||||
|
*/
|
||||||
|
export function createMockFilter<TValue extends string>(
|
||||||
|
options: MockFilterOptions & { properties: Property<TValue>[] },
|
||||||
|
) {
|
||||||
|
return createFilter<TValue>(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock filter for categories
|
||||||
|
*/
|
||||||
|
export function createCategoriesFilter(options?: { selected?: FontCategory[] }) {
|
||||||
|
const properties = UNIFIED_CATEGORIES.map(cat => ({
|
||||||
|
...cat,
|
||||||
|
selected: options?.selected?.includes(cat.value) ?? false,
|
||||||
|
}));
|
||||||
|
return createFilter<FontCategory>({ properties });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock filter for subsets
|
||||||
|
*/
|
||||||
|
export function createSubsetsFilter(options?: { selected?: FontSubset[] }) {
|
||||||
|
const properties = FONT_SUBSETS.map(subset => ({
|
||||||
|
...subset,
|
||||||
|
selected: options?.selected?.includes(subset.value) ?? false,
|
||||||
|
}));
|
||||||
|
return createFilter<FontSubset>({ properties });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock filter for providers
|
||||||
|
*/
|
||||||
|
export function createProvidersFilter(options?: { selected?: FontProvider[] }) {
|
||||||
|
const properties = FONT_PROVIDERS.map(provider => ({
|
||||||
|
...provider,
|
||||||
|
selected: options?.selected?.includes(provider.value) ?? false,
|
||||||
|
}));
|
||||||
|
return createFilter<FontProvider>({ properties });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PRESET FILTERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset mock filters - use these directly in stories
|
||||||
|
*/
|
||||||
|
export const MOCK_FILTERS: MockFilters = {
|
||||||
|
providers: createFilter({
|
||||||
|
properties: FONT_PROVIDERS,
|
||||||
|
}),
|
||||||
|
categories: createFilter({
|
||||||
|
properties: UNIFIED_CATEGORIES,
|
||||||
|
}),
|
||||||
|
subsets: createFilter({
|
||||||
|
properties: FONT_SUBSETS,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset filters with some items selected
|
||||||
|
*/
|
||||||
|
export const MOCK_FILTERS_SELECTED: MockFilters = {
|
||||||
|
providers: createFilter({
|
||||||
|
properties: [
|
||||||
|
{ ...FONT_PROVIDERS[0], selected: true },
|
||||||
|
{ ...FONT_PROVIDERS[1] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
categories: createFilter({
|
||||||
|
properties: [
|
||||||
|
{ ...UNIFIED_CATEGORIES[0], selected: true },
|
||||||
|
{ ...UNIFIED_CATEGORIES[1], selected: true },
|
||||||
|
{ ...UNIFIED_CATEGORIES[2] },
|
||||||
|
{ ...UNIFIED_CATEGORIES[3] },
|
||||||
|
{ ...UNIFIED_CATEGORIES[4] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
subsets: createFilter({
|
||||||
|
properties: [
|
||||||
|
{ ...FONT_SUBSETS[0], selected: true },
|
||||||
|
{ ...FONT_SUBSETS[1] },
|
||||||
|
{ ...FONT_SUBSETS[2] },
|
||||||
|
{ ...FONT_SUBSETS[3] },
|
||||||
|
{ ...FONT_SUBSETS[4] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty filters (all properties, none selected)
|
||||||
|
*/
|
||||||
|
export const MOCK_FILTERS_EMPTY: MockFilters = {
|
||||||
|
providers: createFilter({
|
||||||
|
properties: FONT_PROVIDERS.map(p => ({ ...p, selected: false })),
|
||||||
|
}),
|
||||||
|
categories: createFilter({
|
||||||
|
properties: UNIFIED_CATEGORIES.map(c => ({ ...c, selected: false })),
|
||||||
|
}),
|
||||||
|
subsets: createFilter({
|
||||||
|
properties: FONT_SUBSETS.map(s => ({ ...s, selected: false })),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All selected filters
|
||||||
|
*/
|
||||||
|
export const MOCK_FILTERS_ALL_SELECTED: MockFilters = {
|
||||||
|
providers: createFilter({
|
||||||
|
properties: FONT_PROVIDERS.map(p => ({ ...p, selected: true })),
|
||||||
|
}),
|
||||||
|
categories: createFilter({
|
||||||
|
properties: UNIFIED_CATEGORIES.map(c => ({ ...c, selected: true })),
|
||||||
|
}),
|
||||||
|
subsets: createFilter({
|
||||||
|
properties: FONT_SUBSETS.map(s => ({ ...s, selected: true })),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GENERIC FILTER MOCKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock filter with generic string properties
|
||||||
|
* Useful for testing generic filter components
|
||||||
|
*/
|
||||||
|
export function createGenericFilter(
|
||||||
|
items: Array<{ id: string; name: string; selected?: boolean }>,
|
||||||
|
options?: { selected?: string[] },
|
||||||
|
) {
|
||||||
|
const properties = items.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
value: item.id,
|
||||||
|
selected: options?.selected?.includes(item.id) ?? item.selected ?? false,
|
||||||
|
}));
|
||||||
|
return createFilter({ properties });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset generic filters for testing
|
||||||
|
*/
|
||||||
|
export const GENERIC_FILTERS = {
|
||||||
|
/** Small filter with 3 items */
|
||||||
|
small: createFilter({
|
||||||
|
properties: [
|
||||||
|
{ id: 'option-1', name: 'Option 1', value: 'option-1' },
|
||||||
|
{ id: 'option-2', name: 'Option 2', value: 'option-2' },
|
||||||
|
{ id: 'option-3', name: 'Option 3', value: 'option-3' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/** Medium filter with 6 items */
|
||||||
|
medium: createFilter({
|
||||||
|
properties: [
|
||||||
|
{ id: 'alpha', name: 'Alpha', value: 'alpha' },
|
||||||
|
{ id: 'beta', name: 'Beta', value: 'beta' },
|
||||||
|
{ id: 'gamma', name: 'Gamma', value: 'gamma' },
|
||||||
|
{ id: 'delta', name: 'Delta', value: 'delta' },
|
||||||
|
{ id: 'epsilon', name: 'Epsilon', value: 'epsilon' },
|
||||||
|
{ id: 'zeta', name: 'Zeta', value: 'zeta' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/** Large filter with 12 items */
|
||||||
|
large: createFilter({
|
||||||
|
properties: [
|
||||||
|
{ id: 'jan', name: 'January', value: 'jan' },
|
||||||
|
{ id: 'feb', name: 'February', value: 'feb' },
|
||||||
|
{ id: 'mar', name: 'March', value: 'mar' },
|
||||||
|
{ id: 'apr', name: 'April', value: 'apr' },
|
||||||
|
{ id: 'may', name: 'May', value: 'may' },
|
||||||
|
{ id: 'jun', name: 'June', value: 'jun' },
|
||||||
|
{ id: 'jul', name: 'July', value: 'jul' },
|
||||||
|
{ id: 'aug', name: 'August', value: 'aug' },
|
||||||
|
{ id: 'sep', name: 'September', value: 'sep' },
|
||||||
|
{ id: 'oct', name: 'October', value: 'oct' },
|
||||||
|
{ id: 'nov', name: 'November', value: 'nov' },
|
||||||
|
{ id: 'dec', name: 'December', value: 'dec' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/** Filter with some pre-selected items */
|
||||||
|
partial: createFilter({
|
||||||
|
properties: [
|
||||||
|
{ id: 'red', name: 'Red', value: 'red', selected: true },
|
||||||
|
{ id: 'blue', name: 'Blue', value: 'blue', selected: false },
|
||||||
|
{ id: 'green', name: 'Green', value: 'green', selected: true },
|
||||||
|
{ id: 'yellow', name: 'Yellow', value: 'yellow', selected: false },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/** Filter with all items selected */
|
||||||
|
allSelected: createFilter({
|
||||||
|
properties: [
|
||||||
|
{ id: 'cat', name: 'Cat', value: 'cat', selected: true },
|
||||||
|
{ id: 'dog', name: 'Dog', value: 'dog', selected: true },
|
||||||
|
{ id: 'bird', name: 'Bird', value: 'bird', selected: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/** Empty filter (no items) */
|
||||||
|
empty: createFilter({
|
||||||
|
properties: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a filter with sequential items
|
||||||
|
*/
|
||||||
|
export function generateSequentialFilter(count: number, prefix = 'Item ') {
|
||||||
|
const properties = Array.from({ length: count }, (_, i) => ({
|
||||||
|
id: `item-${i + 1}`,
|
||||||
|
name: `${prefix}${i + 1}`,
|
||||||
|
value: `item-${i + 1}`,
|
||||||
|
}));
|
||||||
|
return createFilter({ properties });
|
||||||
|
}
|
||||||
630
src/entities/Font/lib/mocks/fonts.mock.ts
Normal file
630
src/entities/Font/lib/mocks/fonts.mock.ts
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* MOCK FONT DATA
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Factory functions and preset mock data for fonts.
|
||||||
|
* Used in Storybook stories, tests, and development.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import {
|
||||||
|
* mockGoogleFont,
|
||||||
|
* mockFontshareFont,
|
||||||
|
* mockUnifiedFont,
|
||||||
|
* GOOGLE_FONTS,
|
||||||
|
* FONTHARE_FONTS,
|
||||||
|
* UNIFIED_FONTS,
|
||||||
|
* } from '$entities/Font/lib/mocks';
|
||||||
|
*
|
||||||
|
* // Create a mock Google Font
|
||||||
|
* const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' });
|
||||||
|
*
|
||||||
|
* // Create a mock Fontshare font
|
||||||
|
* const satoshi = mockFontshareFont({ name: 'Satoshi', slug: 'satoshi' });
|
||||||
|
*
|
||||||
|
* // Create a mock UnifiedFont
|
||||||
|
* const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' });
|
||||||
|
*
|
||||||
|
* // Use preset fonts
|
||||||
|
* import { UNIFIED_FONTS } from '$entities/Font/lib/mocks';
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FontCategory,
|
||||||
|
FontProvider,
|
||||||
|
FontSubset,
|
||||||
|
FontVariant,
|
||||||
|
} from '$entities/Font/model/types';
|
||||||
|
import type {
|
||||||
|
FontItem,
|
||||||
|
FontshareFont,
|
||||||
|
GoogleFontItem,
|
||||||
|
} from '$entities/Font/model/types';
|
||||||
|
import type {
|
||||||
|
FontFeatures,
|
||||||
|
FontMetadata,
|
||||||
|
FontStyleUrls,
|
||||||
|
UnifiedFont,
|
||||||
|
} from '$entities/Font/model/types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GOOGLE FONTS MOCKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a mock Google Font
|
||||||
|
*/
|
||||||
|
export interface MockGoogleFontOptions {
|
||||||
|
/** Font family name (default: 'Mock Font') */
|
||||||
|
family?: string;
|
||||||
|
/** Font category (default: 'sans-serif') */
|
||||||
|
category?: 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
||||||
|
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
|
||||||
|
variants?: FontVariant[];
|
||||||
|
/** Font subsets (default: ['latin']) */
|
||||||
|
subsets?: string[];
|
||||||
|
/** Font version (default: 'v30') */
|
||||||
|
version?: string;
|
||||||
|
/** Last modified date (default: current ISO date) */
|
||||||
|
lastModified?: string;
|
||||||
|
/** Custom file URLs (if not provided, mock URLs are generated) */
|
||||||
|
files?: Partial<Record<FontVariant, string>>;
|
||||||
|
/** Popularity rank (1 = most popular) */
|
||||||
|
popularity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default mock Google Font
|
||||||
|
*/
|
||||||
|
export function mockGoogleFont(options: MockGoogleFontOptions = {}): GoogleFontItem {
|
||||||
|
const {
|
||||||
|
family = 'Mock Font',
|
||||||
|
category = 'sans-serif',
|
||||||
|
variants = ['regular', '700', 'italic', '700italic'],
|
||||||
|
subsets = ['latin'],
|
||||||
|
version = 'v30',
|
||||||
|
lastModified = new Date().toISOString().split('T')[0],
|
||||||
|
files,
|
||||||
|
popularity = 1,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const baseUrl = `https://fonts.gstatic.com/s/${family.toLowerCase().replace(/\s+/g, '')}/${version}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
family,
|
||||||
|
category,
|
||||||
|
variants: variants as FontVariant[],
|
||||||
|
subsets,
|
||||||
|
version,
|
||||||
|
lastModified,
|
||||||
|
files: files ?? {
|
||||||
|
regular: `${baseUrl}/KFOmCnqEu92Fr1Me4W.woff2`,
|
||||||
|
'700': `${baseUrl}/KFOlCnqEu92Fr1MmWUlfBBc9.woff2`,
|
||||||
|
italic: `${baseUrl}/KFOkCnqEu92Fr1Mu51xIIzI.woff2`,
|
||||||
|
'700italic': `${baseUrl}/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2`,
|
||||||
|
},
|
||||||
|
menu: `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset Google Font mocks
|
||||||
|
*/
|
||||||
|
export const GOOGLE_FONTS: Record<string, GoogleFontItem> = {
|
||||||
|
roboto: mockGoogleFont({
|
||||||
|
family: 'Roboto',
|
||||||
|
category: 'sans-serif',
|
||||||
|
variants: ['100', '300', '400', '500', '700', '900', 'italic', '700italic'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
|
||||||
|
popularity: 1,
|
||||||
|
}),
|
||||||
|
openSans: mockGoogleFont({
|
||||||
|
family: 'Open Sans',
|
||||||
|
category: 'sans-serif',
|
||||||
|
variants: ['300', '400', '500', '600', '700', '800', 'italic', '700italic'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
|
||||||
|
popularity: 2,
|
||||||
|
}),
|
||||||
|
lato: mockGoogleFont({
|
||||||
|
family: 'Lato',
|
||||||
|
category: 'sans-serif',
|
||||||
|
variants: ['100', '300', '400', '700', '900', 'italic', '700italic'],
|
||||||
|
subsets: ['latin', 'latin-ext'],
|
||||||
|
popularity: 3,
|
||||||
|
}),
|
||||||
|
playfairDisplay: mockGoogleFont({
|
||||||
|
family: 'Playfair Display',
|
||||||
|
category: 'serif',
|
||||||
|
variants: ['400', '500', '600', '700', '800', '900', 'italic', '700italic'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'cyrillic'],
|
||||||
|
popularity: 10,
|
||||||
|
}),
|
||||||
|
montserrat: mockGoogleFont({
|
||||||
|
family: 'Montserrat',
|
||||||
|
category: 'sans-serif',
|
||||||
|
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic', '700italic'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
||||||
|
popularity: 4,
|
||||||
|
}),
|
||||||
|
sourceSansPro: mockGoogleFont({
|
||||||
|
family: 'Source Sans Pro',
|
||||||
|
category: 'sans-serif',
|
||||||
|
variants: ['200', '300', '400', '600', '700', '900', 'italic', '700italic'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
|
||||||
|
popularity: 5,
|
||||||
|
}),
|
||||||
|
merriweather: mockGoogleFont({
|
||||||
|
family: 'Merriweather',
|
||||||
|
category: 'serif',
|
||||||
|
variants: ['300', '400', '700', '900', 'italic', '700italic'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
||||||
|
popularity: 15,
|
||||||
|
}),
|
||||||
|
robotoSlab: mockGoogleFont({
|
||||||
|
family: 'Roboto Slab',
|
||||||
|
category: 'serif',
|
||||||
|
variants: ['100', '300', '400', '500', '700', '900'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
|
||||||
|
popularity: 8,
|
||||||
|
}),
|
||||||
|
oswald: mockGoogleFont({
|
||||||
|
family: 'Oswald',
|
||||||
|
category: 'sans-serif',
|
||||||
|
variants: ['200', '300', '400', '500', '600', '700'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'vietnamese'],
|
||||||
|
popularity: 6,
|
||||||
|
}),
|
||||||
|
raleway: mockGoogleFont({
|
||||||
|
family: 'Raleway',
|
||||||
|
category: 'sans-serif',
|
||||||
|
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic'],
|
||||||
|
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
||||||
|
popularity: 7,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FONTHARE MOCKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a mock Fontshare font
|
||||||
|
*/
|
||||||
|
export interface MockFontshareFontOptions {
|
||||||
|
/** Font name (default: 'Mock Font') */
|
||||||
|
name?: string;
|
||||||
|
/** URL-friendly slug (default: derived from name) */
|
||||||
|
slug?: string;
|
||||||
|
/** Font category (default: 'sans') */
|
||||||
|
category?: 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script' | 'mono';
|
||||||
|
/** Script (default: 'latin') */
|
||||||
|
script?: string;
|
||||||
|
/** Whether this is a variable font (default: false) */
|
||||||
|
isVariable?: boolean;
|
||||||
|
/** Font version (default: '1.0') */
|
||||||
|
version?: string;
|
||||||
|
/** Popularity/views count (default: 1000) */
|
||||||
|
views?: number;
|
||||||
|
/** Usage tags */
|
||||||
|
tags?: string[];
|
||||||
|
/** Font weights available */
|
||||||
|
weights?: number[];
|
||||||
|
/** Publisher name */
|
||||||
|
publisher?: string;
|
||||||
|
/** Designer name */
|
||||||
|
designer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock Fontshare style
|
||||||
|
*/
|
||||||
|
function mockFontshareStyle(
|
||||||
|
weight: number,
|
||||||
|
isItalic: boolean,
|
||||||
|
isVariable: boolean,
|
||||||
|
slug: string,
|
||||||
|
): FontshareFont['styles'][number] {
|
||||||
|
const weightLabel = weight === 400 ? 'Regular' : weight === 700 ? 'Bold' : weight.toString();
|
||||||
|
const suffix = isItalic ? 'italic' : '';
|
||||||
|
const variablePrefix = isVariable ? 'variable-' : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `style-${weight}${isItalic ? '-italic' : ''}`,
|
||||||
|
default: weight === 400 && !isItalic,
|
||||||
|
file: `//cdn.fontshare.com/wf/${slug}-${variablePrefix}${weight}${suffix}.woff2`,
|
||||||
|
is_italic: isItalic,
|
||||||
|
is_variable: isVariable,
|
||||||
|
properties: {},
|
||||||
|
weight: {
|
||||||
|
label: isVariable ? 'Variable' + (isItalic ? ' Italic' : '') : weightLabel,
|
||||||
|
name: isVariable ? 'Variable' + (isItalic ? 'Italic' : '') : weightLabel,
|
||||||
|
native_name: null,
|
||||||
|
number: isVariable ? 0 : weight,
|
||||||
|
weight: isVariable ? 0 : weight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default mock Fontshare font
|
||||||
|
*/
|
||||||
|
export function mockFontshareFont(options: MockFontshareFontOptions = {}): FontshareFont {
|
||||||
|
const {
|
||||||
|
name = 'Mock Font',
|
||||||
|
slug = name.toLowerCase().replace(/\s+/g, '-'),
|
||||||
|
category = 'sans',
|
||||||
|
script = 'latin',
|
||||||
|
isVariable = false,
|
||||||
|
version = '1.0',
|
||||||
|
views = 1000,
|
||||||
|
tags = [],
|
||||||
|
weights = [400, 700],
|
||||||
|
publisher = 'Mock Foundry',
|
||||||
|
designer = 'Mock Designer',
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Generate styles based on weights and variable setting
|
||||||
|
const styles: FontshareFont['styles'] = isVariable
|
||||||
|
? [
|
||||||
|
mockFontshareStyle(0, false, true, slug),
|
||||||
|
mockFontshareStyle(0, true, true, slug),
|
||||||
|
]
|
||||||
|
: weights.flatMap(weight => [
|
||||||
|
mockFontshareStyle(weight, false, false, slug),
|
||||||
|
mockFontshareStyle(weight, true, false, slug),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `mock-${slug}`,
|
||||||
|
name,
|
||||||
|
native_name: null,
|
||||||
|
slug,
|
||||||
|
category,
|
||||||
|
script,
|
||||||
|
publisher: {
|
||||||
|
bio: `Mock publisher bio for ${publisher}`,
|
||||||
|
email: null,
|
||||||
|
id: `pub-${slug}`,
|
||||||
|
links: [],
|
||||||
|
name: publisher,
|
||||||
|
},
|
||||||
|
designers: [
|
||||||
|
{
|
||||||
|
bio: `Mock designer bio for ${designer}`,
|
||||||
|
links: [],
|
||||||
|
name: designer,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
related_families: null,
|
||||||
|
display_publisher_as_designer: false,
|
||||||
|
trials_enabled: true,
|
||||||
|
show_latin_metrics: false,
|
||||||
|
license_type: 'ofl',
|
||||||
|
languages: 'English, Spanish, French, German',
|
||||||
|
inserted_at: '2021-03-12T20:49:05Z',
|
||||||
|
story: `<p>A mock font story for ${name}.</p>`,
|
||||||
|
version,
|
||||||
|
views,
|
||||||
|
views_recent: Math.floor(views * 0.1),
|
||||||
|
is_hot: views > 5000,
|
||||||
|
is_new: views < 500,
|
||||||
|
is_shortlisted: null,
|
||||||
|
is_top: views > 10000,
|
||||||
|
axes: isVariable
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'Weight',
|
||||||
|
property: 'wght',
|
||||||
|
range_default: 400,
|
||||||
|
range_left: 300,
|
||||||
|
range_right: 700,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
font_tags: tags.map(name => ({ name })),
|
||||||
|
features: [],
|
||||||
|
styles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset Fontshare font mocks
|
||||||
|
*/
|
||||||
|
export const FONTHARE_FONTS: Record<string, FontshareFont> = {
|
||||||
|
satoshi: mockFontshareFont({
|
||||||
|
name: 'Satoshi',
|
||||||
|
slug: 'satoshi',
|
||||||
|
category: 'sans',
|
||||||
|
isVariable: true,
|
||||||
|
views: 15000,
|
||||||
|
tags: ['Branding', 'Logos', 'Editorial'],
|
||||||
|
publisher: 'Indian Type Foundry',
|
||||||
|
designer: 'Denis Shelabovets',
|
||||||
|
}),
|
||||||
|
generalSans: mockFontshareFont({
|
||||||
|
name: 'General Sans',
|
||||||
|
slug: 'general-sans',
|
||||||
|
category: 'sans',
|
||||||
|
isVariable: true,
|
||||||
|
views: 12000,
|
||||||
|
tags: ['UI', 'Branding', 'Display'],
|
||||||
|
publisher: 'Indestructible Type',
|
||||||
|
designer: 'Eugene Tantsur',
|
||||||
|
}),
|
||||||
|
clashDisplay: mockFontshareFont({
|
||||||
|
name: 'Clash Display',
|
||||||
|
slug: 'clash-display',
|
||||||
|
category: 'display',
|
||||||
|
isVariable: false,
|
||||||
|
views: 8000,
|
||||||
|
tags: ['Headlines', 'Posters', 'Branding'],
|
||||||
|
weights: [400, 500, 600, 700],
|
||||||
|
publisher: 'Letterogika',
|
||||||
|
designer: 'Matěj Trnka',
|
||||||
|
}),
|
||||||
|
fonta: mockFontshareFont({
|
||||||
|
name: 'Fonta',
|
||||||
|
slug: 'fonta',
|
||||||
|
category: 'serif',
|
||||||
|
isVariable: false,
|
||||||
|
views: 5000,
|
||||||
|
tags: ['Editorial', 'Books', 'Magazines'],
|
||||||
|
weights: [300, 400, 500, 600, 700],
|
||||||
|
publisher: 'Fonta',
|
||||||
|
designer: 'Alexei Vanyashin',
|
||||||
|
}),
|
||||||
|
aileron: mockFontshareFont({
|
||||||
|
name: 'Aileron',
|
||||||
|
slug: 'aileron',
|
||||||
|
category: 'sans',
|
||||||
|
isVariable: false,
|
||||||
|
views: 3000,
|
||||||
|
tags: ['Display', 'Headlines'],
|
||||||
|
weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||||
|
publisher: 'Sorkin Type',
|
||||||
|
designer: 'Sorkin Type',
|
||||||
|
}),
|
||||||
|
beVietnamPro: mockFontshareFont({
|
||||||
|
name: 'Be Vietnam Pro',
|
||||||
|
slug: 'be-vietnam-pro',
|
||||||
|
category: 'sans',
|
||||||
|
isVariable: true,
|
||||||
|
views: 20000,
|
||||||
|
tags: ['UI', 'App', 'Web'],
|
||||||
|
publisher: 'ildefox',
|
||||||
|
designer: 'Manh Nguyen',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UNIFIED FONT MOCKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for creating a mock UnifiedFont
|
||||||
|
*/
|
||||||
|
export interface MockUnifiedFontOptions {
|
||||||
|
/** Unique identifier (default: derived from name) */
|
||||||
|
id?: string;
|
||||||
|
/** Font display name (default: 'Mock Font') */
|
||||||
|
name?: string;
|
||||||
|
/** Font provider (default: 'google') */
|
||||||
|
provider?: FontProvider;
|
||||||
|
/** Font category (default: 'sans-serif') */
|
||||||
|
category?: FontCategory;
|
||||||
|
/** Font subsets (default: ['latin']) */
|
||||||
|
subsets?: FontSubset[];
|
||||||
|
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
|
||||||
|
variants?: FontVariant[];
|
||||||
|
/** Style URLs (if not provided, mock URLs are generated) */
|
||||||
|
styles?: FontStyleUrls;
|
||||||
|
/** Metadata overrides */
|
||||||
|
metadata?: Partial<FontMetadata>;
|
||||||
|
/** Features overrides */
|
||||||
|
features?: Partial<FontFeatures>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default mock UnifiedFont
|
||||||
|
*/
|
||||||
|
export function mockUnifiedFont(options: MockUnifiedFontOptions = {}): UnifiedFont {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
name = 'Mock Font',
|
||||||
|
provider = 'google',
|
||||||
|
category = 'sans-serif',
|
||||||
|
subsets = ['latin'],
|
||||||
|
variants = ['regular', '700', 'italic', '700italic'],
|
||||||
|
styles,
|
||||||
|
metadata,
|
||||||
|
features,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const fontId = id ?? name.toLowerCase().replace(/\s+/g, '');
|
||||||
|
const baseUrl = provider === 'google'
|
||||||
|
? `https://fonts.gstatic.com/s/${fontId}/v30`
|
||||||
|
: `//cdn.fontshare.com/wf/${fontId}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: fontId,
|
||||||
|
name,
|
||||||
|
provider,
|
||||||
|
category,
|
||||||
|
subsets,
|
||||||
|
variants: variants as FontVariant[],
|
||||||
|
styles: styles ?? {
|
||||||
|
regular: `${baseUrl}/regular.woff2`,
|
||||||
|
bold: `${baseUrl}/bold.woff2`,
|
||||||
|
italic: `${baseUrl}/italic.woff2`,
|
||||||
|
boldItalic: `${baseUrl}/bolditalic.woff2`,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
version: '1.0',
|
||||||
|
lastModified: new Date().toISOString().split('T')[0],
|
||||||
|
popularity: 1,
|
||||||
|
...metadata,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
isVariable: false,
|
||||||
|
...features,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset UnifiedFont mocks
|
||||||
|
*/
|
||||||
|
export const UNIFIED_FONTS: Record<string, UnifiedFont> = {
|
||||||
|
roboto: mockUnifiedFont({
|
||||||
|
id: 'roboto',
|
||||||
|
name: 'Roboto',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin', 'latin-ext'],
|
||||||
|
variants: ['100', '300', '400', '500', '700', '900'],
|
||||||
|
metadata: { popularity: 1 },
|
||||||
|
}),
|
||||||
|
openSans: mockUnifiedFont({
|
||||||
|
id: 'open-sans',
|
||||||
|
name: 'Open Sans',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin', 'latin-ext'],
|
||||||
|
variants: ['300', '400', '500', '600', '700', '800'],
|
||||||
|
metadata: { popularity: 2 },
|
||||||
|
}),
|
||||||
|
lato: mockUnifiedFont({
|
||||||
|
id: 'lato',
|
||||||
|
name: 'Lato',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin', 'latin-ext'],
|
||||||
|
variants: ['100', '300', '400', '700', '900'],
|
||||||
|
metadata: { popularity: 3 },
|
||||||
|
}),
|
||||||
|
playfairDisplay: mockUnifiedFont({
|
||||||
|
id: 'playfair-display',
|
||||||
|
name: 'Playfair Display',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'serif',
|
||||||
|
subsets: ['latin'],
|
||||||
|
variants: ['400', '700', '900'],
|
||||||
|
metadata: { popularity: 10 },
|
||||||
|
}),
|
||||||
|
montserrat: mockUnifiedFont({
|
||||||
|
id: 'montserrat',
|
||||||
|
name: 'Montserrat',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin', 'latin-ext'],
|
||||||
|
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
|
||||||
|
metadata: { popularity: 4 },
|
||||||
|
}),
|
||||||
|
satoshi: mockUnifiedFont({
|
||||||
|
id: 'satoshi',
|
||||||
|
name: 'Satoshi',
|
||||||
|
provider: 'fontshare',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin'],
|
||||||
|
variants: ['regular', 'bold', 'italic', 'bolditalic'] as FontVariant[],
|
||||||
|
features: { isVariable: true, axes: [{ name: 'wght', property: 'wght', default: 400, min: 300, max: 700 }] },
|
||||||
|
metadata: { popularity: 15000 },
|
||||||
|
}),
|
||||||
|
generalSans: mockUnifiedFont({
|
||||||
|
id: 'general-sans',
|
||||||
|
name: 'General Sans',
|
||||||
|
provider: 'fontshare',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin'],
|
||||||
|
variants: ['regular', 'bold', 'italic', 'bolditalic'] as FontVariant[],
|
||||||
|
features: { isVariable: true },
|
||||||
|
metadata: { popularity: 12000 },
|
||||||
|
}),
|
||||||
|
clashDisplay: mockUnifiedFont({
|
||||||
|
id: 'clash-display',
|
||||||
|
name: 'Clash Display',
|
||||||
|
provider: 'fontshare',
|
||||||
|
category: 'display',
|
||||||
|
subsets: ['latin'],
|
||||||
|
variants: ['regular', '500', '600', 'bold'] as FontVariant[],
|
||||||
|
features: { tags: ['Headlines', 'Posters', 'Branding'] },
|
||||||
|
metadata: { popularity: 8000 },
|
||||||
|
}),
|
||||||
|
oswald: mockUnifiedFont({
|
||||||
|
id: 'oswald',
|
||||||
|
name: 'Oswald',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin'],
|
||||||
|
variants: ['200', '300', '400', '500', '600', '700'],
|
||||||
|
metadata: { popularity: 6 },
|
||||||
|
}),
|
||||||
|
raleway: mockUnifiedFont({
|
||||||
|
id: 'raleway',
|
||||||
|
name: 'Raleway',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin'],
|
||||||
|
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
|
||||||
|
metadata: { popularity: 7 },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an array of all preset UnifiedFonts
|
||||||
|
*/
|
||||||
|
export function getAllMockFonts(): UnifiedFont[] {
|
||||||
|
return Object.values(UNIFIED_FONTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fonts by provider
|
||||||
|
*/
|
||||||
|
export function getFontsByProvider(provider: FontProvider): UnifiedFont[] {
|
||||||
|
return getAllMockFonts().filter(font => font.provider === provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get fonts by category
|
||||||
|
*/
|
||||||
|
export function getFontsByCategory(category: FontCategory): UnifiedFont[] {
|
||||||
|
return getAllMockFonts().filter(font => font.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an array of mock fonts with sequential naming
|
||||||
|
*/
|
||||||
|
export function generateMockFonts(count: number, options?: Omit<MockUnifiedFontOptions, 'id' | 'name'>): UnifiedFont[] {
|
||||||
|
return Array.from({ length: count }, (_, i) =>
|
||||||
|
mockUnifiedFont({
|
||||||
|
...options,
|
||||||
|
id: `mock-font-${i + 1}`,
|
||||||
|
name: `Mock Font ${i + 1}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an array of mock fonts with different categories
|
||||||
|
*/
|
||||||
|
export function generateMixedCategoryFonts(countPerCategory: number = 2): UnifiedFont[] {
|
||||||
|
const categories: FontCategory[] = ['sans-serif', 'serif', 'display', 'handwriting', 'monospace'];
|
||||||
|
const fonts: UnifiedFont[] = [];
|
||||||
|
|
||||||
|
categories.forEach(category => {
|
||||||
|
for (let i = 0; i < countPerCategory; i++) {
|
||||||
|
fonts.push(
|
||||||
|
mockUnifiedFont({
|
||||||
|
id: `${category}-${i + 1}`,
|
||||||
|
name: `${category.replace('-', ' ')} ${i + 1}`,
|
||||||
|
category,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return fonts;
|
||||||
|
}
|
||||||
84
src/entities/Font/lib/mocks/index.ts
Normal file
84
src/entities/Font/lib/mocks/index.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* MOCK DATA HELPERS - MAIN EXPORT
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Comprehensive mock data for Storybook stories, tests, and development.
|
||||||
|
*
|
||||||
|
* ## Quick Start
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import {
|
||||||
|
* mockUnifiedFont,
|
||||||
|
* UNIFIED_FONTS,
|
||||||
|
* MOCK_FILTERS,
|
||||||
|
* createMockFontStoreState,
|
||||||
|
* } from '$entities/Font/lib/mocks';
|
||||||
|
*
|
||||||
|
* // Use in stories
|
||||||
|
* const font = mockUnifiedFont({ name: 'My Font', category: 'serif' });
|
||||||
|
* const presets = UNIFIED_FONTS;
|
||||||
|
* const filter = MOCK_FILTERS.categories;
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Font mocks
|
||||||
|
export {
|
||||||
|
FONTHARE_FONTS,
|
||||||
|
generateMixedCategoryFonts,
|
||||||
|
generateMockFonts,
|
||||||
|
getAllMockFonts,
|
||||||
|
getFontsByCategory,
|
||||||
|
getFontsByProvider,
|
||||||
|
GOOGLE_FONTS,
|
||||||
|
mockFontshareFont,
|
||||||
|
type MockFontshareFontOptions,
|
||||||
|
mockGoogleFont,
|
||||||
|
type MockGoogleFontOptions,
|
||||||
|
mockUnifiedFont,
|
||||||
|
type MockUnifiedFontOptions,
|
||||||
|
UNIFIED_FONTS,
|
||||||
|
} from './fonts.mock';
|
||||||
|
|
||||||
|
// Filter mocks
|
||||||
|
export {
|
||||||
|
createCategoriesFilter,
|
||||||
|
createGenericFilter,
|
||||||
|
createMockFilter,
|
||||||
|
createProvidersFilter,
|
||||||
|
createSubsetsFilter,
|
||||||
|
FONT_PROVIDERS,
|
||||||
|
FONT_SUBSETS,
|
||||||
|
FONTHARE_CATEGORIES,
|
||||||
|
generateSequentialFilter,
|
||||||
|
GENERIC_FILTERS,
|
||||||
|
GOOGLE_CATEGORIES,
|
||||||
|
MOCK_FILTERS,
|
||||||
|
MOCK_FILTERS_ALL_SELECTED,
|
||||||
|
MOCK_FILTERS_EMPTY,
|
||||||
|
MOCK_FILTERS_SELECTED,
|
||||||
|
type MockFilterOptions,
|
||||||
|
type MockFilters,
|
||||||
|
UNIFIED_CATEGORIES,
|
||||||
|
} from './filters.mock';
|
||||||
|
|
||||||
|
// Store mocks
|
||||||
|
export {
|
||||||
|
createErrorState,
|
||||||
|
createLoadingState,
|
||||||
|
createMockComparisonStore,
|
||||||
|
createMockFontApiResponse,
|
||||||
|
createMockFontStoreState,
|
||||||
|
createMockQueryState,
|
||||||
|
createMockReactiveState,
|
||||||
|
createMockStore,
|
||||||
|
createSuccessState,
|
||||||
|
generatePaginatedFonts,
|
||||||
|
MOCK_FONT_STORE_STATES,
|
||||||
|
MOCK_STORES,
|
||||||
|
type MockFontStoreState,
|
||||||
|
type MockQueryObserverResult,
|
||||||
|
type MockQueryState,
|
||||||
|
} from './stores.mock';
|
||||||
590
src/entities/Font/lib/mocks/stores.mock.ts
Normal file
590
src/entities/Font/lib/mocks/stores.mock.ts
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* MOCK FONT STORE HELPERS
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Factory functions and preset mock data for TanStack Query stores and state management.
|
||||||
|
* Used in Storybook stories for components that use reactive stores.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import {
|
||||||
|
* createMockQueryState,
|
||||||
|
* MOCK_STORES,
|
||||||
|
* } from '$entities/Font/lib/mocks';
|
||||||
|
*
|
||||||
|
* // Create a mock query state
|
||||||
|
* const loadingState = createMockQueryState({ status: 'pending' });
|
||||||
|
* const errorState = createMockQueryState({ status: 'error', error: 'Failed to load' });
|
||||||
|
* const successState = createMockQueryState({ status: 'success', data: mockFonts });
|
||||||
|
*
|
||||||
|
* // Use preset stores
|
||||||
|
* const mockFontStore = MOCK_STORES.unifiedFontStore();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { UnifiedFont } from '$entities/Font/model/types';
|
||||||
|
import type {
|
||||||
|
QueryKey,
|
||||||
|
QueryObserverResult,
|
||||||
|
QueryStatus,
|
||||||
|
} from '@tanstack/svelte-query';
|
||||||
|
import {
|
||||||
|
UNIFIED_FONTS,
|
||||||
|
generateMockFonts,
|
||||||
|
} from './fonts.mock';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TANSTACK QUERY MOCK TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock TanStack Query state
|
||||||
|
*/
|
||||||
|
export interface MockQueryState<TData = unknown, TError = Error> {
|
||||||
|
status: QueryStatus;
|
||||||
|
data?: TData;
|
||||||
|
error?: TError;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isFetching?: boolean;
|
||||||
|
isSuccess?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
isPending?: boolean;
|
||||||
|
dataUpdatedAt?: number;
|
||||||
|
errorUpdatedAt?: number;
|
||||||
|
failureCount?: number;
|
||||||
|
failureReason?: TError;
|
||||||
|
errorUpdateCount?: number;
|
||||||
|
isRefetching?: boolean;
|
||||||
|
isRefetchError?: boolean;
|
||||||
|
isPaused?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock TanStack Query observer result
|
||||||
|
*/
|
||||||
|
export interface MockQueryObserverResult<TData = unknown, TError = Error> {
|
||||||
|
status?: QueryStatus;
|
||||||
|
data?: TData;
|
||||||
|
error?: TError;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isFetching?: boolean;
|
||||||
|
isSuccess?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
isPending?: boolean;
|
||||||
|
dataUpdatedAt?: number;
|
||||||
|
errorUpdatedAt?: number;
|
||||||
|
failureCount?: number;
|
||||||
|
failureReason?: TError;
|
||||||
|
errorUpdateCount?: number;
|
||||||
|
isRefetching?: boolean;
|
||||||
|
isRefetchError?: boolean;
|
||||||
|
isPaused?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TANSTACK QUERY MOCK FACTORIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock query state for TanStack Query
|
||||||
|
*/
|
||||||
|
export function createMockQueryState<TData = unknown, TError = Error>(
|
||||||
|
options: MockQueryState<TData, TError>,
|
||||||
|
): MockQueryObserverResult<TData, TError> {
|
||||||
|
const {
|
||||||
|
status,
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: status ?? 'success',
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
isLoading: status === 'pending' ? true : false,
|
||||||
|
isFetching: status === 'pending' ? true : false,
|
||||||
|
isSuccess: status === 'success',
|
||||||
|
isError: status === 'error',
|
||||||
|
isPending: status === 'pending',
|
||||||
|
dataUpdatedAt: status === 'success' ? Date.now() : undefined,
|
||||||
|
errorUpdatedAt: status === 'error' ? Date.now() : undefined,
|
||||||
|
failureCount: status === 'error' ? 1 : 0,
|
||||||
|
failureReason: status === 'error' ? error : undefined,
|
||||||
|
errorUpdateCount: status === 'error' ? 1 : 0,
|
||||||
|
isRefetching: false,
|
||||||
|
isRefetchError: false,
|
||||||
|
isPaused: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a loading query state
|
||||||
|
*/
|
||||||
|
export function createLoadingState<TData = unknown>(): MockQueryObserverResult<TData> {
|
||||||
|
return createMockQueryState<TData>({ status: 'pending', data: undefined, error: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an error query state
|
||||||
|
*/
|
||||||
|
export function createErrorState<TError = Error>(
|
||||||
|
error: TError,
|
||||||
|
): MockQueryObserverResult<unknown, TError> {
|
||||||
|
return createMockQueryState<unknown, TError>({ status: 'error', data: undefined, error });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a success query state
|
||||||
|
*/
|
||||||
|
export function createSuccessState<TData>(data: TData): MockQueryObserverResult<TData> {
|
||||||
|
return createMockQueryState<TData>({ status: 'success', data, error: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FONT STORE MOCKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock UnifiedFontStore state
|
||||||
|
*/
|
||||||
|
export interface MockFontStoreState {
|
||||||
|
/** All cached fonts */
|
||||||
|
fonts: Record<string, UnifiedFont>;
|
||||||
|
/** Current page */
|
||||||
|
page: number;
|
||||||
|
/** Total pages available */
|
||||||
|
totalPages: number;
|
||||||
|
/** Items per page */
|
||||||
|
limit: number;
|
||||||
|
/** Total font count */
|
||||||
|
total: number;
|
||||||
|
/** Loading state */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Error state */
|
||||||
|
error: Error | null;
|
||||||
|
/** Search query */
|
||||||
|
searchQuery: string;
|
||||||
|
/** Selected provider */
|
||||||
|
provider: 'google' | 'fontshare' | 'all';
|
||||||
|
/** Selected category */
|
||||||
|
category: string | null;
|
||||||
|
/** Selected subset */
|
||||||
|
subset: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock font store state
|
||||||
|
*/
|
||||||
|
export function createMockFontStoreState(
|
||||||
|
options: Partial<MockFontStoreState> = {},
|
||||||
|
): MockFontStoreState {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 24,
|
||||||
|
isLoading = false,
|
||||||
|
error = null,
|
||||||
|
searchQuery = '',
|
||||||
|
provider = 'all',
|
||||||
|
category = null,
|
||||||
|
subset = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Generate mock fonts if not provided
|
||||||
|
const mockFonts = options.fonts ?? Object.fromEntries(
|
||||||
|
Object.values(UNIFIED_FONTS).map(font => [font.id, font]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fontArray = Object.values(mockFonts);
|
||||||
|
const total = options.total ?? fontArray.length;
|
||||||
|
const totalPages = options.totalPages ?? Math.ceil(total / limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fonts: mockFonts,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
searchQuery,
|
||||||
|
provider,
|
||||||
|
category,
|
||||||
|
subset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset font store states
|
||||||
|
*/
|
||||||
|
export const MOCK_FONT_STORE_STATES = {
|
||||||
|
/** Initial loading state */
|
||||||
|
loading: createMockFontStoreState({
|
||||||
|
isLoading: true,
|
||||||
|
fonts: {},
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Empty state (no fonts found) */
|
||||||
|
empty: createMockFontStoreState({
|
||||||
|
fonts: {},
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** First page with fonts */
|
||||||
|
firstPage: createMockFontStoreState({
|
||||||
|
fonts: Object.fromEntries(
|
||||||
|
Object.values(UNIFIED_FONTS).slice(0, 10).map(font => [font.id, font]),
|
||||||
|
),
|
||||||
|
total: 50,
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
totalPages: 5,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Second page with fonts */
|
||||||
|
secondPage: createMockFontStoreState({
|
||||||
|
fonts: Object.fromEntries(
|
||||||
|
Object.values(UNIFIED_FONTS).slice(10, 20).map(font => [font.id, font]),
|
||||||
|
),
|
||||||
|
total: 50,
|
||||||
|
page: 2,
|
||||||
|
limit: 10,
|
||||||
|
totalPages: 5,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Last page with fonts */
|
||||||
|
lastPage: createMockFontStoreState({
|
||||||
|
fonts: Object.fromEntries(
|
||||||
|
Object.values(UNIFIED_FONTS).slice(0, 5).map(font => [font.id, font]),
|
||||||
|
),
|
||||||
|
total: 25,
|
||||||
|
page: 3,
|
||||||
|
limit: 10,
|
||||||
|
totalPages: 3,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Error state */
|
||||||
|
error: createMockFontStoreState({
|
||||||
|
fonts: {},
|
||||||
|
error: new Error('Failed to load fonts'),
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** With search query */
|
||||||
|
withSearch: createMockFontStoreState({
|
||||||
|
fonts: Object.fromEntries(
|
||||||
|
Object.values(UNIFIED_FONTS).slice(0, 3).map(font => [font.id, font]),
|
||||||
|
),
|
||||||
|
total: 3,
|
||||||
|
page: 1,
|
||||||
|
isLoading: false,
|
||||||
|
searchQuery: 'Roboto',
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Filtered by category */
|
||||||
|
filteredByCategory: createMockFontStoreState({
|
||||||
|
fonts: Object.fromEntries(
|
||||||
|
Object.values(UNIFIED_FONTS)
|
||||||
|
.filter(f => f.category === 'serif')
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(font => [font.id, font]),
|
||||||
|
),
|
||||||
|
total: 5,
|
||||||
|
page: 1,
|
||||||
|
isLoading: false,
|
||||||
|
category: 'serif',
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Filtered by provider */
|
||||||
|
filteredByProvider: createMockFontStoreState({
|
||||||
|
fonts: Object.fromEntries(
|
||||||
|
Object.values(UNIFIED_FONTS)
|
||||||
|
.filter(f => f.provider === 'google')
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(font => [font.id, font]),
|
||||||
|
),
|
||||||
|
total: 5,
|
||||||
|
page: 1,
|
||||||
|
isLoading: false,
|
||||||
|
provider: 'google',
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Large dataset */
|
||||||
|
largeDataset: createMockFontStoreState({
|
||||||
|
fonts: Object.fromEntries(
|
||||||
|
generateMockFonts(50).map(font => [font.id, font]),
|
||||||
|
),
|
||||||
|
total: 500,
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
totalPages: 10,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MOCK STORE OBJECT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock store object that mimics TanStack Query behavior
|
||||||
|
* Useful for components that subscribe to store properties
|
||||||
|
*/
|
||||||
|
export function createMockStore<T>(config: {
|
||||||
|
data?: T;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
error?: Error;
|
||||||
|
isFetching?: boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading = false,
|
||||||
|
isError = false,
|
||||||
|
error,
|
||||||
|
isFetching = false,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get data() {
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
get isLoading() {
|
||||||
|
return isLoading;
|
||||||
|
},
|
||||||
|
get isError() {
|
||||||
|
return isError;
|
||||||
|
},
|
||||||
|
get error() {
|
||||||
|
return error;
|
||||||
|
},
|
||||||
|
get isFetching() {
|
||||||
|
return isFetching;
|
||||||
|
},
|
||||||
|
get isSuccess() {
|
||||||
|
return !isLoading && !isError && data !== undefined;
|
||||||
|
},
|
||||||
|
get status() {
|
||||||
|
if (isLoading) return 'pending';
|
||||||
|
if (isError) return 'error';
|
||||||
|
return 'success';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset mock stores
|
||||||
|
*/
|
||||||
|
export const MOCK_STORES = {
|
||||||
|
/** Font store in loading state */
|
||||||
|
loadingFontStore: createMockStore<UnifiedFont[]>({
|
||||||
|
isLoading: true,
|
||||||
|
data: undefined,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Font store with fonts loaded */
|
||||||
|
successFontStore: createMockStore<UnifiedFont[]>({
|
||||||
|
data: Object.values(UNIFIED_FONTS),
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Font store with error */
|
||||||
|
errorFontStore: createMockStore<UnifiedFont[]>({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
isError: true,
|
||||||
|
error: new Error('Failed to load fonts'),
|
||||||
|
}),
|
||||||
|
|
||||||
|
/** Font store with empty results */
|
||||||
|
emptyFontStore: createMockStore<UnifiedFont[]>({
|
||||||
|
data: [],
|
||||||
|
isLoading: false,
|
||||||
|
isError: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock UnifiedFontStore-like object
|
||||||
|
* Note: This is a simplified mock for Storybook use
|
||||||
|
*/
|
||||||
|
unifiedFontStore: (state: Partial<MockFontStoreState> = {}) => {
|
||||||
|
const mockState = createMockFontStoreState(state);
|
||||||
|
return {
|
||||||
|
// State properties
|
||||||
|
get fonts() {
|
||||||
|
return mockState.fonts;
|
||||||
|
},
|
||||||
|
get page() {
|
||||||
|
return mockState.page;
|
||||||
|
},
|
||||||
|
get totalPages() {
|
||||||
|
return mockState.totalPages;
|
||||||
|
},
|
||||||
|
get limit() {
|
||||||
|
return mockState.limit;
|
||||||
|
},
|
||||||
|
get total() {
|
||||||
|
return mockState.total;
|
||||||
|
},
|
||||||
|
get isLoading() {
|
||||||
|
return mockState.isLoading;
|
||||||
|
},
|
||||||
|
get error() {
|
||||||
|
return mockState.error;
|
||||||
|
},
|
||||||
|
get searchQuery() {
|
||||||
|
return mockState.searchQuery;
|
||||||
|
},
|
||||||
|
get provider() {
|
||||||
|
return mockState.provider;
|
||||||
|
},
|
||||||
|
get category() {
|
||||||
|
return mockState.category;
|
||||||
|
},
|
||||||
|
get subset() {
|
||||||
|
return mockState.subset;
|
||||||
|
},
|
||||||
|
// Methods (no-op for Storybook)
|
||||||
|
nextPage: () => {},
|
||||||
|
prevPage: () => {},
|
||||||
|
goToPage: (_page: number) => {},
|
||||||
|
setLimit: (_limit: number) => {},
|
||||||
|
setProvider: (_provider: typeof mockState.provider) => {},
|
||||||
|
setCategory: (_category: string | null) => {},
|
||||||
|
setSubset: (_subset: string | null) => {},
|
||||||
|
setSearch: (_query: string) => {},
|
||||||
|
resetFilters: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// REACTIVE STATE MOCKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a reactive state object using Svelte 5 runes pattern
|
||||||
|
* Useful for stories that need reactive state
|
||||||
|
*
|
||||||
|
* Note: This uses plain JavaScript objects since Svelte runes
|
||||||
|
* only work in .svelte files. For Storybook, this provides
|
||||||
|
* a similar API for testing.
|
||||||
|
*/
|
||||||
|
export function createMockReactiveState<T>(initialValue: T) {
|
||||||
|
let value = initialValue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get value() {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
set value(newValue: T) {
|
||||||
|
value = newValue;
|
||||||
|
},
|
||||||
|
update(fn: (current: T) => T) {
|
||||||
|
value = fn(value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock comparison store for ComparisonSlider component
|
||||||
|
*/
|
||||||
|
export function createMockComparisonStore(config: {
|
||||||
|
fontA?: UnifiedFont;
|
||||||
|
fontB?: UnifiedFont;
|
||||||
|
text?: string;
|
||||||
|
} = {}) {
|
||||||
|
const { fontA, fontB, text = 'The quick brown fox jumps over the lazy dog.' } = config;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get fontA() {
|
||||||
|
return fontA ?? UNIFIED_FONTS.roboto;
|
||||||
|
},
|
||||||
|
get fontB() {
|
||||||
|
return fontB ?? UNIFIED_FONTS.openSans;
|
||||||
|
},
|
||||||
|
get text() {
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
// Methods (no-op for Storybook)
|
||||||
|
setFontA: (_font: UnifiedFont | undefined) => {},
|
||||||
|
setFontB: (_font: UnifiedFont | undefined) => {},
|
||||||
|
setText: (_text: string) => {},
|
||||||
|
swapFonts: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MOCK DATA GENERATORS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate paginated font data
|
||||||
|
*/
|
||||||
|
export function generatePaginatedFonts(
|
||||||
|
totalCount: number,
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
): {
|
||||||
|
fonts: UnifiedFont[];
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
total: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPrevPage: boolean;
|
||||||
|
} {
|
||||||
|
const totalPages = Math.ceil(totalCount / limit);
|
||||||
|
const startIndex = (page - 1) * limit;
|
||||||
|
const endIndex = Math.min(startIndex + limit, totalCount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fonts: generateMockFonts(endIndex - startIndex).map((font, i) => ({
|
||||||
|
...font,
|
||||||
|
id: `font-${startIndex + i + 1}`,
|
||||||
|
name: `Font ${startIndex + i + 1}`,
|
||||||
|
})),
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
total: totalCount,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPrevPage: page > 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create mock API response for fonts
|
||||||
|
*/
|
||||||
|
export function createMockFontApiResponse(config: {
|
||||||
|
fonts?: UnifiedFont[];
|
||||||
|
total?: number;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
} = {}) {
|
||||||
|
const fonts = config.fonts ?? Object.values(UNIFIED_FONTS);
|
||||||
|
const total = config.total ?? fonts.length;
|
||||||
|
const page = config.page ?? 1;
|
||||||
|
const limit = config.limit ?? fonts.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: fonts,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
hasNextPage: page < Math.ceil(total / limit),
|
||||||
|
hasPrevPage: page > 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
FontshareFont,
|
FontshareFont,
|
||||||
GoogleFontItem,
|
GoogleFontItem,
|
||||||
UnifiedFont,
|
UnifiedFont,
|
||||||
|
UnifiedFontVariant,
|
||||||
} from '../../model/types';
|
} from '../../model/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,7 +187,7 @@ export function normalizeFontshareFont(apiFont: FontshareFont): UnifiedFont {
|
|||||||
const variants = apiFont.styles.map(style => {
|
const variants = apiFont.styles.map(style => {
|
||||||
const weightLabel = style.weight.label;
|
const weightLabel = style.weight.label;
|
||||||
const isItalic = style.is_italic;
|
const isItalic = style.is_italic;
|
||||||
return isItalic ? `${weightLabel}italic` : weightLabel;
|
return (isItalic ? `${weightLabel}italic` : weightLabel) as UnifiedFontVariant;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map styles to URLs
|
// Map styles to URLs
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export type {
|
|||||||
export {
|
export {
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
createUnifiedFontStore,
|
createUnifiedFontStore,
|
||||||
selectedFontsStore,
|
type FontConfigRequest,
|
||||||
type UnifiedFontStore,
|
type UnifiedFontStore,
|
||||||
unifiedFontStore,
|
unifiedFontStore,
|
||||||
} from './store';
|
} from './store';
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/** @vitest-environment jsdom */
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
||||||
|
|
||||||
|
describe('AppliedFontsManager', () => {
|
||||||
|
let manager: AppliedFontsManager;
|
||||||
|
let mockFontFaceSet: any;
|
||||||
|
let mockFetch: any;
|
||||||
|
let failUrls: Set<string>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
failUrls = new Set();
|
||||||
|
|
||||||
|
mockFontFaceSet = {
|
||||||
|
add: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Properly mock FontFace as a constructor function
|
||||||
|
// The actual implementation passes buffer (ArrayBuffer) as second arg, not URL string
|
||||||
|
const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) {
|
||||||
|
this.name = name;
|
||||||
|
this.bufferOrUrl = bufferOrUrl;
|
||||||
|
this.load = vi.fn().mockImplementation(() => {
|
||||||
|
// For error tests, we track which URLs should fail via failUrls
|
||||||
|
// The fetch mock will have already rejected for those URLs
|
||||||
|
return Promise.resolve(this);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('FontFace', MockFontFace);
|
||||||
|
|
||||||
|
// 2. Mock document.fonts safely
|
||||||
|
Object.defineProperty(document, 'fonts', {
|
||||||
|
value: mockFontFaceSet,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('crypto', {
|
||||||
|
randomUUID: () => '11111111-1111-1111-1111-111111111111' as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Mock fetch to return fake ArrayBuffer data
|
||||||
|
mockFetch = vi.fn((url: string) => {
|
||||||
|
if (failUrls.has(url)) {
|
||||||
|
return Promise.reject(new Error('Network error'));
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
||||||
|
clone: () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
manager = new AppliedFontsManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should batch multiple font requests into a single process', async () => {
|
||||||
|
const configs = [
|
||||||
|
{ id: 'lato-400', name: 'Lato', url: 'https://example.com/lato.ttf', weight: 400 },
|
||||||
|
{ id: 'lato-700', name: 'Lato', url: 'https://example.com/lato-bold.ttf', weight: 700 },
|
||||||
|
];
|
||||||
|
|
||||||
|
manager.touch(configs);
|
||||||
|
|
||||||
|
// Advance to trigger the 16ms debounced #processQueue
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(manager.getFontStatus('lato-400', 400)).toBe('loaded');
|
||||||
|
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle font loading errors gracefully', async () => {
|
||||||
|
// Suppress expected console error for clean test logs
|
||||||
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const failUrl = 'https://example.com/fail.ttf';
|
||||||
|
failUrls.add(failUrl);
|
||||||
|
|
||||||
|
const config = { id: 'broken', name: 'Broken', url: failUrl, weight: 400 };
|
||||||
|
|
||||||
|
manager.touch([config]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(manager.getFontStatus('broken', 400)).toBe('error');
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should purge fonts after TTL expires', async () => {
|
||||||
|
const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 };
|
||||||
|
|
||||||
|
manager.touch([config]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded');
|
||||||
|
|
||||||
|
// Move clock forward past TTL (5m) and Purge Interval (1m)
|
||||||
|
// advanceTimersByTimeAsync is key here; it handles the promises inside the interval
|
||||||
|
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
|
||||||
|
|
||||||
|
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
|
||||||
|
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT purge fonts that are still being "touched"', async () => {
|
||||||
|
const config = { id: 'active', name: 'Active', url: 'https://example.com/active.ttf', weight: 400 };
|
||||||
|
|
||||||
|
manager.touch([config]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
// Advance 4 minutes
|
||||||
|
await vi.advanceTimersByTimeAsync(4 * 60 * 1000);
|
||||||
|
|
||||||
|
// Refresh touch
|
||||||
|
manager.touch([config]);
|
||||||
|
|
||||||
|
// Advance another 2 minutes (Total 6 since start)
|
||||||
|
await vi.advanceTimersByTimeAsync(2 * 60 * 1000);
|
||||||
|
|
||||||
|
expect(manager.getFontStatus('active', 400)).toBe('loaded');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,170 +1,354 @@
|
|||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
/** Loading state of a font. Failed loads may be retried up to MAX_RETRIES. */
|
||||||
export type FontStatus = 'loading' | 'loaded' | 'error';
|
export type FontStatus = 'loading' | 'loaded' | 'error';
|
||||||
|
|
||||||
|
/** Configuration for a font load request. */
|
||||||
export interface FontConfigRequest {
|
export interface FontConfigRequest {
|
||||||
/**
|
/**
|
||||||
* Font id
|
* Unique identifier for the font (e.g., "lato", "roboto").
|
||||||
*/
|
*/
|
||||||
id: string;
|
id: string;
|
||||||
/**
|
/**
|
||||||
* Real font name (e.g. "Lato")
|
* Actual font family name recognized by the browser (e.g., "Lato", "Roboto").
|
||||||
*/
|
*/
|
||||||
name: string;
|
name: string;
|
||||||
/**
|
/**
|
||||||
* The .ttf URL
|
* URL pointing to the font file (typically .ttf or .woff2).
|
||||||
*/
|
*/
|
||||||
url: string;
|
url: string;
|
||||||
/**
|
/**
|
||||||
* Font weight
|
* Numeric weight (100-900). Variable fonts load once per ID regardless of weight.
|
||||||
*/
|
*/
|
||||||
weight: number;
|
weight: number;
|
||||||
/**
|
/**
|
||||||
* Flag of the variable weight
|
* Variable fonts load once per ID; static fonts load per weight.
|
||||||
*/
|
*/
|
||||||
isVariable?: boolean;
|
isVariable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manager that handles loading of fonts.
|
* Manages web font loading with caching, adaptive concurrency, and automatic cleanup.
|
||||||
* Logic:
|
*
|
||||||
* - Variable fonts: Loaded once per id (covers all weights).
|
* **Two-Phase Loading Strategy:**
|
||||||
* - Static fonts: Loaded per id + weight combination.
|
* 1. *Concurrent Fetching*: Font files fetched in parallel (network I/O is non-blocking)
|
||||||
|
* 2. *Sequential Parsing*: Buffers parsed into FontFace objects one at a time with periodic yields
|
||||||
|
*
|
||||||
|
* **Yielding Strategy:**
|
||||||
|
* - Chromium: Yields only when user input is pending (via `scheduler.yield()` + `isInputPending()`)
|
||||||
|
* - Others: Time-based fallback, yields every 8ms
|
||||||
|
*
|
||||||
|
* **Network Adaptation:**
|
||||||
|
* - 2G: 1 concurrent request, 3G: 2, 4G+: 4 (via Network Information API)
|
||||||
|
* - Respects `saveData` mode to defer non-critical weights
|
||||||
|
*
|
||||||
|
* **Cache Integration:** Cache API with best-effort fallback (handles private browsing, quota limits)
|
||||||
|
*
|
||||||
|
* **Cleanup:** LRU-style eviction after 5 minutes of inactivity; cleanup runs every 60 seconds
|
||||||
|
*
|
||||||
|
* **Font Identity:** Variable fonts use `{id}@vf`, static fonts use `{id}@{weight}`
|
||||||
|
*
|
||||||
|
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
|
||||||
*/
|
*/
|
||||||
class AppliedFontsManager {
|
export class AppliedFontsManager {
|
||||||
#usageTracker = new Map<string, number>();
|
// Loaded FontFace instances registered with document.fonts. Key: `{id}@{weight}` or `{id}@vf`
|
||||||
#idToBatch = new Map<string, string>();
|
#loadedFonts = new Map<string, FontFace>();
|
||||||
// Changed to HTMLStyleElement
|
|
||||||
#batchElements = new Map<string, HTMLStyleElement>();
|
|
||||||
|
|
||||||
#queue = new Map<string, FontConfigRequest>(); // Track config in queue
|
// Last-used timestamps for LRU cleanup. Key: `{id}@{weight}` or `{id}@vf`, Value: unix timestamp (ms)
|
||||||
|
#usageTracker = new Map<string, number>();
|
||||||
|
|
||||||
|
// Fonts queued for loading by `touch()`, processed by `#processQueue()`
|
||||||
|
#queue = new Map<string, FontConfigRequest>();
|
||||||
|
|
||||||
|
// Handle for scheduled queue processing (requestIdleCallback or setTimeout)
|
||||||
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
#PURGE_INTERVAL = 60000;
|
// Interval handle for periodic cleanup (runs every PURGE_INTERVAL)
|
||||||
#TTL = 5 * 60 * 1000;
|
#intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
#CHUNK_SIZE = 5; // Can be larger since we're just injecting strings
|
|
||||||
|
|
||||||
|
// AbortController for canceling in-flight fetches on destroy
|
||||||
|
#abortController = new AbortController();
|
||||||
|
|
||||||
|
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
|
||||||
|
#pendingType: 'idle' | 'timeout' | null = null;
|
||||||
|
|
||||||
|
// Retry counts for failed loads; fonts exceeding MAX_RETRIES are permanently skipped
|
||||||
|
#retryCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
readonly #MAX_RETRIES = 3;
|
||||||
|
readonly #PURGE_INTERVAL = 60000; // 60 seconds
|
||||||
|
readonly #TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
readonly #CACHE_NAME = 'font-cache-v1'; // Versioned for future invalidation
|
||||||
|
|
||||||
|
// Reactive status map for Svelte components to track font states
|
||||||
statuses = new SvelteMap<string, FontStatus>();
|
statuses = new SvelteMap<string, FontStatus>();
|
||||||
|
|
||||||
|
// Starts periodic cleanup timer (browser-only).
|
||||||
constructor() {
|
constructor() {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#getFontKey(id: string, weight: number): string {
|
// Generates font key: `{id}@vf` for variable, `{id}@{weight}` for static.
|
||||||
return `${id.toLowerCase()}@${weight}`;
|
#getFontKey(id: string, weight: number, isVariable: boolean): string {
|
||||||
|
return isVariable ? `${id.toLowerCase()}@vf` : `${id.toLowerCase()}@${weight}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests fonts to be loaded. Updates usage tracking and queues new fonts.
|
||||||
|
*
|
||||||
|
* Retry behavior: 'loaded' and 'loading' fonts are skipped; 'error' fonts retry if count < MAX_RETRIES.
|
||||||
|
* Scheduling: Prefers requestIdleCallback (150ms timeout), falls back to setTimeout(16ms).
|
||||||
|
*/
|
||||||
touch(configs: FontConfigRequest[]) {
|
touch(configs: FontConfigRequest[]) {
|
||||||
|
if (this.#abortController.signal.aborted) return;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
configs.forEach(config => {
|
let hasNewItems = false;
|
||||||
const key = this.#getFontKey(config.id, config.weight);
|
|
||||||
|
for (const config of configs) {
|
||||||
|
const key = this.#getFontKey(config.id, config.weight, !!config.isVariable);
|
||||||
this.#usageTracker.set(key, now);
|
this.#usageTracker.set(key, now);
|
||||||
|
|
||||||
if (!this.#idToBatch.has(key) && !this.#queue.has(key)) {
|
const status = this.statuses.get(key);
|
||||||
this.#queue.set(key, config);
|
if (status === 'loaded' || status === 'loading' || this.#queue.has(key)) continue;
|
||||||
|
if (status === 'error' && (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES) continue;
|
||||||
|
|
||||||
if (this.#timeoutId) clearTimeout(this.#timeoutId);
|
this.#queue.set(key, config);
|
||||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
|
hasNewItems = true;
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getFontStatus(id: string, weight: number) {
|
|
||||||
return this.statuses.get(this.#getFontKey(id, weight));
|
|
||||||
}
|
|
||||||
|
|
||||||
#processQueue() {
|
|
||||||
const entries = Array.from(this.#queue.entries());
|
|
||||||
if (entries.length === 0) return;
|
|
||||||
|
|
||||||
for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) {
|
|
||||||
this.#createBatch(entries.slice(i, i + this.#CHUNK_SIZE));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#queue.clear();
|
if (hasNewItems && !this.#timeoutId) {
|
||||||
|
if (typeof requestIdleCallback !== 'undefined') {
|
||||||
|
this.#timeoutId = requestIdleCallback(
|
||||||
|
() => this.#processQueue(),
|
||||||
|
{ timeout: 150 },
|
||||||
|
) as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
this.#pendingType = 'idle';
|
||||||
|
} else {
|
||||||
|
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
||||||
|
this.#pendingType = 'timeout';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() (Chrome/Edge) or MessageChannel fallback. */
|
||||||
|
async #yieldToMain(): Promise<void> {
|
||||||
|
// @ts-expect-error - scheduler not in TypeScript lib yet
|
||||||
|
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
|
||||||
|
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
|
||||||
|
await scheduler.yield();
|
||||||
|
} else {
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
const ch = new MessageChannel();
|
||||||
|
ch.port1.onmessage = () => resolve();
|
||||||
|
ch.port2.postMessage(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns optimal concurrent fetches based on Network Information API: 1 for 2G, 2 for 3G, 4 for 4G/default. */
|
||||||
|
#getEffectiveConcurrency(): number {
|
||||||
|
const nav = navigator as any;
|
||||||
|
const conn = nav.connection;
|
||||||
|
if (!conn) return 4;
|
||||||
|
|
||||||
|
switch (conn.effectiveType) {
|
||||||
|
case 'slow-2g':
|
||||||
|
case '2g':
|
||||||
|
return 1;
|
||||||
|
case '3g':
|
||||||
|
return 2;
|
||||||
|
default:
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if data-saver mode is enabled (defers non-critical weights). */
|
||||||
|
#shouldDeferNonCritical(): boolean {
|
||||||
|
const nav = navigator as any;
|
||||||
|
return nav.connection?.saveData === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes queued fonts in two phases:
|
||||||
|
* 1. Concurrent fetching (network I/O, non-blocking)
|
||||||
|
* 2. Sequential parsing with periodic yields (CPU-intensive, can block UI)
|
||||||
|
*
|
||||||
|
* Yielding: Chromium uses `isInputPending()` for optimal responsiveness; others yield every 8ms.
|
||||||
|
*/
|
||||||
|
async #processQueue() {
|
||||||
this.#timeoutId = null;
|
this.#timeoutId = null;
|
||||||
}
|
this.#pendingType = null;
|
||||||
|
|
||||||
#createBatch(batchEntries: [string, FontConfigRequest][]) {
|
let entries = Array.from(this.#queue.entries());
|
||||||
if (typeof document === 'undefined') return;
|
if (!entries.length) return;
|
||||||
|
this.#queue.clear();
|
||||||
|
|
||||||
const batchId = crypto.randomUUID();
|
if (this.#shouldDeferNonCritical()) {
|
||||||
let cssRules = '';
|
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
|
||||||
|
}
|
||||||
|
|
||||||
batchEntries.forEach(([key, config]) => {
|
// Phase 1: Concurrent fetching (I/O bound, non-blocking)
|
||||||
this.statuses.set(key, 'loading');
|
const concurrency = this.#getEffectiveConcurrency();
|
||||||
this.#idToBatch.set(key, batchId);
|
const buffers = new Map<string, ArrayBuffer>();
|
||||||
|
|
||||||
// Construct the @font-face rule
|
for (let i = 0; i < entries.length; i += concurrency) {
|
||||||
// Using format('truetype') for .ttf
|
const chunk = entries.slice(i, i + concurrency);
|
||||||
cssRules += `
|
const results = await Promise.allSettled(
|
||||||
@font-face {
|
chunk.map(async ([key, config]) => {
|
||||||
font-family: '${config.name}';
|
this.statuses.set(key, 'loading');
|
||||||
src: url('${config.url}') format('truetype');
|
const buffer = await this.#fetchFontBuffer(
|
||||||
font-weight: ${config.weight};
|
config.url,
|
||||||
font-style: normal;
|
this.#abortController.signal,
|
||||||
font-display: swap;
|
);
|
||||||
|
buffers.set(key, buffer);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let j = 0; j < results.length; j++) {
|
||||||
|
if (results[j].status === 'rejected') {
|
||||||
|
const [key, config] = chunk[j];
|
||||||
|
console.error(`Font fetch failed: ${config.name}`, (results[j] as PromiseRejectedResult).reason);
|
||||||
|
this.statuses.set(key, 'error');
|
||||||
|
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
`;
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// Create and inject the style tag
|
// Phase 2: Sequential parsing (CPU-intensive, yields periodically)
|
||||||
const style = document.createElement('style');
|
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
||||||
style.dataset.batchId = batchId;
|
let lastYield = performance.now();
|
||||||
style.innerHTML = cssRules;
|
const YIELD_INTERVAL = 8; // ms
|
||||||
document.head.appendChild(style);
|
|
||||||
this.#batchElements.set(batchId, style);
|
|
||||||
|
|
||||||
// Verify loading via Font Loading API
|
for (const [key, config] of entries) {
|
||||||
batchEntries.forEach(([key, config]) => {
|
const buffer = buffers.get(key);
|
||||||
document.fonts.load(`${config.weight} 1em "${config.name}"`)
|
if (!buffer) continue;
|
||||||
.then(loaded => {
|
|
||||||
this.statuses.set(key, loaded.length > 0 ? 'loaded' : 'error');
|
try {
|
||||||
})
|
const weightRange = config.isVariable ? '100 900' : `${config.weight}`;
|
||||||
.catch(() => this.statuses.set(key, 'error'));
|
const font = new FontFace(config.name, buffer, {
|
||||||
});
|
weight: weightRange,
|
||||||
|
style: 'normal',
|
||||||
|
display: 'swap',
|
||||||
|
});
|
||||||
|
await font.load();
|
||||||
|
document.fonts.add(font);
|
||||||
|
this.#loadedFonts.set(key, font);
|
||||||
|
this.statuses.set(key, 'loaded');
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.name === 'AbortError') continue;
|
||||||
|
console.error(`Font parse failed: ${config.name}`, e);
|
||||||
|
this.statuses.set(key, 'error');
|
||||||
|
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldYield = hasInputPending
|
||||||
|
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
|
||||||
|
: (performance.now() - lastYield > YIELD_INTERVAL);
|
||||||
|
|
||||||
|
if (shouldYield) {
|
||||||
|
await this.#yieldToMain();
|
||||||
|
lastYield = performance.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches font with cache-aside pattern: checks Cache API first, falls back to network.
|
||||||
|
* Cache failures (private browsing, quota limits) are silently ignored.
|
||||||
|
*/
|
||||||
|
async #fetchFontBuffer(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
|
||||||
|
try {
|
||||||
|
if (typeof caches !== 'undefined') {
|
||||||
|
const cache = await caches.open(this.#CACHE_NAME);
|
||||||
|
const cached = await cache.match(url);
|
||||||
|
if (cached) return cached.arrayBuffer();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Cache unavailable (private browsing, security restrictions) — fall through to network
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { signal });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof caches !== 'undefined') {
|
||||||
|
const cache = await caches.open(this.#CACHE_NAME);
|
||||||
|
await cache.put(url, response.clone());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Cache write failed (quota, storage pressure) — return font anyway
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. */
|
||||||
#purgeUnused() {
|
#purgeUnused() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const batchesToRemove = new Set<string>();
|
for (const [key, lastUsed] of this.#usageTracker) {
|
||||||
const keysToRemove: string[] = [];
|
if (now - lastUsed < this.#TTL) continue;
|
||||||
|
|
||||||
for (const [key, lastUsed] of this.#usageTracker.entries()) {
|
const font = this.#loadedFonts.get(key);
|
||||||
if (now - lastUsed > this.#TTL) {
|
if (font) document.fonts.delete(font);
|
||||||
const batchId = this.#idToBatch.get(key);
|
|
||||||
if (batchId) {
|
|
||||||
// Check if EVERY font in this batch is expired
|
|
||||||
const batchKeys = Array.from(this.#idToBatch.entries())
|
|
||||||
.filter(([_, bId]) => bId === batchId)
|
|
||||||
.map(([k]) => k);
|
|
||||||
|
|
||||||
const canDeleteBatch = batchKeys.every(k => {
|
this.#loadedFonts.delete(key);
|
||||||
const lastK = this.#usageTracker.get(k);
|
this.#usageTracker.delete(key);
|
||||||
return lastK && (now - lastK > this.#TTL);
|
this.statuses.delete(key);
|
||||||
});
|
this.#retryCounts.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (canDeleteBatch) {
|
/** Returns current loading status for a font, or undefined if never requested. */
|
||||||
batchesToRemove.add(batchId);
|
getFontStatus(id: string, weight: number, isVariable = false) {
|
||||||
keysToRemove.push(...batchKeys);
|
return this.statuses.get(this.#getFontKey(id, weight, isVariable));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/** Waits for all fonts to finish loading using document.fonts.ready. */
|
||||||
|
async ready(): Promise<void> {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
try {
|
||||||
|
await document.fonts.ready;
|
||||||
|
} catch {
|
||||||
|
// document.fonts.ready can reject in some edge cases
|
||||||
|
// (e.g., document unloaded). Silently resolve.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */
|
||||||
|
destroy() {
|
||||||
|
this.#abortController.abort();
|
||||||
|
|
||||||
|
if (this.#timeoutId !== null) {
|
||||||
|
if (this.#pendingType === 'idle' && typeof cancelIdleCallback !== 'undefined') {
|
||||||
|
cancelIdleCallback(this.#timeoutId as unknown as number);
|
||||||
|
} else {
|
||||||
|
clearTimeout(this.#timeoutId);
|
||||||
|
}
|
||||||
|
this.#timeoutId = null;
|
||||||
|
this.#pendingType = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#intervalId) {
|
||||||
|
clearInterval(this.#intervalId);
|
||||||
|
this.#intervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
for (const font of this.#loadedFonts.values()) {
|
||||||
|
document.fonts.delete(font);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
batchesToRemove.forEach(id => {
|
this.#loadedFonts.clear();
|
||||||
this.#batchElements.get(id)?.remove();
|
this.#usageTracker.clear();
|
||||||
this.#batchElements.delete(id);
|
this.#retryCounts.clear();
|
||||||
});
|
this.statuses.clear();
|
||||||
|
this.#queue.clear();
|
||||||
keysToRemove.forEach(k => {
|
|
||||||
this.#idToBatch.delete(k);
|
|
||||||
this.#usageTracker.delete(k);
|
|
||||||
this.statuses.delete(k);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Singleton instance — use throughout the application for unified font loading state. */
|
||||||
export const appliedFontsManager = new AppliedFontsManager();
|
export const appliedFontsManager = new AppliedFontsManager();
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type { UnifiedFont } from '../types';
|
|||||||
|
|
||||||
/** */
|
/** */
|
||||||
export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
||||||
// params = $state<TParams>({} as TParams);
|
|
||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
|
|
||||||
#bindings = $state<(() => Partial<TParams>)[]>([]);
|
#bindings = $state<(() => Partial<TParams>)[]>([]);
|
||||||
@@ -18,9 +17,11 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
params = $derived.by(() => {
|
params = $derived.by(() => {
|
||||||
let merged = { ...this.#internalParams };
|
let merged = { ...this.#internalParams };
|
||||||
|
|
||||||
|
// Loop through every "Cable" plugged into the store
|
||||||
// Loop through every "Cable" plugged into the store
|
// Loop through every "Cable" plugged into the store
|
||||||
for (const getter of this.#bindings) {
|
for (const getter of this.#bindings) {
|
||||||
merged = { ...merged, ...getter() };
|
const bindingResult = getter();
|
||||||
|
merged = { ...merged, ...bindingResult };
|
||||||
}
|
}
|
||||||
|
|
||||||
return merged as TParams;
|
return merged as TParams;
|
||||||
@@ -54,7 +55,7 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
protected abstract getQueryKey(params: TParams): QueryKey;
|
protected abstract getQueryKey(params: TParams): QueryKey;
|
||||||
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
|
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
|
||||||
|
|
||||||
private getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
||||||
return {
|
return {
|
||||||
queryKey: this.getQueryKey(params),
|
queryKey: this.getQueryKey(params),
|
||||||
queryFn: () => this.fetchFn(params),
|
queryFn: () => this.fetchFn(params),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export {
|
|||||||
} from './unifiedFontStore.svelte';
|
} from './unifiedFontStore.svelte';
|
||||||
|
|
||||||
// Applied fonts manager (CSS loading - unchanged)
|
// Applied fonts manager (CSS loading - unchanged)
|
||||||
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
export {
|
||||||
|
appliedFontsManager,
|
||||||
// Selected fonts store (user selection - unchanged)
|
type FontConfigRequest,
|
||||||
export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte';
|
} from './appliedFontsStore/appliedFontsStore.svelte';
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { createEntityStore } from '$shared/lib';
|
|
||||||
import type { UnifiedFont } from '../../types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store that handles collection of selected fonts
|
|
||||||
*/
|
|
||||||
export const selectedFontsStore = createEntityStore<UnifiedFont>([]);
|
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
* - Provider-specific shortcuts for common operations
|
* - Provider-specific shortcuts for common operations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { QueryObserverOptions } from '@tanstack/query-core';
|
||||||
import type { ProxyFontsParams } from '../../api';
|
import type { ProxyFontsParams } from '../../api';
|
||||||
import { fetchProxyFonts } from '../../api';
|
import { fetchProxyFonts } from '../../api';
|
||||||
import type { UnifiedFont } from '../types';
|
import type { UnifiedFont } from '../types';
|
||||||
@@ -121,6 +122,19 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
this.#previousFilterParams = filterParams;
|
this.#previousFilterParams = filterParams;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Effect: Sync state from Query result (Handles Cache Hits)
|
||||||
|
$effect(() => {
|
||||||
|
const data = this.result.data;
|
||||||
|
const offset = this.params.offset || 0;
|
||||||
|
|
||||||
|
// When we have data and we are at the start (offset 0),
|
||||||
|
// we must ensure accumulatedFonts matches the fresh (or cached) data.
|
||||||
|
// This fixes the issue where cache hits skip fetchFn side-effects.
|
||||||
|
if (offset === 0 && data && data.length > 0) {
|
||||||
|
this.#accumulatedFonts = data;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,15 +159,26 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
protected getQueryKey(params: ProxyFontsParams) {
|
protected getQueryKey(params: ProxyFontsParams) {
|
||||||
// Normalize params to treat empty arrays/strings as undefined
|
// Normalize params to treat empty arrays/strings as undefined
|
||||||
const normalized = Object.entries(params).reduce((acc, [key, value]) => {
|
const normalized = Object.entries(params).reduce((acc, [key, value]) => {
|
||||||
if (value === '' || (Array.isArray(value) && value.length === 0)) {
|
if (value === '' || value === undefined || (Array.isArray(value) && value.length === 0)) {
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
return { ...acc, [key]: value };
|
return { ...acc, [key]: value };
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
|
// Return a consistent key
|
||||||
return ['unifiedFonts', normalized] as const;
|
return ['unifiedFonts', normalized] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
||||||
|
const hasFilters = !!(params.q || params.provider || params.category || params.subset);
|
||||||
|
return {
|
||||||
|
queryKey: this.getQueryKey(params),
|
||||||
|
queryFn: () => this.fetchFn(params),
|
||||||
|
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch function that calls the proxy API
|
* Fetch function that calls the proxy API
|
||||||
* Returns the full response including pagination metadata
|
* Returns the full response including pagination metadata
|
||||||
@@ -187,11 +212,9 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Accumulate fonts for infinite scroll
|
// Accumulate fonts for infinite scroll
|
||||||
if (params.offset === 0) {
|
// Note: For offset === 0, we rely on the $effect above to handle the reset/init
|
||||||
// Reset when starting from beginning (new search/filter)
|
// This prevents race conditions and double-setting.
|
||||||
this.#accumulatedFonts = response.fonts;
|
if (params.offset !== 0) {
|
||||||
} else {
|
|
||||||
// Append new fonts to existing ones
|
|
||||||
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,3 +32,27 @@ export interface FontFilters {
|
|||||||
|
|
||||||
export type CheckboxFilter = 'providers' | 'categories' | 'subsets';
|
export type CheckboxFilter = 'providers' | 'categories' | 'subsets';
|
||||||
export type FilterType = CheckboxFilter | 'searchQuery';
|
export type FilterType = CheckboxFilter | 'searchQuery';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard font weights
|
||||||
|
*/
|
||||||
|
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Italic variant format: e.g., "100italic", "400italic", "700italic"
|
||||||
|
*/
|
||||||
|
export type FontWeightItalic = `${FontWeight}italic`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All possible font variants
|
||||||
|
* - Numeric weights: "400", "700", etc.
|
||||||
|
* - Italic variants: "400italic", "700italic", etc.
|
||||||
|
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
||||||
|
*/
|
||||||
|
export type FontVariant =
|
||||||
|
| FontWeight
|
||||||
|
| FontWeightItalic
|
||||||
|
| 'regular'
|
||||||
|
| 'italic'
|
||||||
|
| 'bold'
|
||||||
|
| 'bolditalic';
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
* ============================================================================
|
* ============================================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { FontVariant } from './common';
|
||||||
|
|
||||||
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,30 +88,6 @@ export interface FontItem {
|
|||||||
*/
|
*/
|
||||||
export type GoogleFontItem = FontItem;
|
export type GoogleFontItem = FontItem;
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard font weights that can appear in Google Fonts API
|
|
||||||
*/
|
|
||||||
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Italic variant format: e.g., "100italic", "400italic", "700italic"
|
|
||||||
*/
|
|
||||||
export type FontWeightItalic = `${FontWeight}italic`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All possible font variants in Google Fonts API
|
|
||||||
* - Numeric weights: "400", "700", etc.
|
|
||||||
* - Italic variants: "400italic", "700italic", etc.
|
|
||||||
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
|
||||||
*/
|
|
||||||
export type FontVariant =
|
|
||||||
| FontWeight
|
|
||||||
| FontWeightItalic
|
|
||||||
| 'regular'
|
|
||||||
| 'italic'
|
|
||||||
| 'bold'
|
|
||||||
| 'bolditalic';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Google Fonts API file mapping
|
* Google Fonts API file mapping
|
||||||
* Dynamic keys that match the variants array
|
* Dynamic keys that match the variants array
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ export type {
|
|||||||
FontCategory,
|
FontCategory,
|
||||||
FontProvider,
|
FontProvider,
|
||||||
FontSubset,
|
FontSubset,
|
||||||
|
FontVariant,
|
||||||
|
FontWeight,
|
||||||
|
FontWeightItalic,
|
||||||
} from './common';
|
} from './common';
|
||||||
|
|
||||||
// Google Fonts API types
|
// Google Fonts API types
|
||||||
export type {
|
export type {
|
||||||
FontFiles,
|
FontFiles,
|
||||||
FontItem,
|
FontItem,
|
||||||
FontVariant,
|
|
||||||
FontWeight,
|
|
||||||
FontWeightItalic,
|
|
||||||
GoogleFontItem,
|
GoogleFontItem,
|
||||||
GoogleFontsApiModel,
|
GoogleFontsApiModel,
|
||||||
} from './google';
|
} from './google';
|
||||||
|
|||||||
@@ -8,17 +8,18 @@ import type {
|
|||||||
FontCategory,
|
FontCategory,
|
||||||
FontProvider,
|
FontProvider,
|
||||||
FontSubset,
|
FontSubset,
|
||||||
|
FontVariant,
|
||||||
} from './common';
|
} from './common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font variant types (standardized)
|
* Font variant types (standardized)
|
||||||
*/
|
*/
|
||||||
export type UnifiedFontVariant = string;
|
export type UnifiedFontVariant = FontVariant;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font style URLs
|
* Font style URLs
|
||||||
*/
|
*/
|
||||||
export interface FontStyleUrls {
|
export interface LegacyFontStyleUrls {
|
||||||
/** Regular weight URL */
|
/** Regular weight URL */
|
||||||
regular?: string;
|
regular?: string;
|
||||||
/** Italic URL */
|
/** Italic URL */
|
||||||
@@ -29,6 +30,10 @@ export interface FontStyleUrls {
|
|||||||
boldItalic?: string;
|
boldItalic?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FontStyleUrls extends LegacyFontStyleUrls {
|
||||||
|
variants?: Partial<Record<UnifiedFontVariant, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font metadata
|
* Font metadata
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,26 +2,23 @@
|
|||||||
Component: FontApplicator
|
Component: FontApplicator
|
||||||
Loads fonts from fontshare with link tag
|
Loads fonts from fontshare with link tag
|
||||||
- Loads font only if it's not already applied
|
- Loads font only if it's not already applied
|
||||||
- Uses IntersectionObserver to detect when font is visible
|
- Reacts to font load status to show/hide content
|
||||||
- Adds smooth transition when font appears
|
- Adds smooth transition when font appears
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { prefersReducedMotion } from 'svelte/motion';
|
import { prefersReducedMotion } from 'svelte/motion';
|
||||||
import { appliedFontsManager } from '../../model';
|
import {
|
||||||
|
type UnifiedFont,
|
||||||
|
appliedFontsManager,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Font name to set
|
* Applied font
|
||||||
*/
|
*/
|
||||||
name: string;
|
font: UnifiedFont;
|
||||||
/**
|
|
||||||
* Font id to load
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
url: string;
|
|
||||||
/**
|
/**
|
||||||
* Font weight
|
* Font weight
|
||||||
*/
|
*/
|
||||||
@@ -36,47 +33,43 @@ interface Props {
|
|||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { name, id, url, weight = 400, className, children }: Props = $props();
|
let {
|
||||||
let element: Element;
|
font,
|
||||||
|
weight = 400,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
// Track if the user has actually scrolled this into view
|
const status = $derived(
|
||||||
let hasEnteredViewport = $state(false);
|
appliedFontsManager.getFontStatus(
|
||||||
|
font.id,
|
||||||
|
weight,
|
||||||
|
font.features.isVariable,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
// The "Show" condition: Font is loaded OR it errored out OR it's a noTouch preview (like in search)
|
||||||
const observer = new IntersectionObserver(entries => {
|
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
||||||
if (entries[0].isIntersecting) {
|
|
||||||
hasEnteredViewport = true;
|
|
||||||
appliedFontsManager.touch([{ id, weight, name, url }]);
|
|
||||||
|
|
||||||
// Once it has entered, we can stop observing to save CPU
|
|
||||||
observer.unobserve(element);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
observer.observe(element);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
const status = $derived(appliedFontsManager.getFontStatus(id, weight));
|
|
||||||
// The "Show" condition: Element is in view AND (Font is ready OR it errored out)
|
|
||||||
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error'));
|
|
||||||
|
|
||||||
const transitionClasses = $derived(
|
const transitionClasses = $derived(
|
||||||
prefersReducedMotion.current
|
prefersReducedMotion.current
|
||||||
? 'transition-none' // Disable CSS transitions if motion is reduced
|
? 'transition-none' // Disable CSS transitions if motion is reduced
|
||||||
: 'transition-all duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
: 'transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={element}
|
style:font-family={shouldReveal
|
||||||
style:font-family={name}
|
? `'${font.name}'`
|
||||||
|
: 'system-ui, -apple-system, sans-serif'}
|
||||||
class={cn(
|
class={cn(
|
||||||
transitionClasses,
|
transitionClasses,
|
||||||
// If reduced motion is on, we skip the transform/blur entirely
|
// If reduced motion is on, we skip the transform/blur entirely
|
||||||
!shouldReveal && !prefersReducedMotion.current
|
!shouldReveal
|
||||||
&& 'opacity-0 translate-y-8 scale-[0.98] blur-sm',
|
&& !prefersReducedMotion.current
|
||||||
|
&& 'opacity-50 scale-[0.95] blur-sm',
|
||||||
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
|
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
|
||||||
shouldReveal && 'opacity-100 translate-y-0 scale-100 blur-0',
|
shouldReveal && 'opacity-100 scale-100 blur-0',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
<!--
|
|
||||||
Component: FontListItem
|
|
||||||
Displays a font item and manages its animations
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { Spring } from 'svelte/motion';
|
import { type UnifiedFont } from '../../model';
|
||||||
import {
|
|
||||||
type UnifiedFont,
|
|
||||||
selectedFontsStore,
|
|
||||||
} from '../../model';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -34,52 +26,14 @@ interface Props {
|
|||||||
children: Snippet<[font: UnifiedFont]>;
|
children: Snippet<[font: UnifiedFont]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { font, isFullyVisible, isPartiallyVisible, proximity, children }: Props = $props();
|
const { font, children }: Props = $props();
|
||||||
|
|
||||||
const selected = $derived(selectedFontsStore.has(font.id));
|
|
||||||
let timeoutId = $state<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
// Create a spring for smooth scale animation
|
|
||||||
const scale = new Spring(1, {
|
|
||||||
stiffness: 0.3,
|
|
||||||
damping: 0.7,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Springs react to the virtualizer's computed state
|
|
||||||
const bloom = new Spring(0, {
|
|
||||||
stiffness: 0.15,
|
|
||||||
damping: 0.6,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync spring to proximity for a "Lens" effect
|
|
||||||
$effect(() => {
|
|
||||||
bloom.target = isPartiallyVisible ? 1 : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function animateSelection() {
|
|
||||||
scale.target = 0.98;
|
|
||||||
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
scale.target = 1;
|
|
||||||
}, 150);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn('pb-1 will-change-transform')}
|
class={cn(
|
||||||
style:opacity={bloom.current}
|
'pb-1 will-change-transform transition-transform duration-200 ease-out',
|
||||||
style:transform="
|
'hover:scale-[0.98]', // Simple CSS hover effect
|
||||||
scale({0.92 + (bloom.current * 0.08)})
|
)}
|
||||||
translateY({(1 - bloom.current) * 10}px)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
{@render children?.(font)}
|
{@render children?.(font)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,50 +3,127 @@
|
|||||||
- Renders a virtualized list of fonts
|
- Renders a virtualized list of fonts
|
||||||
- Handles font registration with the manager
|
- Handles font registration with the manager
|
||||||
-->
|
-->
|
||||||
<script lang="ts" generics="T extends UnifiedFont">
|
<script lang="ts">
|
||||||
import type { FontConfigRequest } from '$entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte';
|
|
||||||
import { VirtualList } from '$shared/ui';
|
|
||||||
import type { ComponentProps } from 'svelte';
|
|
||||||
import {
|
import {
|
||||||
|
Skeleton,
|
||||||
|
VirtualList,
|
||||||
|
} from '$shared/ui';
|
||||||
|
import type {
|
||||||
|
ComponentProps,
|
||||||
|
Snippet,
|
||||||
|
} from 'svelte';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import { getFontUrl } from '../../lib';
|
||||||
|
import {
|
||||||
|
type FontConfigRequest,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
|
unifiedFontStore,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> {
|
interface Props extends
|
||||||
onVisibleItemsChange?: (items: T[]) => void;
|
Omit<
|
||||||
onNearBottom?: (lastVisibleIndex: number) => void;
|
ComponentProps<typeof VirtualList<UnifiedFont>>,
|
||||||
|
'items' | 'total' | 'isLoading' | 'onVisibleItemsChange' | 'onNearBottom'
|
||||||
|
>
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Callback for when visible items change
|
||||||
|
*/
|
||||||
|
onVisibleItemsChange?: (items: UnifiedFont[]) => void;
|
||||||
|
/**
|
||||||
|
* Weight of the font
|
||||||
|
*/
|
||||||
weight: number;
|
weight: number;
|
||||||
|
/**
|
||||||
|
* Skeleton snippet
|
||||||
|
*/
|
||||||
|
skeleton?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { items, children, onVisibleItemsChange, onNearBottom, weight, ...rest }: Props = $props();
|
let {
|
||||||
|
children,
|
||||||
|
onVisibleItemsChange,
|
||||||
|
weight,
|
||||||
|
skeleton,
|
||||||
|
...rest
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
function handleInternalVisibleChange(visibleItems: T[]) {
|
const isLoading = $derived(
|
||||||
|
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
||||||
|
const configs: FontConfigRequest[] = [];
|
||||||
|
|
||||||
|
visibleItems.forEach(item => {
|
||||||
|
const url = getFontUrl(item, weight);
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
configs.push({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
weight,
|
||||||
|
url,
|
||||||
|
isVariable: item.features?.isVariable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
// Auto-register fonts with the manager
|
// Auto-register fonts with the manager
|
||||||
const configs = visibleItems.map<FontConfigRequest>(item => ({
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
weight,
|
|
||||||
url: item.styles.regular!,
|
|
||||||
}));
|
|
||||||
appliedFontsManager.touch(configs);
|
appliedFontsManager.touch(configs);
|
||||||
|
|
||||||
// // Forward the call to any external listener
|
// Forward the call to any external listener
|
||||||
// onVisibleItemsChange?.(visibleItems);
|
// onVisibleItemsChange?.(visibleItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNearBottom(lastVisibleIndex: number) {
|
/**
|
||||||
// Forward the call to any external listener
|
* Load more fonts by moving to the next page
|
||||||
onNearBottom?.(lastVisibleIndex);
|
*/
|
||||||
|
function loadMore() {
|
||||||
|
if (
|
||||||
|
!unifiedFontStore.pagination.hasMore
|
||||||
|
|| unifiedFontStore.isFetching
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unifiedFontStore.nextPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle scroll near bottom - auto-load next page
|
||||||
|
*
|
||||||
|
* Triggered by VirtualList when the user scrolls within 5 items of the end
|
||||||
|
* of the loaded items. Only fetches if there are more pages available.
|
||||||
|
*/
|
||||||
|
function handleNearBottom(_lastVisibleIndex: number) {
|
||||||
|
const { hasMore } = unifiedFontStore.pagination;
|
||||||
|
|
||||||
|
// VirtualList already checks if we're near the bottom of loaded items
|
||||||
|
if (hasMore && !unifiedFontStore.isFetching) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<VirtualList
|
<div class="relative w-full h-full">
|
||||||
{items}
|
{#if skeleton && isLoading && unifiedFontStore.fonts.length === 0}
|
||||||
{...rest}
|
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
|
||||||
onVisibleItemsChange={handleInternalVisibleChange}
|
<div transition:fade={{ duration: 300 }}>
|
||||||
onNearBottom={handleNearBottom}
|
{@render skeleton()}
|
||||||
>
|
</div>
|
||||||
{#snippet children(scope)}
|
{:else}
|
||||||
{@render children(scope)}
|
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||||
{/snippet}
|
<VirtualList
|
||||||
</VirtualList>
|
items={unifiedFontStore.fonts}
|
||||||
|
total={unifiedFontStore.pagination.total}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onVisibleItemsChange={handleInternalVisibleChange}
|
||||||
|
onNearBottom={handleNearBottom}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{#snippet children(scope)}
|
||||||
|
{@render children(scope)}
|
||||||
|
{/snippet}
|
||||||
|
</VirtualList>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
import {
|
import {
|
||||||
FontApplicator,
|
FontApplicator,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
selectedFontsStore,
|
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import { controlManager } from '$features/SetupFont';
|
import { controlManager } from '$features/SetupFont';
|
||||||
import {
|
import {
|
||||||
ContentEditable,
|
ContentEditable,
|
||||||
IconButton,
|
Footnote,
|
||||||
|
// IconButton,
|
||||||
} from '$shared/ui';
|
} from '$shared/ui';
|
||||||
import XIcon from '@lucide/svelte/icons/x';
|
// import XIcon from '@lucide/svelte/icons/x';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -36,83 +36,75 @@ interface Props {
|
|||||||
letterSpacing?: number;
|
letterSpacing?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { font, text = $bindable(), index = 0, ...restProps }: Props = $props();
|
||||||
font,
|
|
||||||
text = $bindable(),
|
|
||||||
index = 0,
|
|
||||||
...restProps
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const fontWeight = $derived(controlManager.weight);
|
const fontWeight = $derived(controlManager.weight);
|
||||||
const fontSize = $derived(controlManager.size);
|
const fontSize = $derived(controlManager.renderedSize);
|
||||||
const lineHeight = $derived(controlManager.height);
|
const lineHeight = $derived(controlManager.height);
|
||||||
const letterSpacing = $derived(controlManager.spacing);
|
const letterSpacing = $derived(controlManager.spacing);
|
||||||
|
|
||||||
function removeSample() {
|
|
||||||
selectedFontsStore.removeOne(font.id);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
w-full h-full rounded-2xl
|
w-full h-full rounded-xl sm:rounded-2xl
|
||||||
flex flex-col
|
flex flex-col
|
||||||
backdrop-blur-md bg-white/80
|
bg-background-80
|
||||||
border border-gray-300/50
|
border border-border-muted
|
||||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||||
relative overflow-hidden
|
relative overflow-hidden
|
||||||
"
|
"
|
||||||
style:font-weight={fontWeight}
|
style:font-weight={fontWeight}
|
||||||
>
|
>
|
||||||
<div class="px-6 py-3 border-b border-gray-200/60 flex items-center justify-between">
|
<div class="px-4 sm:px-5 md:px-6 py-2.5 sm:py-3 border-b border-border-subtle flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2 sm:gap-2.5">
|
||||||
<span class="font-mono text-[9px] uppercase tracking-[0.25em] text-gray-500 font-medium">
|
<Footnote>
|
||||||
typeface_{String(index).padStart(3, '0')}
|
typeface_{String(index).padStart(3, '0')}
|
||||||
</span>
|
</Footnote>
|
||||||
<div class="w-px h-2.5 bg-gray-300/60"></div>
|
<div class="w-px h-2 sm:h-2.5 bg-border-subtle"></div>
|
||||||
<span class="font-mono text-[10px] tracking-[0.15em] font-bold uppercase text-gray-900">
|
<div class="font-bold text-foreground">
|
||||||
{font.name}
|
{font.name}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IconButton
|
<!--
|
||||||
onclick={removeSample}
|
<IconButton
|
||||||
class="w-5 h-5 rounded-full hover:bg-transparent flex items-center justify-center transition-colors group translate-x-1/2 cursor-pointer"
|
onclick={removeSample}
|
||||||
>
|
class="w-5 h-5 rounded-full hover:bg-transparent flex items-center justify-center transition-colors group translate-x-1/2 cursor-pointer"
|
||||||
{#snippet icon({ className })}
|
>
|
||||||
<XIcon class={className} />
|
{#snippet icon({ className })}
|
||||||
{/snippet}
|
<XIcon class={className} />
|
||||||
</IconButton>
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
-->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-8 relative z-10">
|
<div class="p-4 sm:p-5 md:p-8 relative z-10">
|
||||||
<!-- TODO: Fix this ! -->
|
<FontApplicator {font} weight={fontWeight}>
|
||||||
<FontApplicator id={font.id} name={font.name} url={font.styles.regular!}>
|
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
bind:text={text}
|
bind:text
|
||||||
{...restProps}
|
{...restProps}
|
||||||
fontSize={fontSize}
|
{fontSize}
|
||||||
lineHeight={lineHeight}
|
{lineHeight}
|
||||||
letterSpacing={letterSpacing}
|
{letterSpacing}
|
||||||
/>
|
/>
|
||||||
</FontApplicator>
|
</FontApplicator>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-6 py-2 border-t border-gray-200/40 w-full flex gap-4 bg-gray-50/30 mt-auto">
|
<div class="px-4 sm:px-5 md:px-6 py-1.5 sm:py-2 border-t border-border-subtle w-full flex flex-row gap-2 sm:gap-4 bg-background mt-auto">
|
||||||
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider ml-auto">
|
<Footnote class="text-[7px] sm:text-[8px] tracking-wider ml-auto">
|
||||||
SZ:{fontSize}PX
|
SZ:{fontSize}PX
|
||||||
</span>
|
</Footnote>
|
||||||
<div class="w-px h-2.5 self-center bg-gray-300/40"></div>
|
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||||
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider">
|
<Footnote class="text-[7px] sm:text-[8px] tracking-wider">
|
||||||
WGT:{fontWeight}
|
WGT:{fontWeight}
|
||||||
</span>
|
</Footnote>
|
||||||
<div class="w-px h-2.5 self-center bg-gray-300/40"></div>
|
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||||
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider">
|
<Footnote class="text-[7px] sm:text-[8px] tracking-wider">
|
||||||
LH:{lineHeight?.toFixed(2)}
|
LH:{lineHeight?.toFixed(2)}
|
||||||
</span>
|
</Footnote>
|
||||||
<div class="w-px h-2.5 self-center bg-gray-300/40"></div>
|
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||||
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider">
|
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider">
|
||||||
LTR:{letterSpacing}
|
LTR:{letterSpacing}
|
||||||
</span>
|
</Footnote>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import SetupFontMenu from './ui/SetupFontMenu.svelte';
|
export { TypographyMenu } from './ui';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
type ControlId,
|
||||||
controlManager,
|
controlManager,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
DEFAULT_LETTER_SPACING,
|
DEFAULT_LETTER_SPACING,
|
||||||
DEFAULT_LINE_HEIGHT,
|
DEFAULT_LINE_HEIGHT,
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
FONT_SIZE_STEP,
|
FONT_SIZE_STEP,
|
||||||
FONT_WEIGHT_STEP,
|
FONT_WEIGHT_STEP,
|
||||||
LINE_HEIGHT_STEP,
|
LINE_HEIGHT_STEP,
|
||||||
@@ -15,5 +17,12 @@ export {
|
|||||||
MIN_FONT_SIZE,
|
MIN_FONT_SIZE,
|
||||||
MIN_FONT_WEIGHT,
|
MIN_FONT_WEIGHT,
|
||||||
MIN_LINE_HEIGHT,
|
MIN_LINE_HEIGHT,
|
||||||
|
MULTIPLIER_L,
|
||||||
|
MULTIPLIER_M,
|
||||||
|
MULTIPLIER_S,
|
||||||
} from './model';
|
} from './model';
|
||||||
export { SetupFontMenu };
|
|
||||||
|
export {
|
||||||
|
createTypographyControlManager,
|
||||||
|
type TypographyControlManager,
|
||||||
|
} from './lib';
|
||||||
|
|||||||
@@ -1,60 +1,215 @@
|
|||||||
import {
|
import {
|
||||||
|
type ControlDataModel,
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
|
type PersistentStore,
|
||||||
type TypographyControl,
|
type TypographyControl,
|
||||||
|
createPersistentStore,
|
||||||
createTypographyControl,
|
createTypographyControl,
|
||||||
} from '$shared/lib';
|
} from '$shared/lib';
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
import {
|
||||||
|
type ControlId,
|
||||||
|
DEFAULT_FONT_SIZE,
|
||||||
|
DEFAULT_FONT_WEIGHT,
|
||||||
|
DEFAULT_LETTER_SPACING,
|
||||||
|
DEFAULT_LINE_HEIGHT,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
export interface Control {
|
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
||||||
id: string;
|
|
||||||
increaseLabel?: string;
|
export interface Control extends ControlOnlyFields<ControlId> {
|
||||||
decreaseLabel?: string;
|
|
||||||
controlLabel?: string;
|
|
||||||
instance: TypographyControl;
|
instance: TypographyControl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TypographyControlManager {
|
export class TypographyControlManager {
|
||||||
#controls = new SvelteMap<string, Control>();
|
#controls = new SvelteMap<string, Control>();
|
||||||
|
#multiplier = $state(1);
|
||||||
|
#storage: PersistentStore<TypographySettings>;
|
||||||
|
#baseSize = $state(DEFAULT_FONT_SIZE);
|
||||||
|
|
||||||
constructor(configs: ControlModel[]) {
|
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
||||||
configs.forEach(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => {
|
this.#storage = storage;
|
||||||
this.#controls.set(id, {
|
|
||||||
id,
|
// Initial Load
|
||||||
increaseLabel,
|
const saved = storage.value;
|
||||||
decreaseLabel,
|
this.#baseSize = saved.fontSize;
|
||||||
controlLabel,
|
|
||||||
instance: createTypographyControl(config),
|
// Setup Controls
|
||||||
|
configs.forEach(config => {
|
||||||
|
const initialValue = this.#getInitialValue(config.id, saved);
|
||||||
|
|
||||||
|
this.#controls.set(config.id, {
|
||||||
|
...config,
|
||||||
|
instance: createTypographyControl({
|
||||||
|
...config,
|
||||||
|
value: initialValue,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The Sync Effect (UI -> Storage)
|
||||||
|
// We access .value explicitly to ensure Svelte 5 tracks the dependency
|
||||||
|
$effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
// EXPLICIT DEPENDENCIES: Accessing these triggers the effect
|
||||||
|
const fontSize = this.#baseSize;
|
||||||
|
const fontWeight = this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
|
||||||
|
const lineHeight = this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
|
||||||
|
const letterSpacing = this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
|
||||||
|
|
||||||
|
// Syncing back to storage
|
||||||
|
this.#storage.value = {
|
||||||
|
fontSize,
|
||||||
|
fontWeight,
|
||||||
|
lineHeight,
|
||||||
|
letterSpacing,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// The Font Size Proxy Effect
|
||||||
|
// This handles the "Multiplier" logic specifically for the Font Size Control
|
||||||
|
$effect(() => {
|
||||||
|
const ctrl = this.#controls.get('font_size')?.instance;
|
||||||
|
if (!ctrl) return;
|
||||||
|
|
||||||
|
// If the user moves the slider/clicks buttons in the UI:
|
||||||
|
// We update the baseSize (User Intent)
|
||||||
|
const currentDisplayValue = ctrl.value;
|
||||||
|
const calculatedBase = currentDisplayValue / this.#multiplier;
|
||||||
|
|
||||||
|
// Only update if the difference is significant (prevents rounding jitter)
|
||||||
|
if (Math.abs(this.#baseSize - calculatedBase) > 0.01) {
|
||||||
|
this.#baseSize = calculatedBase;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#getInitialValue(id: string, saved: TypographySettings): number {
|
||||||
|
if (id === 'font_size') return saved.fontSize * this.#multiplier;
|
||||||
|
if (id === 'font_weight') return saved.fontWeight;
|
||||||
|
if (id === 'line_height') return saved.lineHeight;
|
||||||
|
if (id === 'letter_spacing') return saved.letterSpacing;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Getters / Setters ---
|
||||||
|
|
||||||
|
get multiplier() {
|
||||||
|
return this.#multiplier;
|
||||||
|
}
|
||||||
|
set multiplier(value: number) {
|
||||||
|
if (this.#multiplier === value) return;
|
||||||
|
this.#multiplier = value;
|
||||||
|
|
||||||
|
// When multiplier changes, we must update the Font Size Control's display value
|
||||||
|
const ctrl = this.#controls.get('font_size')?.instance;
|
||||||
|
if (ctrl) {
|
||||||
|
ctrl.value = this.#baseSize * this.#multiplier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The scaled size for CSS usage */
|
||||||
|
get renderedSize() {
|
||||||
|
return this.#baseSize * this.#multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The base size (User Preference) */
|
||||||
|
get baseSize() {
|
||||||
|
return this.#baseSize;
|
||||||
|
}
|
||||||
|
set baseSize(val: number) {
|
||||||
|
this.#baseSize = val;
|
||||||
|
const ctrl = this.#controls.get('font_size')?.instance;
|
||||||
|
if (ctrl) ctrl.value = val * this.#multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getters for controls
|
||||||
|
*/
|
||||||
get controls() {
|
get controls() {
|
||||||
return this.#controls.values();
|
return Array.from(this.#controls.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get weightControl() {
|
||||||
|
return this.#controls.get('font_weight')?.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
get sizeControl() {
|
||||||
|
return this.#controls.get('font_size')?.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
get heightControl() {
|
||||||
|
return this.#controls.get('line_height')?.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
get spacingControl() {
|
||||||
|
return this.#controls.get('letter_spacing')?.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getters for values (besides font-size)
|
||||||
|
*/
|
||||||
get weight() {
|
get weight() {
|
||||||
return this.#controls.get('font_weight')?.instance.value ?? 400;
|
return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
|
||||||
}
|
|
||||||
|
|
||||||
get size() {
|
|
||||||
return this.#controls.get('font_size')?.instance.value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get height() {
|
get height() {
|
||||||
return this.#controls.get('line_height')?.instance.value;
|
return this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
get spacing() {
|
get spacing() {
|
||||||
return this.#controls.get('letter_spacing')?.instance.value;
|
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.#storage.clear();
|
||||||
|
const defaults = this.#storage.value;
|
||||||
|
|
||||||
|
this.#baseSize = defaults.fontSize;
|
||||||
|
|
||||||
|
// Reset all control instances
|
||||||
|
this.#controls.forEach(c => {
|
||||||
|
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;
|
||||||
|
if (c.id === 'line_height') c.instance.value = defaults.lineHeight;
|
||||||
|
if (c.id === 'letter_spacing') c.instance.value = defaults.letterSpacing;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage schema for typography settings
|
||||||
|
*/
|
||||||
|
export interface TypographySettings {
|
||||||
|
fontSize: number;
|
||||||
|
fontWeight: number;
|
||||||
|
lineHeight: number;
|
||||||
|
letterSpacing: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a typography control manager that handles a collection of typography controls.
|
* Creates a typography control manager that handles a collection of typography controls.
|
||||||
*
|
*
|
||||||
* @param configs - Array of control configurations.
|
* @param configs - Array of control configurations.
|
||||||
|
* @param storageId - Persistent storage identifier.
|
||||||
* @returns - Typography control manager instance.
|
* @returns - Typography control manager instance.
|
||||||
*/
|
*/
|
||||||
export function createTypographyControlManager(configs: ControlModel[]) {
|
export function createTypographyControlManager(
|
||||||
return new TypographyControlManager(configs);
|
configs: ControlModel<ControlId>[],
|
||||||
|
storageId: string = 'glyphdiff:typography',
|
||||||
|
) {
|
||||||
|
const storage = createPersistentStore<TypographySettings>(storageId, {
|
||||||
|
fontSize: DEFAULT_FONT_SIZE,
|
||||||
|
fontWeight: DEFAULT_FONT_WEIGHT,
|
||||||
|
lineHeight: DEFAULT_LINE_HEIGHT,
|
||||||
|
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||||
|
});
|
||||||
|
return new TypographyControlManager(configs, storage);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
export { createTypographyControlManager } from './controlManager/controlManager.svelte';
|
export {
|
||||||
|
createTypographyControlManager,
|
||||||
|
type TypographyControlManager,
|
||||||
|
} from './controlManager/controlManager.svelte';
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { ControlModel } from '$shared/lib';
|
||||||
|
import type { ControlId } from '..';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font size constants
|
* Font size constants
|
||||||
*/
|
*/
|
||||||
@@ -29,3 +32,57 @@ export const DEFAULT_LETTER_SPACING = 0;
|
|||||||
export const MIN_LETTER_SPACING = -0.1;
|
export const MIN_LETTER_SPACING = -0.1;
|
||||||
export const MAX_LETTER_SPACING = 0.5;
|
export const MAX_LETTER_SPACING = 0.5;
|
||||||
export const LETTER_SPACING_STEP = 0.01;
|
export const LETTER_SPACING_STEP = 0.01;
|
||||||
|
|
||||||
|
export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
||||||
|
{
|
||||||
|
id: 'font_size',
|
||||||
|
value: DEFAULT_FONT_SIZE,
|
||||||
|
max: MAX_FONT_SIZE,
|
||||||
|
min: MIN_FONT_SIZE,
|
||||||
|
step: FONT_SIZE_STEP,
|
||||||
|
|
||||||
|
increaseLabel: 'Increase Font Size',
|
||||||
|
decreaseLabel: 'Decrease Font Size',
|
||||||
|
controlLabel: 'Font Size',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'font_weight',
|
||||||
|
value: DEFAULT_FONT_WEIGHT,
|
||||||
|
max: MAX_FONT_WEIGHT,
|
||||||
|
min: MIN_FONT_WEIGHT,
|
||||||
|
step: FONT_WEIGHT_STEP,
|
||||||
|
|
||||||
|
increaseLabel: 'Increase Font Weight',
|
||||||
|
decreaseLabel: 'Decrease Font Weight',
|
||||||
|
controlLabel: 'Font Weight',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'line_height',
|
||||||
|
value: DEFAULT_LINE_HEIGHT,
|
||||||
|
max: MAX_LINE_HEIGHT,
|
||||||
|
min: MIN_LINE_HEIGHT,
|
||||||
|
step: LINE_HEIGHT_STEP,
|
||||||
|
|
||||||
|
increaseLabel: 'Increase Line Height',
|
||||||
|
decreaseLabel: 'Decrease Line Height',
|
||||||
|
controlLabel: 'Line Height',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'letter_spacing',
|
||||||
|
value: DEFAULT_LETTER_SPACING,
|
||||||
|
max: MAX_LETTER_SPACING,
|
||||||
|
min: MIN_LETTER_SPACING,
|
||||||
|
step: LETTER_SPACING_STEP,
|
||||||
|
|
||||||
|
increaseLabel: 'Increase Letter Spacing',
|
||||||
|
decreaseLabel: 'Decrease Letter Spacing',
|
||||||
|
controlLabel: 'Letter Spacing',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font size multipliers
|
||||||
|
*/
|
||||||
|
export const MULTIPLIER_S = 0.5;
|
||||||
|
export const MULTIPLIER_M = 0.75;
|
||||||
|
export const MULTIPLIER_L = 1;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export {
|
|||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
DEFAULT_LETTER_SPACING,
|
DEFAULT_LETTER_SPACING,
|
||||||
DEFAULT_LINE_HEIGHT,
|
DEFAULT_LINE_HEIGHT,
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
FONT_SIZE_STEP,
|
FONT_SIZE_STEP,
|
||||||
FONT_WEIGHT_STEP,
|
FONT_WEIGHT_STEP,
|
||||||
LINE_HEIGHT_STEP,
|
LINE_HEIGHT_STEP,
|
||||||
@@ -12,6 +13,12 @@ export {
|
|||||||
MIN_FONT_SIZE,
|
MIN_FONT_SIZE,
|
||||||
MIN_FONT_WEIGHT,
|
MIN_FONT_WEIGHT,
|
||||||
MIN_LINE_HEIGHT,
|
MIN_LINE_HEIGHT,
|
||||||
|
MULTIPLIER_L,
|
||||||
|
MULTIPLIER_M,
|
||||||
|
MULTIPLIER_S,
|
||||||
} from './const/const';
|
} from './const/const';
|
||||||
|
|
||||||
export { controlManager } from './state/manager.svelte';
|
export {
|
||||||
|
type ControlId,
|
||||||
|
controlManager,
|
||||||
|
} from './state/manager.svelte';
|
||||||
|
|||||||
@@ -1,69 +1,6 @@
|
|||||||
import type { ControlModel } from '$shared/lib';
|
|
||||||
import { createTypographyControlManager } from '../../lib';
|
import { createTypographyControlManager } from '../../lib';
|
||||||
import {
|
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../const/const';
|
||||||
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,
|
|
||||||
} from '../const/const';
|
|
||||||
|
|
||||||
const controlData: ControlModel[] = [
|
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
|
||||||
{
|
|
||||||
id: 'font_size',
|
|
||||||
value: DEFAULT_FONT_SIZE,
|
|
||||||
max: MAX_FONT_SIZE,
|
|
||||||
min: MIN_FONT_SIZE,
|
|
||||||
step: FONT_SIZE_STEP,
|
|
||||||
|
|
||||||
increaseLabel: 'Increase Font Size',
|
export const controlManager = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA);
|
||||||
decreaseLabel: 'Decrease Font Size',
|
|
||||||
controlLabel: 'Font Size',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'font_weight',
|
|
||||||
value: DEFAULT_FONT_WEIGHT,
|
|
||||||
max: MAX_FONT_WEIGHT,
|
|
||||||
min: MIN_FONT_WEIGHT,
|
|
||||||
step: FONT_WEIGHT_STEP,
|
|
||||||
|
|
||||||
increaseLabel: 'Increase Font Weight',
|
|
||||||
decreaseLabel: 'Decrease Font Weight',
|
|
||||||
controlLabel: 'Font Weight',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'line_height',
|
|
||||||
value: DEFAULT_LINE_HEIGHT,
|
|
||||||
max: MAX_LINE_HEIGHT,
|
|
||||||
min: MIN_LINE_HEIGHT,
|
|
||||||
step: LINE_HEIGHT_STEP,
|
|
||||||
|
|
||||||
increaseLabel: 'Increase Line Height',
|
|
||||||
decreaseLabel: 'Decrease Line Height',
|
|
||||||
controlLabel: 'Line Height',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'letter_spacing',
|
|
||||||
value: DEFAULT_LETTER_SPACING,
|
|
||||||
max: MAX_LETTER_SPACING,
|
|
||||||
min: MIN_LETTER_SPACING,
|
|
||||||
step: LETTER_SPACING_STEP,
|
|
||||||
|
|
||||||
increaseLabel: 'Increase Letter Spacing',
|
|
||||||
decreaseLabel: 'Decrease Letter Spacing',
|
|
||||||
controlLabel: 'Letter Spacing',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const controlManager = createTypographyControlManager(controlData);
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: SetupFontMenu
|
|
||||||
Contains controls for setting up font properties.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { ComboControl } from '$shared/ui';
|
|
||||||
import { controlManager } from '../model';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="py-2 px-10 flex flex-row items-center gap-2">
|
|
||||||
<div class="flex flex-row gap-3">
|
|
||||||
{#each controlManager.controls as control (control.id)}
|
|
||||||
<ComboControl
|
|
||||||
control={control.instance}
|
|
||||||
increaseLabel={control.increaseLabel}
|
|
||||||
decreaseLabel={control.decreaseLabel}
|
|
||||||
controlLabel={control.controlLabel}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
134
src/features/SetupFont/ui/TypographyMenu.svelte
Normal file
134
src/features/SetupFont/ui/TypographyMenu.svelte
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<!--
|
||||||
|
Component: TypographyMenu
|
||||||
|
Provides a menu for selecting and configuring typography settings
|
||||||
|
- On mobile the menu is displayed as a drawer
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
|
import {
|
||||||
|
Content as ItemContent,
|
||||||
|
Root as ItemRoot,
|
||||||
|
} from '$shared/shadcn/ui/item';
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import {
|
||||||
|
ComboControlV2,
|
||||||
|
Drawer,
|
||||||
|
IconButton,
|
||||||
|
} from '$shared/ui';
|
||||||
|
import { Label } from '$shared/ui';
|
||||||
|
import SlidersIcon from '@lucide/svelte/icons/sliders-vertical';
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
|
import { crossfade } from 'svelte/transition';
|
||||||
|
import {
|
||||||
|
MULTIPLIER_L,
|
||||||
|
MULTIPLIER_M,
|
||||||
|
MULTIPLIER_S,
|
||||||
|
controlManager,
|
||||||
|
} from '../model';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { class: className, hidden = false }: Props = $props();
|
||||||
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
|
const [send, receive] = crossfade({
|
||||||
|
duration: 300,
|
||||||
|
easing: cubicOut,
|
||||||
|
fallback(node, params) {
|
||||||
|
// If it can't find a pair, it falls back to a simple fade/slide
|
||||||
|
return {
|
||||||
|
duration: 300,
|
||||||
|
css: t => `opacity: ${t}; transform: translateY(${(1 - t) * 10}px);`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the common font size multiplier based on the current responsive state.
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
if (!responsive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (true) {
|
||||||
|
case responsive.isMobile:
|
||||||
|
controlManager.multiplier = MULTIPLIER_S;
|
||||||
|
break;
|
||||||
|
case responsive.isTablet:
|
||||||
|
controlManager.multiplier = MULTIPLIER_M;
|
||||||
|
break;
|
||||||
|
case responsive.isDesktop:
|
||||||
|
controlManager.multiplier = MULTIPLIER_L;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
controlManager.multiplier = MULTIPLIER_L;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'w-auto max-screen z-10 flex justify-center',
|
||||||
|
hidden && 'hidden',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
in:receive={{ key: 'panel' }}
|
||||||
|
out:send={{ key: 'panel' }}
|
||||||
|
>
|
||||||
|
{#if responsive.isMobile}
|
||||||
|
<Drawer>
|
||||||
|
{#snippet trigger({ onClick })}
|
||||||
|
<IconButton onclick={onClick}>
|
||||||
|
{#snippet icon({ className })}
|
||||||
|
<SlidersIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet content({ className })}
|
||||||
|
<Label
|
||||||
|
class="mt-6 mb-12 px-2"
|
||||||
|
text="Typography Controls"
|
||||||
|
align="center"
|
||||||
|
/>
|
||||||
|
<div class={cn(className, 'flex flex-col gap-8')}>
|
||||||
|
{#each controlManager.controls as control (control.id)}
|
||||||
|
<ComboControlV2
|
||||||
|
control={control.instance}
|
||||||
|
orientation="horizontal"
|
||||||
|
label={control.controlLabel}
|
||||||
|
reduced
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Drawer>
|
||||||
|
{:else}
|
||||||
|
<ItemRoot
|
||||||
|
variant="outline"
|
||||||
|
class="w-full sm:w-auto max-w-full sm:max-w-max p-2 sm:p-2.5 rounded-xl sm:rounded-2xl backdrop-blur-lg"
|
||||||
|
>
|
||||||
|
<ItemContent class="flex flex-row justify-center items-center max-w-full sm:max-w-max">
|
||||||
|
<div class="sm:py-2 sm:px-10 flex flex-row items-center gap-2">
|
||||||
|
<div class="flex flex-row gap-3">
|
||||||
|
{#each controlManager.controls as control (control.id)}
|
||||||
|
<ComboControlV2
|
||||||
|
control={control.instance}
|
||||||
|
increaseLabel={control.increaseLabel}
|
||||||
|
decreaseLabel={control.decreaseLabel}
|
||||||
|
controlLabel={control.controlLabel}
|
||||||
|
orientation="vertical"
|
||||||
|
showScale={false}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ItemContent>
|
||||||
|
</ItemRoot>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
1
src/features/SetupFont/ui/index.ts
Normal file
1
src/features/SetupFont/ui/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as TypographyMenu } from './TypographyMenu.svelte';
|
||||||
@@ -3,24 +3,40 @@
|
|||||||
Description: The main page component of the application.
|
Description: The main page component of the application.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { BreadcrumbHeader } from '$entities/Breadcrumb';
|
|
||||||
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||||
import { Section } from '$shared/ui';
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import {
|
||||||
|
Logo,
|
||||||
|
Section,
|
||||||
|
} from '$shared/ui';
|
||||||
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
|
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
|
||||||
import { FontSearch } from '$widgets/FontSearch';
|
import { FontSearch } from '$widgets/FontSearch';
|
||||||
import { SampleList } from '$widgets/SampleList';
|
import { SampleList } from '$widgets/SampleList';
|
||||||
|
import CodeIcon from '@lucide/svelte/icons/code';
|
||||||
|
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||||
import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle';
|
import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle';
|
||||||
import ScanEyeIcon from '@lucide/svelte/icons/scan-eye';
|
|
||||||
import ScanSearchIcon from '@lucide/svelte/icons/search';
|
import ScanSearchIcon from '@lucide/svelte/icons/search';
|
||||||
import type { Snippet } from 'svelte';
|
import {
|
||||||
|
type Snippet,
|
||||||
|
getContext,
|
||||||
|
} from 'svelte';
|
||||||
|
import { cubicIn } from 'svelte/easing';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
let searchContainer: HTMLElement;
|
let searchContainer: HTMLElement;
|
||||||
|
|
||||||
let isExpanded = $state(false);
|
let isExpanded = $state(true);
|
||||||
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippet<[{ className?: string }]>) {
|
function handleTitleStatusChanged(
|
||||||
|
index: number,
|
||||||
|
isPast: boolean,
|
||||||
|
title?: Snippet<[{ className?: string }]>,
|
||||||
|
id?: string,
|
||||||
|
) {
|
||||||
if (isPast && title) {
|
if (isPast && title) {
|
||||||
scrollBreadcrumbsStore.add({ index, title });
|
scrollBreadcrumbsStore.add({ index, title, id });
|
||||||
} else {
|
} else {
|
||||||
scrollBreadcrumbsStore.remove(index);
|
scrollBreadcrumbsStore.remove(index);
|
||||||
}
|
}
|
||||||
@@ -29,34 +45,61 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
|
|||||||
scrollBreadcrumbsStore.remove(index);
|
scrollBreadcrumbsStore.remove(index);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// $effect(() => {
|
|
||||||
// appliedFontsManager.touch(
|
|
||||||
// selectedFontsStore.all.map(font => ({
|
|
||||||
// slug: font.id,
|
|
||||||
// weight: controlManager.weight,
|
|
||||||
// })),
|
|
||||||
// );
|
|
||||||
// });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<BreadcrumbHeader />
|
|
||||||
|
|
||||||
<!-- Font List -->
|
<!-- Font List -->
|
||||||
<div class="p-2 h-full flex flex-col gap-3">
|
<div
|
||||||
<Section class="my-12 gap-8" index={0} onTitleStatusChange={handleTitleStatusChanged}>
|
class="p-2 sm:p-3 md:p-4 h-full grid gap-3 sm:gap-4 grid-cols-[max-content_1fr]"
|
||||||
|
in:fade={{ duration: 500, delay: 150, easing: cubicIn }}
|
||||||
|
>
|
||||||
|
<Section
|
||||||
|
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-8"
|
||||||
|
onTitleStatusChange={handleTitleStatusChanged}
|
||||||
|
>
|
||||||
{#snippet icon({ className })}
|
{#snippet icon({ className })}
|
||||||
<ScanEyeIcon class={className} />
|
<CodeIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet description({ className })}
|
||||||
|
<span class={className}> Project_Codename </span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet content({ className })}
|
||||||
|
<div class={cn(className, 'col-start-0 col-span-2')}>
|
||||||
|
<Logo />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||||
|
index={1}
|
||||||
|
id="optical_comparator"
|
||||||
|
onTitleStatusChange={handleTitleStatusChanged}
|
||||||
|
stickyTitle={responsive.isDesktopLarge}
|
||||||
|
stickyOffset="4rem"
|
||||||
|
>
|
||||||
|
{#snippet icon({ className })}
|
||||||
|
<EyeIcon class={className} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet title({ className })}
|
{#snippet title({ className })}
|
||||||
<h1 class={className}>
|
<h1 class={className}>
|
||||||
Optical<br />Comparator
|
Optical<br />Comparator
|
||||||
</h1>
|
</h1>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<ComparisonSlider />
|
{#snippet content({ className })}
|
||||||
|
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||||
|
<ComparisonSlider />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section class="my-12 gap-8" index={1} onTitleStatusChange={handleTitleStatusChanged}>
|
<Section
|
||||||
|
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||||
|
index={2}
|
||||||
|
id="query_module"
|
||||||
|
onTitleStatusChange={handleTitleStatusChanged}
|
||||||
|
stickyTitle={responsive.isDesktopLarge}
|
||||||
|
stickyOffset="4rem"
|
||||||
|
>
|
||||||
{#snippet icon({ className })}
|
{#snippet icon({ className })}
|
||||||
<ScanSearchIcon class={className} />
|
<ScanSearchIcon class={className} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -65,10 +108,21 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
|
|||||||
Query<br />Module
|
Query<br />Module
|
||||||
</h2>
|
</h2>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<FontSearch bind:showFilters={isExpanded} />
|
{#snippet content({ className })}
|
||||||
|
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||||
|
<FontSearch bind:showFilters={isExpanded} />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section class="my-12 gap-8" index={2} onTitleStatusChange={handleTitleStatusChanged}>
|
<Section
|
||||||
|
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||||
|
index={3}
|
||||||
|
id="sample_set"
|
||||||
|
onTitleStatusChange={handleTitleStatusChanged}
|
||||||
|
stickyTitle={responsive.isDesktopLarge}
|
||||||
|
stickyOffset="4rem"
|
||||||
|
>
|
||||||
{#snippet icon({ className })}
|
{#snippet icon({ className })}
|
||||||
<LineSquiggleIcon class={className} />
|
<LineSquiggleIcon class={className} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -77,18 +131,22 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
|
|||||||
Sample<br />Set
|
Sample<br />Set
|
||||||
</h2>
|
</h2>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<SampleList />
|
{#snippet content({ className })}
|
||||||
|
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||||
|
<SampleList />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.content {
|
.content {
|
||||||
/* Tells the browser to skip rendering off-screen content */
|
/* Tells the browser to skip rendering off-screen content */
|
||||||
content-visibility: auto;
|
content-visibility: auto;
|
||||||
/* Helps the browser reserve space without calculating everything */
|
/* Helps the browser reserve space without calculating everything */
|
||||||
contain-intrinsic-size: 1px 1000px;
|
contain-intrinsic-size: 1px 1000px;
|
||||||
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
4
src/shared/assets/GD.svg
Normal file
4
src/shared/assets/GD.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="52" height="35" viewBox="0 0 52 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10.608 34.368C8.496 34.368 6.64 33.968 5.04 33.168C3.44 32.336 2.192 31.184 1.296 29.712C0.432 28.208 0 26.48 0 24.528V9.84C0 7.888 0.432 6.176 1.296 4.704C2.192 3.2 3.44 2.048 5.04 1.248C6.64 0.415999 8.496 0 10.608 0C12.688 0 14.528 0.415999 16.128 1.248C17.728 2.048 18.96 3.2 19.824 4.704C20.688 6.176 21.12 7.872 21.12 9.792V10.512C21.12 10.832 20.96 10.992 20.64 10.992H20.16C19.84 10.992 19.68 10.832 19.68 10.512V9.744C19.68 7.216 18.848 5.184 17.184 3.648C15.52 2.112 13.328 1.344 10.608 1.344C7.856 1.344 5.632 2.128 3.936 3.696C2.272 5.232 1.44 7.264 1.44 9.792V24.576C1.44 27.104 2.272 29.152 3.936 30.72C5.632 32.256 7.856 33.024 10.608 33.024C13.328 33.024 15.52 32.272 17.184 30.768C18.848 29.232 19.68 27.2 19.68 24.672V19.152C19.68 19.024 19.616 18.96 19.488 18.96H11.472C11.152 18.96 10.992 18.8 10.992 18.48V18.144C10.992 17.824 11.152 17.664 11.472 17.664H20.64C20.96 17.664 21.12 17.824 21.12 18.144V24.48C21.12 26.464 20.688 28.208 19.824 29.712C18.96 31.184 17.728 32.336 16.128 33.168C14.528 33.968 12.688 34.368 10.608 34.368Z" fill="white"/>
|
||||||
|
<path d="M31.2124 33.984C30.8924 33.984 30.7324 33.824 30.7324 33.504V0.863997C30.7324 0.543998 30.8924 0.383998 31.2124 0.383998H42.1084C45.0204 0.383998 47.3084 1.168 48.9724 2.736C50.6684 4.272 51.5164 6.4 51.5164 9.12V25.248C51.5164 27.968 50.6684 30.112 48.9724 31.68C47.3084 33.216 45.0204 33.984 42.1084 33.984H31.2124ZM32.1724 32.448C32.1724 32.576 32.2364 32.64 32.3644 32.64H42.2044C44.6364 32.64 46.5564 31.984 47.9644 30.672C49.3724 29.328 50.0764 27.504 50.0764 25.2V9.216C50.0764 6.88 49.3724 5.056 47.9644 3.744C46.5564 2.4 44.6364 1.728 42.2044 1.728H32.3644C32.2364 1.728 32.1724 1.792 32.1724 1.92V32.448Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -2,7 +2,13 @@
|
|||||||
* Interface representing a line of text with its measured width.
|
* Interface representing a line of text with its measured width.
|
||||||
*/
|
*/
|
||||||
export interface LineData {
|
export interface LineData {
|
||||||
|
/**
|
||||||
|
* Line's text
|
||||||
|
*/
|
||||||
text: string;
|
text: string;
|
||||||
|
/**
|
||||||
|
* It's width
|
||||||
|
*/
|
||||||
width: number;
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,16 +86,23 @@ export function createCharacterComparison<
|
|||||||
container: HTMLElement | undefined,
|
container: HTMLElement | undefined,
|
||||||
measureCanvas: HTMLCanvasElement | undefined,
|
measureCanvas: HTMLCanvasElement | undefined,
|
||||||
) {
|
) {
|
||||||
if (!container || !measureCanvas || !fontA() || !fontB()) return;
|
if (!container || !measureCanvas || !fontA() || !fontB()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect();
|
// Use offsetWidth instead of getBoundingClientRect() to avoid CSS transform scaling issues
|
||||||
containerWidth = rect.width;
|
// getBoundingClientRect() returns transformed dimensions, which causes incorrect line breaking
|
||||||
|
// when PerspectivePlan applies scale() transforms (e.g., scale(0.5) in settings mode)
|
||||||
|
const width = container.offsetWidth;
|
||||||
|
containerWidth = width;
|
||||||
|
|
||||||
// Padding considerations - matches the container padding
|
// Padding considerations - matches the container padding
|
||||||
const padding = window.innerWidth < 640 ? 48 : 96;
|
const padding = window.innerWidth < 640 ? 48 : 96;
|
||||||
const availableWidth = rect.width - padding;
|
const availableWidth = width - padding;
|
||||||
const ctx = measureCanvas.getContext('2d');
|
const ctx = measureCanvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const controlledFontSize = size();
|
const controlledFontSize = size();
|
||||||
const fontSize = getFontSize();
|
const fontSize = getFontSize();
|
||||||
@@ -150,42 +163,63 @@ export function createCharacterComparison<
|
|||||||
currentLineWords = [];
|
currentLineWords = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
let remainingWord = word;
|
const wordWidthA = measureText(
|
||||||
while (remainingWord.length > 0) {
|
ctx,
|
||||||
let low = 1;
|
word,
|
||||||
let high = remainingWord.length;
|
Math.min(fontSize, controlledFontSize),
|
||||||
let bestBreak = 1;
|
currentWeight,
|
||||||
|
fontA()?.name,
|
||||||
|
);
|
||||||
|
const wordWidthB = measureText(
|
||||||
|
ctx,
|
||||||
|
word,
|
||||||
|
Math.min(fontSize, controlledFontSize),
|
||||||
|
currentWeight,
|
||||||
|
fontB()?.name,
|
||||||
|
);
|
||||||
|
const wordAloneWidth = Math.max(wordWidthA, wordWidthB);
|
||||||
|
|
||||||
// Binary Search to find the maximum characters that fit
|
if (wordAloneWidth <= availableWidth) {
|
||||||
while (low <= high) {
|
// If word fits start new line with it
|
||||||
const mid = Math.floor((low + high) / 2);
|
currentLineWords = [word];
|
||||||
const testFragment = remainingWord.slice(0, mid);
|
} else {
|
||||||
|
let remainingWord = word;
|
||||||
|
while (remainingWord.length > 0) {
|
||||||
|
let low = 1;
|
||||||
|
let high = remainingWord.length;
|
||||||
|
let bestBreak = 1;
|
||||||
|
|
||||||
const wA = measureText(
|
// Binary Search to find the maximum characters that fit
|
||||||
ctx,
|
while (low <= high) {
|
||||||
testFragment,
|
const mid = Math.floor((low + high) / 2);
|
||||||
fontSize,
|
const testFragment = remainingWord.slice(0, mid);
|
||||||
currentWeight,
|
|
||||||
fontA()?.name,
|
|
||||||
);
|
|
||||||
const wB = measureText(
|
|
||||||
ctx,
|
|
||||||
testFragment,
|
|
||||||
fontSize,
|
|
||||||
currentWeight,
|
|
||||||
fontB()?.name,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (Math.max(wA, wB) <= availableWidth) {
|
const wA = measureText(
|
||||||
bestBreak = mid;
|
ctx,
|
||||||
low = mid + 1;
|
testFragment,
|
||||||
} else {
|
fontSize,
|
||||||
high = mid - 1;
|
currentWeight,
|
||||||
|
fontA()?.name,
|
||||||
|
);
|
||||||
|
const wB = measureText(
|
||||||
|
ctx,
|
||||||
|
testFragment,
|
||||||
|
fontSize,
|
||||||
|
currentWeight,
|
||||||
|
fontB()?.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Math.max(wA, wB) <= availableWidth) {
|
||||||
|
bestBreak = mid;
|
||||||
|
low = mid + 1;
|
||||||
|
} else {
|
||||||
|
high = mid - 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pushLine([remainingWord.slice(0, bestBreak)]);
|
pushLine([remainingWord.slice(0, bestBreak)]);
|
||||||
remainingWord = remainingWord.slice(bestBreak);
|
remainingWord = remainingWord.slice(bestBreak);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (maxWidth > availableWidth && currentLineWords.length > 0) {
|
} else if (maxWidth > availableWidth && currentLineWords.length > 0) {
|
||||||
pushLine(currentLineWords);
|
pushLine(currentLineWords);
|
||||||
@@ -255,3 +289,5 @@ export function createCharacterComparison<
|
|||||||
getCharState,
|
getCharState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CharacterComparison = ReturnType<typeof createCharacterComparison>;
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
type Entity,
|
||||||
|
EntityStore,
|
||||||
|
createEntityStore,
|
||||||
|
} from './createEntityStore.svelte';
|
||||||
|
|
||||||
|
interface TestEntity {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createEntityStore', () => {
|
||||||
|
describe('Construction and Initialization', () => {
|
||||||
|
it('should create an empty store when no initial entities are provided', () => {
|
||||||
|
const store = createEntityStore<TestEntity>();
|
||||||
|
|
||||||
|
expect(store.all).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a store with initial entities', () => {
|
||||||
|
const initialEntities: TestEntity[] = [
|
||||||
|
{ id: '1', name: 'First', value: 1 },
|
||||||
|
{ id: '2', name: 'Second', value: 2 },
|
||||||
|
];
|
||||||
|
const store = createEntityStore(initialEntities);
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(2);
|
||||||
|
expect(store.all).toEqual(initialEntities);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create EntityStore instance', () => {
|
||||||
|
const store = createEntityStore<TestEntity>();
|
||||||
|
|
||||||
|
expect(store).toBeInstanceOf(EntityStore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Selectors', () => {
|
||||||
|
let store: EntityStore<TestEntity>;
|
||||||
|
let entities: TestEntity[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
entities = [
|
||||||
|
{ id: '1', name: 'First', value: 10 },
|
||||||
|
{ id: '2', name: 'Second', value: 20 },
|
||||||
|
{ id: '3', name: 'Third', value: 30 },
|
||||||
|
];
|
||||||
|
store = createEntityStore(entities);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return all entities as an array', () => {
|
||||||
|
const all = store.all;
|
||||||
|
|
||||||
|
expect(all).toEqual(entities);
|
||||||
|
expect(all).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get a single entity by ID', () => {
|
||||||
|
const entity = store.getById('2');
|
||||||
|
|
||||||
|
expect(entity).toEqual({ id: '2', name: 'Second', value: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for non-existent ID', () => {
|
||||||
|
const entity = store.getById('999');
|
||||||
|
|
||||||
|
expect(entity).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get multiple entities by IDs', () => {
|
||||||
|
const entities = store.getByIds(['1', '3']);
|
||||||
|
|
||||||
|
expect(entities).toEqual([
|
||||||
|
{ id: '1', name: 'First', value: 10 },
|
||||||
|
{ id: '3', name: 'Third', value: 30 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out undefined results when getting by IDs', () => {
|
||||||
|
const entities = store.getByIds(['1', '999', '3']);
|
||||||
|
|
||||||
|
expect(entities).toEqual([
|
||||||
|
{ id: '1', name: 'First', value: 10 },
|
||||||
|
{ id: '3', name: 'Third', value: 30 },
|
||||||
|
]);
|
||||||
|
expect(entities).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no IDs match', () => {
|
||||||
|
const entities = store.getByIds(['999', '888']);
|
||||||
|
|
||||||
|
expect(entities).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check if entity exists by ID', () => {
|
||||||
|
expect(store.has('1')).toBe(true);
|
||||||
|
expect(store.has('999')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CRUD Operations - Create', () => {
|
||||||
|
it('should add a single entity', () => {
|
||||||
|
const store = createEntityStore<TestEntity>();
|
||||||
|
|
||||||
|
store.addOne({ id: '1', name: 'First', value: 1 });
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
expect(store.getById('1')).toEqual({ id: '1', name: 'First', value: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add multiple entities at once', () => {
|
||||||
|
const store = createEntityStore<TestEntity>();
|
||||||
|
|
||||||
|
store.addMany([
|
||||||
|
{ id: '1', name: 'First', value: 1 },
|
||||||
|
{ id: '2', name: 'Second', value: 2 },
|
||||||
|
{ id: '3', name: 'Third', value: 3 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace entity when adding with existing ID', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
|
||||||
|
|
||||||
|
store.addOne({ id: '1', name: 'Updated', value: 2 });
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CRUD Operations - Update', () => {
|
||||||
|
it('should update an existing entity', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
|
||||||
|
|
||||||
|
store.updateOne('1', { name: 'Updated' });
|
||||||
|
|
||||||
|
expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update multiple properties at once', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
|
||||||
|
|
||||||
|
store.updateOne('1', { name: 'Updated', value: 2 });
|
||||||
|
|
||||||
|
expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing when updating non-existent entity', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
|
||||||
|
|
||||||
|
store.updateOne('999', { name: 'Updated' });
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
expect(store.getById('1')).toEqual({ id: '1', name: 'Original', value: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve entity when no changes are provided', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'Original', value: 1 }]);
|
||||||
|
|
||||||
|
store.updateOne('1', {});
|
||||||
|
|
||||||
|
expect(store.getById('1')).toEqual({ id: '1', name: 'Original', value: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CRUD Operations - Delete', () => {
|
||||||
|
it('should remove a single entity', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([
|
||||||
|
{ id: '1', name: 'First', value: 1 },
|
||||||
|
{ id: '2', name: 'Second', value: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
store.removeOne('1');
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
expect(store.getById('1')).toBeUndefined();
|
||||||
|
expect(store.getById('2')).toEqual({ id: '2', name: 'Second', value: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove multiple entities', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([
|
||||||
|
{ id: '1', name: 'First', value: 1 },
|
||||||
|
{ id: '2', name: 'Second', value: 2 },
|
||||||
|
{ id: '3', name: 'Third', value: 3 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
store.removeMany(['1', '3']);
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
expect(store.getById('2')).toEqual({ id: '2', name: 'Second', value: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing when removing non-existent entity', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
|
||||||
|
|
||||||
|
store.removeOne('999');
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty array when removing many', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
|
||||||
|
|
||||||
|
store.removeMany([]);
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bulk Operations', () => {
|
||||||
|
it('should set all entities, replacing existing', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([
|
||||||
|
{ id: '1', name: 'First', value: 1 },
|
||||||
|
{ id: '2', name: 'Second', value: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
store.setAll([{ id: '3', name: 'Third', value: 3 }]);
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
expect(store.getById('1')).toBeUndefined();
|
||||||
|
expect(store.getById('3')).toEqual({ id: '3', name: 'Third', value: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear all entities', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([
|
||||||
|
{ id: '1', name: 'First', value: 1 },
|
||||||
|
{ id: '2', name: 'Second', value: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
store.clear();
|
||||||
|
|
||||||
|
expect(store.all).toEqual([]);
|
||||||
|
expect(store.all).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reactivity with SvelteMap', () => {
|
||||||
|
it('should return reactive arrays', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
|
||||||
|
|
||||||
|
// The all getter should return a fresh array (or reactive state)
|
||||||
|
const first = store.all;
|
||||||
|
const second = store.all;
|
||||||
|
|
||||||
|
// Both should have the same content
|
||||||
|
expect(first).toEqual(second);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reflect changes in subsequent calls', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
|
||||||
|
store.addOne({ id: '2', name: 'Second', value: 2 });
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle empty initial array', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([]);
|
||||||
|
|
||||||
|
expect(store.all).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single entity', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([{ id: '1', name: 'First', value: 1 }]);
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1);
|
||||||
|
expect(store.getById('1')).toEqual({ id: '1', name: 'First', value: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle entities with complex objects', () => {
|
||||||
|
interface ComplexEntity extends Entity {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
nested: {
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entity: ComplexEntity = {
|
||||||
|
id: '1',
|
||||||
|
data: { nested: { value: 'test' } },
|
||||||
|
tags: ['a', 'b', 'c'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = createEntityStore<ComplexEntity>([entity]);
|
||||||
|
|
||||||
|
expect(store.getById('1')).toEqual(entity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle numeric string IDs', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([
|
||||||
|
{ id: '123', name: 'First', value: 1 },
|
||||||
|
{ id: '456', name: 'Second', value: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(store.getById('123')).toEqual({ id: '123', name: 'First', value: 1 });
|
||||||
|
expect(store.getById('456')).toEqual({ id: '456', name: 'Second', value: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle UUID-like IDs', () => {
|
||||||
|
const uuid1 = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
const uuid2 = '550e8400-e29b-41d4-a716-446655440001';
|
||||||
|
|
||||||
|
const store = createEntityStore<TestEntity>([
|
||||||
|
{ id: uuid1, name: 'First', value: 1 },
|
||||||
|
{ id: uuid2, name: 'Second', value: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(store.getById(uuid1)).toEqual({ id: uuid1, name: 'First', value: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Type Safety', () => {
|
||||||
|
it('should enforce Entity type with id property', () => {
|
||||||
|
// This test verifies type checking at compile time
|
||||||
|
const validEntity: TestEntity = { id: '1', name: 'Test', value: 1 };
|
||||||
|
|
||||||
|
const store = createEntityStore<TestEntity>([validEntity]);
|
||||||
|
|
||||||
|
expect(store.getById('1')).toEqual(validEntity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with different entity types', () => {
|
||||||
|
interface User extends Entity {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Product extends Entity {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userStore = createEntityStore<User>([
|
||||||
|
{ id: 'u1', name: 'John', email: 'john@example.com' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const productStore = createEntityStore<Product>([
|
||||||
|
{ id: 'p1', title: 'Widget', price: 9.99 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(userStore.getById('u1')?.email).toBe('john@example.com');
|
||||||
|
expect(productStore.getById('p1')?.price).toBe(9.99);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Large Datasets', () => {
|
||||||
|
it('should handle large number of entities efficiently', () => {
|
||||||
|
const entities: TestEntity[] = Array.from({ length: 1000 }, (_, i) => ({
|
||||||
|
id: `id-${i}`,
|
||||||
|
name: `Entity ${i}`,
|
||||||
|
value: i,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const store = createEntityStore(entities);
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(1000);
|
||||||
|
expect(store.getById('id-500')).toEqual({
|
||||||
|
id: 'id-500',
|
||||||
|
name: 'Entity 500',
|
||||||
|
value: 500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should efficiently check existence in large dataset', () => {
|
||||||
|
const entities: TestEntity[] = Array.from({ length: 1000 }, (_, i) => ({
|
||||||
|
id: `id-${i}`,
|
||||||
|
name: `Entity ${i}`,
|
||||||
|
value: i,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const store = createEntityStore(entities);
|
||||||
|
|
||||||
|
expect(store.has('id-999')).toBe(true);
|
||||||
|
expect(store.has('id-1000')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Method Chaining', () => {
|
||||||
|
it('should support chaining add operations', () => {
|
||||||
|
const store = createEntityStore<TestEntity>();
|
||||||
|
|
||||||
|
store.addOne({ id: '1', name: 'First', value: 1 });
|
||||||
|
store.addOne({ id: '2', name: 'Second', value: 2 });
|
||||||
|
store.addOne({ id: '3', name: 'Third', value: 3 });
|
||||||
|
|
||||||
|
expect(store.all).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support chaining update operations', () => {
|
||||||
|
const store = createEntityStore<TestEntity>([
|
||||||
|
{ id: '1', name: 'First', value: 1 },
|
||||||
|
{ id: '2', name: 'Second', value: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
store.updateOne('1', { value: 10 });
|
||||||
|
store.updateOne('2', { value: 20 });
|
||||||
|
|
||||||
|
expect(store.getById('1')?.value).toBe(10);
|
||||||
|
expect(store.getById('2')?.value).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,3 +49,5 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PersistentStore<T> = ReturnType<typeof createPersistentStore<T>>;
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
/** @vitest-environment jsdom */
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import { createPersistentStore } from './createPersistentStore.svelte';
|
||||||
|
|
||||||
|
describe('createPersistentStore', () => {
|
||||||
|
let mockLocalStorage: Storage;
|
||||||
|
const testKey = 'test-store-key';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock localStorage
|
||||||
|
const storeMap = new Map<string, string>();
|
||||||
|
|
||||||
|
mockLocalStorage = {
|
||||||
|
get length() {
|
||||||
|
return storeMap.size;
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
storeMap.clear();
|
||||||
|
},
|
||||||
|
getItem(key: string) {
|
||||||
|
return storeMap.get(key) ?? null;
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
storeMap.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem(key: string) {
|
||||||
|
storeMap.delete(key);
|
||||||
|
},
|
||||||
|
key(index: number) {
|
||||||
|
return Array.from(storeMap.keys())[index] ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.stubGlobal('localStorage', mockLocalStorage);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initialization', () => {
|
||||||
|
it('should create store with default value when localStorage is empty', () => {
|
||||||
|
const store = createPersistentStore(testKey, 'default');
|
||||||
|
|
||||||
|
expect(store.value).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create store with value from localStorage', () => {
|
||||||
|
mockLocalStorage.setItem(testKey, JSON.stringify('stored value'));
|
||||||
|
|
||||||
|
const store = createPersistentStore(testKey, 'default');
|
||||||
|
|
||||||
|
expect(store.value).toBe('stored value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse JSON from localStorage', () => {
|
||||||
|
const storedValue = { name: 'Test', count: 42 };
|
||||||
|
mockLocalStorage.setItem(testKey, JSON.stringify(storedValue));
|
||||||
|
|
||||||
|
const store = createPersistentStore(testKey, { name: 'Default', count: 0 });
|
||||||
|
|
||||||
|
expect(store.value).toEqual(storedValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default value when localStorage has invalid JSON', () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
mockLocalStorage.setItem(testKey, 'invalid json{');
|
||||||
|
|
||||||
|
const store = createPersistentStore(testKey, 'default');
|
||||||
|
|
||||||
|
expect(store.value).toBe('default');
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reading Values', () => {
|
||||||
|
it('should return current value via getter', () => {
|
||||||
|
const store = createPersistentStore(testKey, 'default');
|
||||||
|
|
||||||
|
expect(store.value).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return updated value after setter', () => {
|
||||||
|
const store = createPersistentStore(testKey, 'default');
|
||||||
|
|
||||||
|
store.value = 'updated';
|
||||||
|
|
||||||
|
expect(store.value).toBe('updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve type information', () => {
|
||||||
|
interface TestObject {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
const defaultValue: TestObject = { name: 'Test', count: 0 };
|
||||||
|
const store = createPersistentStore<TestObject>(testKey, defaultValue);
|
||||||
|
|
||||||
|
expect(store.value.name).toBe('Test');
|
||||||
|
expect(store.value.count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Writing Values', () => {
|
||||||
|
it('should update value when set via setter', () => {
|
||||||
|
const store = createPersistentStore(testKey, 'default');
|
||||||
|
|
||||||
|
store.value = 'new value';
|
||||||
|
|
||||||
|
expect(store.value).toBe('new value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serialize objects to JSON', () => {
|
||||||
|
const store = createPersistentStore(testKey, { name: 'Default', count: 0 });
|
||||||
|
|
||||||
|
store.value = { name: 'Updated', count: 42 };
|
||||||
|
|
||||||
|
// The value is updated in the store
|
||||||
|
expect(store.value).toEqual({ name: 'Updated', count: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle arrays', () => {
|
||||||
|
const store = createPersistentStore<number[]>(testKey, []);
|
||||||
|
|
||||||
|
store.value = [1, 2, 3];
|
||||||
|
|
||||||
|
expect(store.value).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle booleans', () => {
|
||||||
|
const store = createPersistentStore<boolean>(testKey, false);
|
||||||
|
|
||||||
|
store.value = true;
|
||||||
|
|
||||||
|
expect(store.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null values', () => {
|
||||||
|
const store = createPersistentStore<string | null>(testKey, null);
|
||||||
|
|
||||||
|
store.value = 'not null';
|
||||||
|
|
||||||
|
expect(store.value).toBe('not null');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Clear Function', () => {
|
||||||
|
it('should reset value to default when clear is called', () => {
|
||||||
|
const store = createPersistentStore(testKey, 'default');
|
||||||
|
|
||||||
|
store.value = 'modified';
|
||||||
|
store.clear();
|
||||||
|
|
||||||
|
expect(store.value).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with object defaults', () => {
|
||||||
|
const defaultValue = { name: 'Default', count: 0 };
|
||||||
|
const store = createPersistentStore(testKey, defaultValue);
|
||||||
|
|
||||||
|
store.value = { name: 'Modified', count: 42 };
|
||||||
|
store.clear();
|
||||||
|
|
||||||
|
expect(store.value).toEqual(defaultValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with array defaults', () => {
|
||||||
|
const defaultValue = [1, 2, 3];
|
||||||
|
const store = createPersistentStore<number[]>(testKey, defaultValue);
|
||||||
|
|
||||||
|
store.value = [4, 5, 6];
|
||||||
|
store.clear();
|
||||||
|
|
||||||
|
expect(store.value).toEqual(defaultValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Type Support', () => {
|
||||||
|
it('should work with string type', () => {
|
||||||
|
const store = createPersistentStore<string>(testKey, 'default');
|
||||||
|
|
||||||
|
store.value = 'test string';
|
||||||
|
|
||||||
|
expect(store.value).toBe('test string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with number type', () => {
|
||||||
|
const store = createPersistentStore<number>(testKey, 0);
|
||||||
|
|
||||||
|
store.value = 42;
|
||||||
|
|
||||||
|
expect(store.value).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with boolean type', () => {
|
||||||
|
const store = createPersistentStore<boolean>(testKey, false);
|
||||||
|
|
||||||
|
store.value = true;
|
||||||
|
|
||||||
|
expect(store.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with object type', () => {
|
||||||
|
interface TestObject {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
const defaultValue: TestObject = { name: 'Test', value: 0 };
|
||||||
|
const store = createPersistentStore<TestObject>(testKey, defaultValue);
|
||||||
|
|
||||||
|
store.value = { name: 'Updated', value: 42 };
|
||||||
|
|
||||||
|
expect(store.value.name).toBe('Updated');
|
||||||
|
expect(store.value.value).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with array type', () => {
|
||||||
|
const store = createPersistentStore<string[]>(testKey, []);
|
||||||
|
|
||||||
|
store.value = ['a', 'b', 'c'];
|
||||||
|
|
||||||
|
expect(store.value).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with null type', () => {
|
||||||
|
const store = createPersistentStore<string | null>(testKey, null);
|
||||||
|
|
||||||
|
expect(store.value).toBeNull();
|
||||||
|
|
||||||
|
store.value = 'not null';
|
||||||
|
|
||||||
|
expect(store.value).toBe('not null');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
const store = createPersistentStore(testKey, 'default');
|
||||||
|
|
||||||
|
store.value = '';
|
||||||
|
|
||||||
|
expect(store.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero number', () => {
|
||||||
|
const store = createPersistentStore<number>(testKey, 100);
|
||||||
|
|
||||||
|
store.value = 0;
|
||||||
|
|
||||||
|
expect(store.value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle false boolean', () => {
|
||||||
|
const store = createPersistentStore<boolean>(testKey, true);
|
||||||
|
|
||||||
|
store.value = false;
|
||||||
|
|
||||||
|
expect(store.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty array', () => {
|
||||||
|
const store = createPersistentStore<number[]>(testKey, [1, 2, 3]);
|
||||||
|
|
||||||
|
store.value = [];
|
||||||
|
|
||||||
|
expect(store.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty object', () => {
|
||||||
|
const store = createPersistentStore<Record<string, unknown>>(testKey, { a: 1 });
|
||||||
|
|
||||||
|
store.value = {};
|
||||||
|
|
||||||
|
expect(store.value).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in string', () => {
|
||||||
|
const store = createPersistentStore(testKey, '');
|
||||||
|
|
||||||
|
const specialString = 'Hello "world"\nNew line\tTab';
|
||||||
|
store.value = specialString;
|
||||||
|
|
||||||
|
expect(store.value).toBe(specialString);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unicode characters', () => {
|
||||||
|
const store = createPersistentStore(testKey, '');
|
||||||
|
|
||||||
|
store.value = 'Hello 世界 🌍';
|
||||||
|
|
||||||
|
expect(store.value).toBe('Hello 世界 🌍');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Multiple Instances', () => {
|
||||||
|
it('should handle multiple stores with different keys', () => {
|
||||||
|
const store1 = createPersistentStore('key1', 'value1');
|
||||||
|
const store2 = createPersistentStore('key2', 'value2');
|
||||||
|
|
||||||
|
store1.value = 'updated1';
|
||||||
|
store2.value = 'updated2';
|
||||||
|
|
||||||
|
expect(store1.value).toBe('updated1');
|
||||||
|
expect(store2.value).toBe('updated2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep stores independent', () => {
|
||||||
|
const store1 = createPersistentStore('key1', 'default1');
|
||||||
|
const store2 = createPersistentStore('key2', 'default2');
|
||||||
|
|
||||||
|
store1.clear();
|
||||||
|
|
||||||
|
expect(store1.value).toBe('default1');
|
||||||
|
expect(store2.value).toBe('default2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Complex Scenarios', () => {
|
||||||
|
it('should handle nested objects', () => {
|
||||||
|
interface NestedObject {
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
settings: {
|
||||||
|
theme: string;
|
||||||
|
notifications: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const defaultValue: NestedObject = {
|
||||||
|
user: {
|
||||||
|
name: 'Test',
|
||||||
|
settings: { theme: 'light', notifications: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const store = createPersistentStore<NestedObject>(testKey, defaultValue);
|
||||||
|
|
||||||
|
store.value = {
|
||||||
|
user: {
|
||||||
|
name: 'Updated',
|
||||||
|
settings: { theme: 'dark', notifications: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(store.value).toEqual({
|
||||||
|
user: {
|
||||||
|
name: 'Updated',
|
||||||
|
settings: { theme: 'dark', notifications: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle arrays of objects', () => {
|
||||||
|
interface Item {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
const store = createPersistentStore<Item[]>(testKey, []);
|
||||||
|
|
||||||
|
store.value = [
|
||||||
|
{ id: 1, name: 'First' },
|
||||||
|
{ id: 2, name: 'Second' },
|
||||||
|
{ id: 3, name: 'Third' },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(store.value).toHaveLength(3);
|
||||||
|
expect(store.value[0].name).toBe('First');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { Spring } from 'svelte/motion';
|
||||||
|
|
||||||
|
export interface PerspectiveConfig {
|
||||||
|
/**
|
||||||
|
* How many px to move back per level
|
||||||
|
*/
|
||||||
|
depthStep?: number;
|
||||||
|
/**
|
||||||
|
* Scale reduction per level
|
||||||
|
*/
|
||||||
|
scaleStep?: number;
|
||||||
|
/**
|
||||||
|
* Blur amount per level
|
||||||
|
*/
|
||||||
|
blurStep?: number;
|
||||||
|
/**
|
||||||
|
* Opacity reduction per level
|
||||||
|
*/
|
||||||
|
opacityStep?: number;
|
||||||
|
/**
|
||||||
|
* Parallax intensity per level
|
||||||
|
*/
|
||||||
|
parallaxIntensity?: number;
|
||||||
|
/**
|
||||||
|
* Horizontal offset for each plan (x-axis positioning)
|
||||||
|
* Positive = right, Negative = left
|
||||||
|
*/
|
||||||
|
horizontalOffset?: number;
|
||||||
|
/**
|
||||||
|
* Layout mode: 'center' (default) or 'split' for Swiss-style side-by-side
|
||||||
|
*/
|
||||||
|
layoutMode?: 'center' | 'split';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages perspective state with a simple boolean flag.
|
||||||
|
*
|
||||||
|
* Drastically simplified from the complex camera/index system.
|
||||||
|
* Just manages whether content is in "back" or "front" state.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const perspective = createPerspectiveManager({
|
||||||
|
* depthStep: 100,
|
||||||
|
* scaleStep: 0.5,
|
||||||
|
* blurStep: 4,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Toggle back/front
|
||||||
|
* perspective.toggle();
|
||||||
|
*
|
||||||
|
* // Check state
|
||||||
|
* const isBack = perspective.isBack; // reactive boolean
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class PerspectiveManager {
|
||||||
|
/**
|
||||||
|
* Spring for smooth back/front transitions
|
||||||
|
*/
|
||||||
|
spring = new Spring(0, {
|
||||||
|
stiffness: 0.2,
|
||||||
|
damping: 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive boolean: true when in back position (blurred, scaled down)
|
||||||
|
*/
|
||||||
|
isBack = $derived(this.spring.current > 0.5);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive boolean: true when in front position (fully visible, interactive)
|
||||||
|
*/
|
||||||
|
isFront = $derived(this.spring.current < 0.5);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration values for style computation
|
||||||
|
*/
|
||||||
|
private config: Required<PerspectiveConfig>;
|
||||||
|
|
||||||
|
constructor(config: PerspectiveConfig = {}) {
|
||||||
|
this.config = {
|
||||||
|
depthStep: config.depthStep ?? 100,
|
||||||
|
scaleStep: config.scaleStep ?? 0.5,
|
||||||
|
blurStep: config.blurStep ?? 4,
|
||||||
|
opacityStep: config.opacityStep ?? 0.5,
|
||||||
|
parallaxIntensity: config.parallaxIntensity ?? 0,
|
||||||
|
horizontalOffset: config.horizontalOffset ?? 0,
|
||||||
|
layoutMode: config.layoutMode ?? 'center',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle between front (0) and back (1) positions.
|
||||||
|
* Smooth spring animation handles the transition.
|
||||||
|
*/
|
||||||
|
toggle = () => {
|
||||||
|
const target = this.spring.current < 0.5 ? 1 : 0;
|
||||||
|
this.spring.target = target;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force to back position
|
||||||
|
*/
|
||||||
|
setBack = () => {
|
||||||
|
this.spring.target = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force to front position
|
||||||
|
*/
|
||||||
|
setFront = () => {
|
||||||
|
this.spring.target = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get configuration for style computation
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
getConfig = () => this.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create a PerspectiveManager instance.
|
||||||
|
*
|
||||||
|
* @param config - Configuration options
|
||||||
|
* @returns Configured PerspectiveManager instance
|
||||||
|
*/
|
||||||
|
export function createPerspectiveManager(config: PerspectiveConfig = {}) {
|
||||||
|
return new PerspectiveManager(config);
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
// $shared/lib/createResponsiveManager.svelte.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breakpoint definitions following common device sizes
|
||||||
|
* Customize these values to match your design system
|
||||||
|
*/
|
||||||
|
export interface Breakpoints {
|
||||||
|
/** Mobile devices (portrait phones) */
|
||||||
|
mobile: number;
|
||||||
|
/** Tablet portrait */
|
||||||
|
tabletPortrait: number;
|
||||||
|
/** Tablet landscape */
|
||||||
|
tablet: number;
|
||||||
|
/** Desktop */
|
||||||
|
desktop: number;
|
||||||
|
/** Large desktop */
|
||||||
|
desktopLarge: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default breakpoints (matches common Tailwind-like breakpoints)
|
||||||
|
*/
|
||||||
|
const DEFAULT_BREAKPOINTS: Breakpoints = {
|
||||||
|
mobile: 640, // sm
|
||||||
|
tabletPortrait: 768, // md
|
||||||
|
tablet: 1024, // lg
|
||||||
|
desktop: 1280, // xl
|
||||||
|
desktopLarge: 1536, // 2xl
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orientation type
|
||||||
|
*/
|
||||||
|
export type Orientation = 'portrait' | 'landscape';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reactive responsive manager that tracks viewport size and breakpoints.
|
||||||
|
*
|
||||||
|
* Provides reactive getters for:
|
||||||
|
* - Current breakpoint detection (isMobile, isTablet, etc.)
|
||||||
|
* - Viewport dimensions (width, height)
|
||||||
|
* - Device orientation (portrait/landscape)
|
||||||
|
* - Custom breakpoint matching
|
||||||
|
*
|
||||||
|
* @param customBreakpoints - Optional custom breakpoint values
|
||||||
|
* @returns Responsive manager instance with reactive properties
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```svelte
|
||||||
|
* <script lang="ts">
|
||||||
|
* const responsive = createResponsiveManager();
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* {#if responsive.isMobile}
|
||||||
|
* <MobileNav />
|
||||||
|
* {:else if responsive.isTablet}
|
||||||
|
* <TabletNav />
|
||||||
|
* {:else}
|
||||||
|
* <DesktopNav />
|
||||||
|
* {/if}
|
||||||
|
*
|
||||||
|
* <p>Width: {responsive.width}px</p>
|
||||||
|
* <p>Orientation: {responsive.orientation}</p>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>) {
|
||||||
|
const breakpoints: Breakpoints = {
|
||||||
|
...DEFAULT_BREAKPOINTS,
|
||||||
|
...customBreakpoints,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reactive state
|
||||||
|
let width = $state(typeof window !== 'undefined' ? window.innerWidth : 0);
|
||||||
|
let height = $state(typeof window !== 'undefined' ? window.innerHeight : 0);
|
||||||
|
|
||||||
|
// Derived breakpoint states
|
||||||
|
const isMobile = $derived(width < breakpoints.mobile);
|
||||||
|
const isTabletPortrait = $derived(
|
||||||
|
width >= breakpoints.mobile && width < breakpoints.tabletPortrait,
|
||||||
|
);
|
||||||
|
const isTablet = $derived(
|
||||||
|
width >= breakpoints.tabletPortrait && width < breakpoints.desktop,
|
||||||
|
);
|
||||||
|
const isDesktop = $derived(
|
||||||
|
width >= breakpoints.desktop && width < breakpoints.desktopLarge,
|
||||||
|
);
|
||||||
|
const isDesktopLarge = $derived(width >= breakpoints.desktopLarge);
|
||||||
|
|
||||||
|
// Convenience groupings
|
||||||
|
const isMobileOrTablet = $derived(width < breakpoints.desktop);
|
||||||
|
const isTabletOrDesktop = $derived(width >= breakpoints.tabletPortrait);
|
||||||
|
|
||||||
|
// Orientation
|
||||||
|
const orientation = $derived<Orientation>(height > width ? 'portrait' : 'landscape');
|
||||||
|
const isPortrait = $derived(orientation === 'portrait');
|
||||||
|
const isLandscape = $derived(orientation === 'landscape');
|
||||||
|
|
||||||
|
// Touch device detection (best effort)
|
||||||
|
const isTouchDevice = $derived(
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
&& ('ontouchstart' in window || navigator.maxTouchPoints > 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize responsive tracking
|
||||||
|
* Call this in an $effect or component mount
|
||||||
|
*/
|
||||||
|
function init() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
width = window.innerWidth;
|
||||||
|
height = window.innerHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use ResizeObserver for more accurate tracking
|
||||||
|
const resizeObserver = new ResizeObserver(handleResize);
|
||||||
|
resizeObserver.observe(document.documentElement);
|
||||||
|
|
||||||
|
// Fallback to window resize event
|
||||||
|
window.addEventListener('resize', handleResize, { passive: true });
|
||||||
|
|
||||||
|
// Initial measurement
|
||||||
|
handleResize();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if current width matches a custom breakpoint
|
||||||
|
* @param min - Minimum width (inclusive)
|
||||||
|
* @param max - Maximum width (exclusive)
|
||||||
|
*/
|
||||||
|
function matches(min: number, max?: number): boolean {
|
||||||
|
if (max !== undefined) {
|
||||||
|
return width >= min && width < max;
|
||||||
|
}
|
||||||
|
return width >= min;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current breakpoint name
|
||||||
|
*/
|
||||||
|
const currentBreakpoint = $derived<keyof Breakpoints | 'xs'>(
|
||||||
|
(() => {
|
||||||
|
if (isMobile) return 'mobile';
|
||||||
|
if (isTabletPortrait) return 'tabletPortrait';
|
||||||
|
if (isTablet) return 'tablet';
|
||||||
|
if (isDesktop) return 'desktop';
|
||||||
|
if (isDesktopLarge) return 'desktopLarge';
|
||||||
|
return 'xs'; // Fallback for very small screens
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Dimensions
|
||||||
|
get width() {
|
||||||
|
return width;
|
||||||
|
},
|
||||||
|
get height() {
|
||||||
|
return height;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Standard breakpoints
|
||||||
|
get isMobile() {
|
||||||
|
return isMobile;
|
||||||
|
},
|
||||||
|
get isTabletPortrait() {
|
||||||
|
return isTabletPortrait;
|
||||||
|
},
|
||||||
|
get isTablet() {
|
||||||
|
return isTablet;
|
||||||
|
},
|
||||||
|
get isDesktop() {
|
||||||
|
return isDesktop;
|
||||||
|
},
|
||||||
|
get isDesktopLarge() {
|
||||||
|
return isDesktopLarge;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Convenience groupings
|
||||||
|
get isMobileOrTablet() {
|
||||||
|
return isMobileOrTablet;
|
||||||
|
},
|
||||||
|
get isTabletOrDesktop() {
|
||||||
|
return isTabletOrDesktop;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Orientation
|
||||||
|
get orientation() {
|
||||||
|
return orientation;
|
||||||
|
},
|
||||||
|
get isPortrait() {
|
||||||
|
return isPortrait;
|
||||||
|
},
|
||||||
|
get isLandscape() {
|
||||||
|
return isLandscape;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Device capabilities
|
||||||
|
get isTouchDevice() {
|
||||||
|
return isTouchDevice;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Current breakpoint
|
||||||
|
get currentBreakpoint() {
|
||||||
|
return currentBreakpoint;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
init,
|
||||||
|
matches,
|
||||||
|
|
||||||
|
// Breakpoint values (for custom logic)
|
||||||
|
breakpoints,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const responsiveManager = createResponsiveManager();
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
responsiveManager.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type for the responsive manager instance
|
||||||
|
*/
|
||||||
|
export type ResponsiveManager = ReturnType<typeof createResponsiveManager>;
|
||||||
@@ -22,11 +22,11 @@ export interface ControlDataModel {
|
|||||||
step: number;
|
step: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControlModel extends ControlDataModel {
|
export interface ControlModel<T extends string = string> extends ControlDataModel {
|
||||||
/**
|
/**
|
||||||
* Control identifier
|
* Control identifier
|
||||||
*/
|
*/
|
||||||
id: string;
|
id: T;
|
||||||
/**
|
/**
|
||||||
* Area label for increase button
|
* Area label for increase button
|
||||||
*/
|
*/
|
||||||
@@ -59,10 +59,10 @@ export function createTypographyControl<T extends ControlDataModel>(
|
|||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
set value(newValue) {
|
set value(newValue) {
|
||||||
value = roundToStepPrecision(
|
const rounded = roundToStepPrecision(clampNumber(newValue, min, max), step);
|
||||||
clampNumber(newValue, min, max),
|
if (value !== rounded) {
|
||||||
step,
|
value = rounded;
|
||||||
);
|
}
|
||||||
},
|
},
|
||||||
get max() {
|
get max() {
|
||||||
return max;
|
return max;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Used to render visible items with absolute positioning based on computed offsets.
|
* Used to render visible items with absolute positioning based on computed offsets.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface VirtualItem {
|
export interface VirtualItem {
|
||||||
/**
|
/**
|
||||||
* Index of the item in the data array
|
* Index of the item in the data array
|
||||||
@@ -120,9 +121,11 @@ export function createVirtualizer<T>(
|
|||||||
// By wrapping the getter in $derived, we track everything inside it
|
// By wrapping the getter in $derived, we track everything inside it
|
||||||
const options = $derived(optionsGetter());
|
const options = $derived(optionsGetter());
|
||||||
|
|
||||||
// This derivation now tracks: count, measuredSizes, AND the data array itself
|
// This derivation now tracks: count, _version (for measuredSizes updates), AND the data array itself
|
||||||
const offsets = $derived.by(() => {
|
const offsets = $derived.by(() => {
|
||||||
const count = options.count;
|
const count = options.count;
|
||||||
|
// Implicit dependency on version signal
|
||||||
|
const v = _version;
|
||||||
const result = new Float64Array(count);
|
const result = new Float64Array(count);
|
||||||
let accumulated = 0;
|
let accumulated = 0;
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -130,6 +133,7 @@ export function createVirtualizer<T>(
|
|||||||
// Accessing measuredSizes here creates the subscription
|
// Accessing measuredSizes here creates the subscription
|
||||||
accumulated += measuredSizes[i] ?? options.estimateSize(i);
|
accumulated += measuredSizes[i] ?? options.estimateSize(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,6 +148,8 @@ export function createVirtualizer<T>(
|
|||||||
// We MUST read options.data here so Svelte knows to re-run
|
// We MUST read options.data here so Svelte knows to re-run
|
||||||
// this derivation when the items array is replaced!
|
// this derivation when the items array is replaced!
|
||||||
const { count, data } = options;
|
const { count, data } = options;
|
||||||
|
// Implicit dependency
|
||||||
|
const v = _version;
|
||||||
if (count === 0 || containerHeight === 0 || !data) return [];
|
if (count === 0 || containerHeight === 0 || !data) return [];
|
||||||
|
|
||||||
const overscan = options.overscan ?? 5;
|
const overscan = options.overscan ?? 5;
|
||||||
@@ -185,10 +191,13 @@ export function createVirtualizer<T>(
|
|||||||
const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
|
const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
|
||||||
|
|
||||||
// Proximity calculation: 1.0 at center, 0.0 at edges
|
// Proximity calculation: 1.0 at center, 0.0 at edges
|
||||||
|
// Guard against division by zero (containerHeight can be 0 on initial render)
|
||||||
const itemCenter = itemStart + (itemSize / 2);
|
const itemCenter = itemStart + (itemSize / 2);
|
||||||
const distanceToCenter = Math.abs(viewportCenter - itemCenter);
|
const distanceToCenter = Math.abs(viewportCenter - itemCenter);
|
||||||
const maxDistance = containerHeight / 2;
|
const maxDistance = containerHeight / 2;
|
||||||
const proximity = Math.max(0, 1 - (distanceToCenter / maxDistance));
|
const proximity = maxDistance > 0
|
||||||
|
? Math.max(0, 1 - (distanceToCenter / maxDistance))
|
||||||
|
: 0;
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
index: i,
|
index: i,
|
||||||
@@ -225,32 +234,42 @@ export function createVirtualizer<T>(
|
|||||||
return rect.top + window.scrollY;
|
return rect.top + window.scrollY;
|
||||||
};
|
};
|
||||||
|
|
||||||
let cachedOffsetTop = getElementOffset();
|
let cachedOffsetTop = 0;
|
||||||
|
let rafId: number | null = null;
|
||||||
containerHeight = window.innerHeight;
|
containerHeight = window.innerHeight;
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
// Use cached offset for scroll calculations
|
if (rafId !== null) return;
|
||||||
scrollOffset = Math.max(0, window.scrollY - cachedOffsetTop);
|
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
// Get current position of element relative to viewport
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
// Calculate how much of the element has scrolled past the top of viewport
|
||||||
|
// When element.top is 0, element is at top of viewport
|
||||||
|
// When element.top is -100, element has scrolled up 100px past viewport top
|
||||||
|
const scrolledPastTop = Math.max(0, -rect.top);
|
||||||
|
scrollOffset = scrolledPastTop;
|
||||||
|
rafId = null;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
const oldHeight = containerHeight;
|
|
||||||
containerHeight = window.innerHeight;
|
containerHeight = window.innerHeight;
|
||||||
|
elementOffsetTop = getElementOffset();
|
||||||
// Recalculate offset on resize (layout may have shifted)
|
cachedOffsetTop = elementOffsetTop;
|
||||||
const newOffsetTop = getElementOffset();
|
handleScroll();
|
||||||
if (Math.abs(newOffsetTop - cachedOffsetTop) > 0.5) {
|
|
||||||
cachedOffsetTop = newOffsetTop;
|
|
||||||
handleScroll(); // Recalculate scroll position
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initial setup
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
elementOffsetTop = getElementOffset();
|
||||||
|
cachedOffsetTop = elementOffsetTop;
|
||||||
|
handleScroll();
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
// Initial calculation
|
|
||||||
handleScroll();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
window.removeEventListener('scroll', handleScroll);
|
window.removeEventListener('scroll', handleScroll);
|
||||||
@@ -259,6 +278,15 @@ export function createVirtualizer<T>(
|
|||||||
cancelAnimationFrame(frameId);
|
cancelAnimationFrame(frameId);
|
||||||
frameId = null;
|
frameId = null;
|
||||||
}
|
}
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
// Disconnect shared ResizeObserver
|
||||||
|
if (sharedResizeObserver) {
|
||||||
|
sharedResizeObserver.disconnect();
|
||||||
|
sharedResizeObserver = null;
|
||||||
|
}
|
||||||
elementRef = null;
|
elementRef = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -280,6 +308,11 @@ export function createVirtualizer<T>(
|
|||||||
destroy() {
|
destroy() {
|
||||||
node.removeEventListener('scroll', handleScroll);
|
node.removeEventListener('scroll', handleScroll);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
// Disconnect shared ResizeObserver
|
||||||
|
if (sharedResizeObserver) {
|
||||||
|
sharedResizeObserver.disconnect();
|
||||||
|
sharedResizeObserver = null;
|
||||||
|
}
|
||||||
elementRef = null;
|
elementRef = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -288,44 +321,67 @@ export function createVirtualizer<T>(
|
|||||||
|
|
||||||
let measurementBuffer: Record<number, number> = {};
|
let measurementBuffer: Record<number, number> = {};
|
||||||
let frameId: number | null = null;
|
let frameId: number | null = null;
|
||||||
|
// Signal to trigger updates when mutating measuredSizes in place
|
||||||
|
let _version = $state(0);
|
||||||
|
|
||||||
|
// Single shared ResizeObserver for all items (performance optimization)
|
||||||
|
let sharedResizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Svelte action to measure individual item elements for dynamic height support.
|
* Svelte action to measure individual item elements for dynamic height support.
|
||||||
*
|
*
|
||||||
* Attaches a ResizeObserver to track actual element height and updates
|
* Uses a single shared ResizeObserver for all items to track actual element heights.
|
||||||
* measured sizes when dimensions change. Requires `data-index` attribute on the element.
|
* Requires `data-index` attribute on the element.
|
||||||
*
|
*
|
||||||
* @param node - The DOM element to measure (should have `data-index` attribute)
|
* @param node - The DOM element to measure (should have `data-index` attribute)
|
||||||
* @returns Object with destroy method for cleanup
|
* @returns Object with destroy method for cleanup
|
||||||
*/
|
*/
|
||||||
function measureElement(node: HTMLElement) {
|
function measureElement(node: HTMLElement) {
|
||||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
// Initialize shared observer on first use
|
||||||
if (!entry) return;
|
if (!sharedResizeObserver) {
|
||||||
const index = parseInt(node.dataset.index || '', 10);
|
sharedResizeObserver = new ResizeObserver(entries => {
|
||||||
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
// Process all entries in a single batch
|
||||||
|
for (const entry of entries) {
|
||||||
|
const target = entry.target as HTMLElement;
|
||||||
|
const index = parseInt(target.dataset.index || '', 10);
|
||||||
|
const height = entry.borderBoxSize[0]?.blockSize ?? target.offsetHeight;
|
||||||
|
|
||||||
if (!isNaN(index)) {
|
if (!isNaN(index)) {
|
||||||
const oldHeight = measuredSizes[index];
|
const oldHeight = measuredSizes[index];
|
||||||
// Only update if the height difference is significant (> 0.5px)
|
|
||||||
// This prevents "jitter" from focus rings or sub-pixel border changes
|
|
||||||
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
|
|
||||||
// Stuff the measurement into a temporary buffer
|
|
||||||
measurementBuffer[index] = height;
|
|
||||||
|
|
||||||
// Schedule a single update for the next animation frame
|
// Only update if the height difference is significant (> 0.5px)
|
||||||
if (frameId === null) {
|
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
|
||||||
frameId = requestAnimationFrame(() => {
|
measurementBuffer[index] = height;
|
||||||
measuredSizes = { ...measuredSizes, ...measurementBuffer };
|
}
|
||||||
// Reset the buffer
|
|
||||||
measurementBuffer = {};
|
|
||||||
frameId = null;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
resizeObserver.observe(node);
|
// Schedule a single update for the next animation frame
|
||||||
return { destroy: () => resizeObserver.disconnect() };
|
if (frameId === null && Object.keys(measurementBuffer).length > 0) {
|
||||||
|
frameId = requestAnimationFrame(() => {
|
||||||
|
// Mutation in place for performance
|
||||||
|
Object.assign(measuredSizes, measurementBuffer);
|
||||||
|
|
||||||
|
// Trigger reactivity
|
||||||
|
_version += 1;
|
||||||
|
|
||||||
|
// Reset buffer
|
||||||
|
measurementBuffer = {};
|
||||||
|
frameId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe this element with the shared observer
|
||||||
|
sharedResizeObserver.observe(node);
|
||||||
|
|
||||||
|
// Return cleanup that only unobserves this specific element
|
||||||
|
return {
|
||||||
|
destroy: () => {
|
||||||
|
sharedResizeObserver?.unobserve(node);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Programmatic Scroll
|
// Programmatic Scroll
|
||||||
@@ -365,7 +421,35 @@ export function createVirtualizer<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls the container to a specific pixel offset.
|
||||||
|
* Used for preserving scroll position during data updates.
|
||||||
|
*
|
||||||
|
* @param offset - The scroll offset in pixels
|
||||||
|
* @param behavior - Scroll behavior: 'auto' for instant, 'smooth' for animated
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* virtualizer.scrollToOffset(1000, 'auto'); // Instant scroll to 1000px
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function scrollToOffset(offset: number, behavior: ScrollBehavior = 'auto') {
|
||||||
|
const { useWindowScroll } = optionsGetter();
|
||||||
|
|
||||||
|
if (useWindowScroll) {
|
||||||
|
window.scrollTo({ top: offset + elementOffsetTop, behavior });
|
||||||
|
} else if (elementRef) {
|
||||||
|
elementRef.scrollTo({ top: offset, behavior });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
get scrollOffset() {
|
||||||
|
return scrollOffset;
|
||||||
|
},
|
||||||
|
get containerHeight() {
|
||||||
|
return containerHeight;
|
||||||
|
},
|
||||||
/** Computed array of visible items to render (reactive) */
|
/** Computed array of visible items to render (reactive) */
|
||||||
get items() {
|
get items() {
|
||||||
return items;
|
return items;
|
||||||
@@ -380,6 +464,8 @@ export function createVirtualizer<T>(
|
|||||||
measureElement,
|
measureElement,
|
||||||
/** Programmatic scroll method to scroll to a specific item */
|
/** Programmatic scroll method to scroll to a specific item */
|
||||||
scrollToIndex,
|
scrollToIndex,
|
||||||
|
/** Programmatic scroll method to scroll to a specific pixel offset */
|
||||||
|
scrollToOffset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,550 @@
|
|||||||
|
/** @vitest-environment jsdom */
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import { createVirtualizer } from './createVirtualizer.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOTE: Svelte 5 Runes Testing Limitations
|
||||||
|
*
|
||||||
|
* The createVirtualizer helper uses Svelte 5 runes ($state, $derived, $derived.by)
|
||||||
|
* which require a full Svelte runtime environment to work correctly. In unit tests
|
||||||
|
* with jsdom, these runes are stubbed and don't provide actual reactivity.
|
||||||
|
*
|
||||||
|
* These tests focus on:
|
||||||
|
* 1. API surface verification (methods, getters exist)
|
||||||
|
* 2. Initial state calculation
|
||||||
|
* 3. DOM integration (event listeners are attached)
|
||||||
|
* 4. Edge case handling
|
||||||
|
*
|
||||||
|
* For full reactivity testing, use browser-based tests with @vitest/browser-playwright
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mock ResizeObserver globally since it's not available in jsdom
|
||||||
|
class MockResizeObserver {
|
||||||
|
observe = vi.fn();
|
||||||
|
unobserve = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = MockResizeObserver as any;
|
||||||
|
|
||||||
|
// Mock requestAnimationFrame
|
||||||
|
globalThis.requestAnimationFrame =
|
||||||
|
((cb: FrameRequestCallback) =>
|
||||||
|
setTimeout(() => cb(performance.now()), 16) as unknown) as typeof requestAnimationFrame;
|
||||||
|
globalThis.cancelAnimationFrame = vi.fn();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create test data array
|
||||||
|
*/
|
||||||
|
function createTestData(count: number): string[] {
|
||||||
|
return Array.from({ length: count }, (_, i) => `Item ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a mock scrollable container element
|
||||||
|
*/
|
||||||
|
function createMockContainer(height = 500, scrollTop = 0): any {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
Object.defineProperty(container, 'offsetHeight', {
|
||||||
|
value: height,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(container, 'scrollTop', {
|
||||||
|
value: scrollTop,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
// Add scrollTo method for testing
|
||||||
|
container.scrollTo = vi.fn();
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createVirtualizer - Basic API and State', () => {
|
||||||
|
describe('Basic Initialization and API Surface', () => {
|
||||||
|
it('should initialize and return expected API surface', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 0,
|
||||||
|
data: [],
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Verify API surface exists
|
||||||
|
expect(virtualizer).toHaveProperty('items');
|
||||||
|
expect(virtualizer).toHaveProperty('totalSize');
|
||||||
|
expect(virtualizer).toHaveProperty('scrollOffset');
|
||||||
|
expect(virtualizer).toHaveProperty('containerHeight');
|
||||||
|
expect(virtualizer).toHaveProperty('container');
|
||||||
|
expect(virtualizer).toHaveProperty('measureElement');
|
||||||
|
expect(virtualizer).toHaveProperty('scrollToIndex');
|
||||||
|
expect(virtualizer).toHaveProperty('scrollToOffset');
|
||||||
|
|
||||||
|
// Verify initial values
|
||||||
|
expect(virtualizer.items).toEqual([]);
|
||||||
|
expect(virtualizer.totalSize).toBe(0);
|
||||||
|
expect(virtualizer.scrollOffset).toBe(0);
|
||||||
|
expect(virtualizer.containerHeight).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate correct totalSize for uniform item sizes', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 10 items * 50px each = 500px total
|
||||||
|
expect(virtualizer.totalSize).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate correct totalSize for varying item sizes', () => {
|
||||||
|
const sizes = [50, 100, 150, 75, 125]; // Sum = 500
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 5,
|
||||||
|
data: createTestData(5),
|
||||||
|
estimateSize: (i: number) => sizes[i],
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer.totalSize).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty list (count = 0)', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 0,
|
||||||
|
data: [],
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer.totalSize).toBe(0);
|
||||||
|
expect(virtualizer.items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very large lists', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100000,
|
||||||
|
data: createTestData(100000),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer.totalSize).toBe(5000000); // 100000 * 50
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero estimated size', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer.totalSize).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Container Action', () => {
|
||||||
|
let cleanupHandlers: (() => void)[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupHandlers.forEach(cleanup => cleanup());
|
||||||
|
cleanupHandlers = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should attach container action and set up listeners', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const addEventListenerSpy = vi.spyOn(container, 'addEventListener');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
// Verify scroll listener was attached
|
||||||
|
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||||
|
'scroll',
|
||||||
|
expect.any(Function),
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update containerHeight when container is attached', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
expect(virtualizer.containerHeight).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up listeners on destroy', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const removeEventListenerSpy = vi.spyOn(container, 'removeEventListener');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanup?.destroy?.();
|
||||||
|
|
||||||
|
expect(removeEventListenerSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support window scrolling mode', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
useWindowScroll: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const windowAddSpy = vi.spyOn(window, 'addEventListener');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
// Should attach to window scroll
|
||||||
|
expect(windowAddSpy).toHaveBeenCalledWith('scroll', expect.any(Function), expect.any(Object));
|
||||||
|
expect(windowAddSpy).toHaveBeenCalledWith('resize', expect.any(Function));
|
||||||
|
|
||||||
|
windowAddSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scrollToIndex Method', () => {
|
||||||
|
let cleanupHandlers: (() => void)[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupHandlers.forEach(cleanup => cleanup());
|
||||||
|
cleanupHandlers = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have scrollToIndex method that does not throw without container', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Should not throw when container is not attached
|
||||||
|
expect(() => virtualizer.scrollToIndex(50)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scroll to specific index with container attached', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const scrollToSpy = vi.spyOn(container, 'scrollTo');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
virtualizer.scrollToIndex(10);
|
||||||
|
|
||||||
|
expect(scrollToSpy).toHaveBeenCalledWith({
|
||||||
|
top: expect.any(Number),
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle center alignment', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const scrollToSpy = vi.spyOn(container, 'scrollTo');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
virtualizer.scrollToIndex(10, 'center');
|
||||||
|
|
||||||
|
expect(scrollToSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle end alignment', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const scrollToSpy = vi.spyOn(container, 'scrollTo');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
virtualizer.scrollToIndex(10, 'end');
|
||||||
|
|
||||||
|
expect(scrollToSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not scroll for out of bounds indices', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const scrollToSpy = vi.spyOn(container, 'scrollTo');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
// Negative index
|
||||||
|
virtualizer.scrollToIndex(-1);
|
||||||
|
|
||||||
|
// Index >= count
|
||||||
|
virtualizer.scrollToIndex(100);
|
||||||
|
|
||||||
|
// Should not have been called
|
||||||
|
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scrollToOffset Method', () => {
|
||||||
|
let cleanupHandlers: (() => void)[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupHandlers.forEach(cleanup => cleanup());
|
||||||
|
cleanupHandlers = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scroll to specific pixel offset', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const scrollToSpy = vi.spyOn(container, 'scrollTo');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
virtualizer.scrollToOffset(1000);
|
||||||
|
|
||||||
|
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: 'auto' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support smooth behavior', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const scrollToSpy = vi.spyOn(container, 'scrollTo');
|
||||||
|
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
cleanupHandlers.push(() => cleanup?.destroy?.());
|
||||||
|
|
||||||
|
virtualizer.scrollToOffset(1000, 'smooth');
|
||||||
|
|
||||||
|
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('measureElement Action', () => {
|
||||||
|
it('should attach measureElement action to DOM element', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.dataset.index = '0';
|
||||||
|
|
||||||
|
// Should not throw when attaching measureElement
|
||||||
|
expect(() => {
|
||||||
|
const cleanup = virtualizer.measureElement(element);
|
||||||
|
cleanup?.destroy?.();
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up observer on destroy', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.dataset.index = '0';
|
||||||
|
|
||||||
|
const cleanup = virtualizer.measureElement(element);
|
||||||
|
|
||||||
|
// Should not throw when destroying
|
||||||
|
expect(() => cleanup?.destroy?.()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple elements being measured', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const elements = Array.from({ length: 5 }, (_, i) => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.dataset.index = String(i);
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanups = elements.map(el => virtualizer.measureElement(el));
|
||||||
|
|
||||||
|
// Should not throw when measuring multiple elements
|
||||||
|
expect(() => {
|
||||||
|
cleanups.forEach(cleanup => cleanup?.destroy?.());
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Options Handling', () => {
|
||||||
|
it('should use default overscan of 5', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Options with default overscan should work
|
||||||
|
expect(virtualizer).toHaveProperty('items');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom overscan value', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
overscan: 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer).toHaveProperty('items');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use index as default key when getItemKey is not provided', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer).toHaveProperty('items');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom getItemKey when provided', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
getItemKey: (i: number) => `custom-key-${i}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer).toHaveProperty('items');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom scrollMargin when provided', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
scrollMargin: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer).toHaveProperty('items');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle single item list', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 1,
|
||||||
|
data: ['Item 0'],
|
||||||
|
estimateSize: () => 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer.totalSize).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle items larger than viewport', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 5,
|
||||||
|
data: createTestData(5),
|
||||||
|
estimateSize: () => 200, // Each item is 200px
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Total size should still be calculated correctly
|
||||||
|
expect(virtualizer.totalSize).toBe(1000); // 5 * 200
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle overscan larger than viewport', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
overscan: 100, // Very large overscan
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(virtualizer).toHaveProperty('items');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative estimated size (graceful degradation)', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 10,
|
||||||
|
data: createTestData(10),
|
||||||
|
estimateSize: () => -10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Should calculate total size (may be negative, but shouldn't crash)
|
||||||
|
expect(virtualizer.totalSize).toBeLessThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Virtual Item Structure', () => {
|
||||||
|
it('should return items with correct structure when container is attached', () => {
|
||||||
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
count: 100,
|
||||||
|
data: createTestData(100),
|
||||||
|
estimateSize: () => 50,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const container = createMockContainer(500, 0);
|
||||||
|
const cleanup = virtualizer.container(container);
|
||||||
|
|
||||||
|
// Items may be empty in test environment due to reactivity limitations
|
||||||
|
// but we verify the structure exists
|
||||||
|
expect(Array.isArray(virtualizer.items)).toBe(true);
|
||||||
|
|
||||||
|
cleanup?.destroy?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,8 +28,23 @@ export {
|
|||||||
} from './createEntityStore/createEntityStore.svelte';
|
} from './createEntityStore/createEntityStore.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
type CharacterComparison,
|
||||||
createCharacterComparison,
|
createCharacterComparison,
|
||||||
type LineData,
|
type LineData,
|
||||||
} from './createCharacterComparison/createCharacterComparison.svelte';
|
} from './createCharacterComparison/createCharacterComparison.svelte';
|
||||||
|
|
||||||
export { createPersistentStore } from './createPersistentStore/createPersistentStore.svelte';
|
export {
|
||||||
|
createPersistentStore,
|
||||||
|
type PersistentStore,
|
||||||
|
} from './createPersistentStore/createPersistentStore.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createResponsiveManager,
|
||||||
|
type ResponsiveManager,
|
||||||
|
responsiveManager,
|
||||||
|
} from './createResponsiveManager/createResponsiveManager.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createPerspectiveManager,
|
||||||
|
type PerspectiveManager,
|
||||||
|
} from './createPerspectiveManager/createPerspectiveManager.svelte';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export {
|
export {
|
||||||
|
type CharacterComparison,
|
||||||
type ControlDataModel,
|
type ControlDataModel,
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
createCharacterComparison,
|
createCharacterComparison,
|
||||||
@@ -6,6 +7,8 @@ export {
|
|||||||
createEntityStore,
|
createEntityStore,
|
||||||
createFilter,
|
createFilter,
|
||||||
createPersistentStore,
|
createPersistentStore,
|
||||||
|
createPerspectiveManager,
|
||||||
|
createResponsiveManager,
|
||||||
createTypographyControl,
|
createTypographyControl,
|
||||||
createVirtualizer,
|
createVirtualizer,
|
||||||
type Entity,
|
type Entity,
|
||||||
@@ -13,13 +16,28 @@ export {
|
|||||||
type Filter,
|
type Filter,
|
||||||
type FilterModel,
|
type FilterModel,
|
||||||
type LineData,
|
type LineData,
|
||||||
|
type PersistentStore,
|
||||||
|
type PerspectiveManager,
|
||||||
type Property,
|
type Property,
|
||||||
|
type ResponsiveManager,
|
||||||
|
responsiveManager,
|
||||||
type TypographyControl,
|
type TypographyControl,
|
||||||
type VirtualItem,
|
type VirtualItem,
|
||||||
type Virtualizer,
|
type Virtualizer,
|
||||||
type VirtualizerOptions,
|
type VirtualizerOptions,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
|
|
||||||
export { splitArray } from './utils';
|
export {
|
||||||
|
buildQueryString,
|
||||||
|
clampNumber,
|
||||||
|
debounce,
|
||||||
|
getDecimalPlaces,
|
||||||
|
roundToStepPrecision,
|
||||||
|
smoothScroll,
|
||||||
|
splitArray,
|
||||||
|
throttle,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
export { springySlideFade } from './transitions';
|
export { springySlideFade } from './transitions';
|
||||||
|
|
||||||
|
export { ResponsiveProvider } from './providers';
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!--
|
||||||
|
Component: ResponsiveProvider
|
||||||
|
Provides a responsive manager to all children
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
type ResponsiveManager,
|
||||||
|
createResponsiveManager,
|
||||||
|
} from '$shared/lib/helpers';
|
||||||
|
import { setContext } from 'svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children }: Props = $props();
|
||||||
|
|
||||||
|
const responsive = createResponsiveManager();
|
||||||
|
|
||||||
|
// Initialize and cleanup
|
||||||
|
$effect(() => {
|
||||||
|
return responsive.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide to all children
|
||||||
|
setContext('responsive', responsive);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
1
src/shared/lib/providers/index.ts
Normal file
1
src/shared/lib/providers/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as ResponsiveProvider } from './ResponsiveProvider/ResponsiveProvider.svelte';
|
||||||
41
src/shared/lib/storybook/MockIcon.svelte
Normal file
41
src/shared/lib/storybook/MockIcon.svelte
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!--
|
||||||
|
Component: MockIcon
|
||||||
|
Wrapper component for Lucide icons to properly handle className in Storybook.
|
||||||
|
|
||||||
|
Lucide Svelte icons from @lucide/svelte/icons/* don't properly handle
|
||||||
|
the className prop directly. This wrapper ensures the class is applied
|
||||||
|
correctly via the HTML element's class attribute.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import type {
|
||||||
|
Component,
|
||||||
|
Snippet,
|
||||||
|
} from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* The Lucide icon component
|
||||||
|
*/
|
||||||
|
icon: Component;
|
||||||
|
/**
|
||||||
|
* CSS classes to apply to the icon
|
||||||
|
*/
|
||||||
|
class?: string;
|
||||||
|
/**
|
||||||
|
* Additional icon-specific attributes
|
||||||
|
*/
|
||||||
|
attrs?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { icon: Icon, class: className, attrs = {} }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if Icon}
|
||||||
|
{@const __iconClass__ = cn('size-4', className)}
|
||||||
|
<!-- Render icon component dynamically with class prop -->
|
||||||
|
<Icon
|
||||||
|
class={__iconClass__}
|
||||||
|
{...attrs}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
64
src/shared/lib/storybook/Providers.svelte
Normal file
64
src/shared/lib/storybook/Providers.svelte
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<!--
|
||||||
|
Component: Providers
|
||||||
|
Storybook wrapper that provides required contexts for components.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- responsive: ResponsiveManager context for breakpoint tracking
|
||||||
|
- tooltip: Tooltip.Provider context for shadcn Tooltip components
|
||||||
|
- Additional Radix UI providers can be added here as needed
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { createResponsiveManager } from '$shared/lib';
|
||||||
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
|
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||||
|
import { setContext } from 'svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
/**
|
||||||
|
* Initial viewport width for the responsive context (default: 1280)
|
||||||
|
*/
|
||||||
|
initialWidth?: number;
|
||||||
|
/**
|
||||||
|
* Initial viewport height for the responsive context (default: 720)
|
||||||
|
*/
|
||||||
|
initialHeight?: number;
|
||||||
|
/**
|
||||||
|
* Tooltip provider options
|
||||||
|
*/
|
||||||
|
tooltipDelayDuration?: number;
|
||||||
|
/**
|
||||||
|
* Tooltip skip delay duration
|
||||||
|
*/
|
||||||
|
tooltipSkipDelayDuration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
initialWidth = 1280,
|
||||||
|
initialHeight = 720,
|
||||||
|
tooltipDelayDuration = 200,
|
||||||
|
tooltipSkipDelayDuration = 300,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Create a responsive manager with default breakpoints
|
||||||
|
const responsiveManager = createResponsiveManager();
|
||||||
|
|
||||||
|
// Initialize the responsive manager to set up resize listeners
|
||||||
|
$effect(() => {
|
||||||
|
return responsiveManager.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide the responsive context
|
||||||
|
setContext<ResponsiveManager>('responsive', responsiveManager);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="storybook-providers" style:width="100%" style:height="100%">
|
||||||
|
<TooltipProvider
|
||||||
|
delayDuration={tooltipDelayDuration}
|
||||||
|
skipDelayDuration={tooltipSkipDelayDuration}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
24
src/shared/lib/storybook/index.ts
Normal file
24
src/shared/lib/storybook/index.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* STORYBOOK HELPERS
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Helper components and utilities for Storybook stories.
|
||||||
|
*
|
||||||
|
* ## Usage
|
||||||
|
*
|
||||||
|
* ```svelte
|
||||||
|
* <script lang="ts">
|
||||||
|
* import { Providers, MockIcon } from '$shared/lib/storybook';
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* <Providers>
|
||||||
|
* <YourComponent />
|
||||||
|
* </Providers>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as MockIcon } from './MockIcon.svelte';
|
||||||
|
export { default as Providers } from './Providers.svelte';
|
||||||
@@ -11,4 +11,6 @@ export { clampNumber } from './clampNumber/clampNumber';
|
|||||||
export { debounce } from './debounce/debounce';
|
export { debounce } from './debounce/debounce';
|
||||||
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
||||||
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
|
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
|
||||||
|
export { smoothScroll } from './smoothScroll/smoothScroll';
|
||||||
export { splitArray } from './splitArray/splitArray';
|
export { splitArray } from './splitArray/splitArray';
|
||||||
|
export { throttle } from './throttle/throttle';
|
||||||
|
|||||||
368
src/shared/lib/utils/smoothScroll/smoothScroll.test.ts
Normal file
368
src/shared/lib/utils/smoothScroll/smoothScroll.test.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
/** @vitest-environment jsdom */
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import { smoothScroll } from './smoothScroll';
|
||||||
|
|
||||||
|
describe('smoothScroll', () => {
|
||||||
|
let mockAnchor: HTMLAnchorElement;
|
||||||
|
let mockTarget: HTMLElement;
|
||||||
|
let mockScrollIntoView: ReturnType<typeof vi.fn>;
|
||||||
|
let mockPushState: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock scrollIntoView
|
||||||
|
mockScrollIntoView = vi.fn();
|
||||||
|
HTMLElement.prototype.scrollIntoView = mockScrollIntoView as (arg?: boolean | ScrollIntoViewOptions) => void;
|
||||||
|
|
||||||
|
// Mock history.pushState
|
||||||
|
mockPushState = vi.fn();
|
||||||
|
vi.stubGlobal('history', {
|
||||||
|
pushState: mockPushState,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create mock elements
|
||||||
|
mockAnchor = document.createElement('a');
|
||||||
|
mockAnchor.setAttribute('href', '#section-1');
|
||||||
|
|
||||||
|
mockTarget = document.createElement('div');
|
||||||
|
mockTarget.id = 'section-1';
|
||||||
|
document.body.appendChild(mockTarget);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Basic Functionality', () => {
|
||||||
|
it('should be a function that returns an object with destroy method', () => {
|
||||||
|
const action = smoothScroll(mockAnchor);
|
||||||
|
|
||||||
|
expect(typeof action).toBe('object');
|
||||||
|
expect(typeof action.destroy).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add click event listener to the anchor element', () => {
|
||||||
|
const addEventListenerSpy = vi.spyOn(mockAnchor, 'addEventListener');
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
|
||||||
|
expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function));
|
||||||
|
addEventListenerSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove click event listener when destroy is called', () => {
|
||||||
|
const action = smoothScroll(mockAnchor);
|
||||||
|
const removeEventListenerSpy = vi.spyOn(mockAnchor, 'removeEventListener');
|
||||||
|
|
||||||
|
action.destroy();
|
||||||
|
|
||||||
|
expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function));
|
||||||
|
removeEventListenerSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Click Handling', () => {
|
||||||
|
it('should prevent default behavior on click', () => {
|
||||||
|
const mockEvent = new MouseEvent('click', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
const preventDefaultSpy = vi.spyOn(mockEvent, 'preventDefault');
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||||
|
preventDefaultSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scroll to target element when clicked', () => {
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalledWith({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update URL hash without jumping when clicked', () => {
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should do nothing when href attribute is missing', () => {
|
||||||
|
mockAnchor.removeAttribute('href');
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).not.toHaveBeenCalled();
|
||||||
|
expect(mockPushState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing when href is just "#"', () => {
|
||||||
|
mockAnchor.setAttribute('href', '#');
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).not.toHaveBeenCalled();
|
||||||
|
expect(mockPushState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing when target element does not exist', () => {
|
||||||
|
mockAnchor.setAttribute('href', '#non-existent');
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).not.toHaveBeenCalled();
|
||||||
|
expect(mockPushState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty href attribute', () => {
|
||||||
|
mockAnchor.setAttribute('href', '');
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Multiple Anchors', () => {
|
||||||
|
it('should work correctly with multiple anchor elements', () => {
|
||||||
|
const anchor1 = document.createElement('a');
|
||||||
|
anchor1.setAttribute('href', '#section-1');
|
||||||
|
const target1 = document.createElement('div');
|
||||||
|
target1.id = 'section-1';
|
||||||
|
document.body.appendChild(target1);
|
||||||
|
|
||||||
|
const anchor2 = document.createElement('a');
|
||||||
|
anchor2.setAttribute('href', '#section-2');
|
||||||
|
const target2 = document.createElement('div');
|
||||||
|
target2.id = 'section-2';
|
||||||
|
document.body.appendChild(target2);
|
||||||
|
|
||||||
|
const action1 = smoothScroll(anchor1);
|
||||||
|
const action2 = smoothScroll(anchor2);
|
||||||
|
|
||||||
|
const event1 = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
anchor1.dispatchEvent(event1);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const event2 = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
anchor2.dispatchEvent(event2);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-2');
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
action1.destroy();
|
||||||
|
action2.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cleanup', () => {
|
||||||
|
it('should not trigger clicks after destroy is called', () => {
|
||||||
|
const action = smoothScroll(mockAnchor);
|
||||||
|
|
||||||
|
action.destroy();
|
||||||
|
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).not.toHaveBeenCalled();
|
||||||
|
expect(mockPushState).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow multiple destroy calls without errors', () => {
|
||||||
|
const action = smoothScroll(mockAnchor);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
action.destroy();
|
||||||
|
action.destroy();
|
||||||
|
action.destroy();
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Scroll Options', () => {
|
||||||
|
it('should always use smooth behavior', () => {
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
behavior: 'smooth',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should always use block: start', () => {
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
block: 'start',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Different Hash Formats', () => {
|
||||||
|
it('should handle simple hash like "#section"', () => {
|
||||||
|
const target = document.createElement('div');
|
||||||
|
target.id = 'section';
|
||||||
|
document.body.appendChild(target);
|
||||||
|
|
||||||
|
mockAnchor.setAttribute('href', '#section');
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalled();
|
||||||
|
expect(mockPushState).toHaveBeenCalledWith(null, '', '#section');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle hash with multiple words like "#my-section"', () => {
|
||||||
|
const target = document.createElement('div');
|
||||||
|
target.id = 'my-section';
|
||||||
|
document.body.appendChild(target);
|
||||||
|
|
||||||
|
mockAnchor.setAttribute('href', '#my-section');
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalled();
|
||||||
|
expect(mockPushState).toHaveBeenCalledWith(null, '', '#my-section');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle hash with numbers like "#section-1-2"', () => {
|
||||||
|
const target = document.createElement('div');
|
||||||
|
target.id = 'section-1-2';
|
||||||
|
document.body.appendChild(target);
|
||||||
|
|
||||||
|
mockAnchor.setAttribute('href', '#section-1-2');
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
smoothScroll(mockAnchor);
|
||||||
|
mockAnchor.dispatchEvent(mockEvent);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalled();
|
||||||
|
expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-1-2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Special Cases', () => {
|
||||||
|
it('should gracefully handle missing history.pushState', () => {
|
||||||
|
// Create a fresh test environment
|
||||||
|
const testAnchor = document.createElement('a');
|
||||||
|
testAnchor.href = '#test';
|
||||||
|
const testTarget = document.createElement('div');
|
||||||
|
testTarget.id = 'test';
|
||||||
|
document.body.appendChild(testTarget);
|
||||||
|
|
||||||
|
// Don't stub history - the action should still work without it
|
||||||
|
const action = smoothScroll(testAnchor);
|
||||||
|
const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
// Should not throw even if history.pushState might not exist
|
||||||
|
expect(() => testAnchor.dispatchEvent(mockEvent)).not.toThrow();
|
||||||
|
|
||||||
|
action.destroy();
|
||||||
|
testTarget.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Return Value', () => {
|
||||||
|
it('should return an action object compatible with Svelte use directive', () => {
|
||||||
|
const action = smoothScroll(mockAnchor);
|
||||||
|
|
||||||
|
expect(action).toHaveProperty('destroy');
|
||||||
|
expect(typeof action.destroy).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow chaining destroy calls', () => {
|
||||||
|
const action = smoothScroll(mockAnchor);
|
||||||
|
|
||||||
|
const result = action.destroy();
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Real-World Scenarios', () => {
|
||||||
|
it('should handle table of contents navigation', () => {
|
||||||
|
const sections = ['intro', 'features', 'pricing', 'contact'];
|
||||||
|
sections.forEach(id => {
|
||||||
|
const section = document.createElement('section');
|
||||||
|
section.id = id;
|
||||||
|
document.body.appendChild(section);
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = `#${id}`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
|
||||||
|
const action = smoothScroll(link);
|
||||||
|
|
||||||
|
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
link.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalled();
|
||||||
|
|
||||||
|
action.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalledTimes(sections.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with back-to-top button', () => {
|
||||||
|
const topAnchor = document.createElement('a');
|
||||||
|
topAnchor.href = '#top';
|
||||||
|
document.body.appendChild(topAnchor);
|
||||||
|
|
||||||
|
const topElement = document.createElement('div');
|
||||||
|
topElement.id = 'top';
|
||||||
|
document.body.prepend(topElement);
|
||||||
|
|
||||||
|
const action = smoothScroll(topAnchor);
|
||||||
|
|
||||||
|
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
topAnchor.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(mockScrollIntoView).toHaveBeenCalled();
|
||||||
|
|
||||||
|
action.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
32
src/shared/lib/utils/smoothScroll/smoothScroll.ts
Normal file
32
src/shared/lib/utils/smoothScroll/smoothScroll.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Smoothly scrolls to the target element when an anchor element is clicked.
|
||||||
|
* @param node - The anchor element to listen for clicks on.
|
||||||
|
*/
|
||||||
|
export function smoothScroll(node: HTMLAnchorElement) {
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const hash = node.getAttribute('href');
|
||||||
|
if (!hash || hash === '#') return;
|
||||||
|
|
||||||
|
const targetElement = document.querySelector(hash);
|
||||||
|
|
||||||
|
if (targetElement) {
|
||||||
|
targetElement.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update URL hash without jumping
|
||||||
|
history.pushState(null, '', hash);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
node.addEventListener('click', handleClick);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener('click', handleClick);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
405
src/shared/lib/utils/splitArray/splitArray.test.ts
Normal file
405
src/shared/lib/utils/splitArray/splitArray.test.ts
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import { splitArray } from './splitArray';
|
||||||
|
|
||||||
|
describe('splitArray', () => {
|
||||||
|
describe('Basic Functionality', () => {
|
||||||
|
it('should split an array into two arrays based on callback', () => {
|
||||||
|
const input = [1, 2, 3, 4, 5];
|
||||||
|
const [pass, fail] = splitArray(input, n => n > 2);
|
||||||
|
|
||||||
|
expect(pass).toEqual([3, 4, 5]);
|
||||||
|
expect(fail).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return two arrays', () => {
|
||||||
|
const result = splitArray([1, 2, 3], () => true);
|
||||||
|
|
||||||
|
expect(Array.isArray(result)).toBe(true);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(Array.isArray(result[0])).toBe(true);
|
||||||
|
expect(Array.isArray(result[1])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve original array', () => {
|
||||||
|
const input = [1, 2, 3, 4, 5];
|
||||||
|
const original = [...input];
|
||||||
|
|
||||||
|
splitArray(input, n => n % 2 === 0);
|
||||||
|
|
||||||
|
expect(input).toEqual(original);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Empty Array', () => {
|
||||||
|
it('should return two empty arrays for empty input', () => {
|
||||||
|
const [pass, fail] = splitArray([], () => true);
|
||||||
|
|
||||||
|
expect(pass).toEqual([]);
|
||||||
|
expect(fail).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty array with falsy callback', () => {
|
||||||
|
const [pass, fail] = splitArray([], () => false);
|
||||||
|
|
||||||
|
expect(pass).toEqual([]);
|
||||||
|
expect(fail).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('All Pass', () => {
|
||||||
|
it('should put all elements in pass array when callback returns true for all', () => {
|
||||||
|
const input = [1, 2, 3, 4, 5];
|
||||||
|
const [pass, fail] = splitArray(input, () => true);
|
||||||
|
|
||||||
|
expect(pass).toEqual([1, 2, 3, 4, 5]);
|
||||||
|
expect(fail).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should put all elements in pass array using always-true condition', () => {
|
||||||
|
const input = ['a', 'b', 'c'];
|
||||||
|
const [pass, fail] = splitArray(input, s => s.length > 0);
|
||||||
|
|
||||||
|
expect(pass).toEqual(['a', 'b', 'c']);
|
||||||
|
expect(fail).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('All Fail', () => {
|
||||||
|
it('should put all elements in fail array when callback returns false for all', () => {
|
||||||
|
const input = [1, 2, 3, 4, 5];
|
||||||
|
const [pass, fail] = splitArray(input, () => false);
|
||||||
|
|
||||||
|
expect(pass).toEqual([]);
|
||||||
|
expect(fail).toEqual([1, 2, 3, 4, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should put all elements in fail array using always-false condition', () => {
|
||||||
|
const input = ['a', 'b', 'c'];
|
||||||
|
const [pass, fail] = splitArray(input, s => s.length > 10);
|
||||||
|
|
||||||
|
expect(pass).toEqual([]);
|
||||||
|
expect(fail).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Mixed Results', () => {
|
||||||
|
it('should split even and odd numbers', () => {
|
||||||
|
const input = [1, 2, 3, 4, 5, 6];
|
||||||
|
const [even, odd] = splitArray(input, n => n % 2 === 0);
|
||||||
|
|
||||||
|
expect(even).toEqual([2, 4, 6]);
|
||||||
|
expect(odd).toEqual([1, 3, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should split positive and negative numbers', () => {
|
||||||
|
const input = [-3, -2, -1, 0, 1, 2, 3];
|
||||||
|
const [positive, negative] = splitArray(input, n => n >= 0);
|
||||||
|
|
||||||
|
expect(positive).toEqual([0, 1, 2, 3]);
|
||||||
|
expect(negative).toEqual([-3, -2, -1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should split strings by length', () => {
|
||||||
|
const input = ['a', 'ab', 'abc', 'abcd'];
|
||||||
|
const [long, short] = splitArray(input, s => s.length >= 3);
|
||||||
|
|
||||||
|
expect(long).toEqual(['abc', 'abcd']);
|
||||||
|
expect(short).toEqual(['a', 'ab']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should split objects by property', () => {
|
||||||
|
interface Item {
|
||||||
|
id: number;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
const input: Item[] = [
|
||||||
|
{ id: 1, active: true },
|
||||||
|
{ id: 2, active: false },
|
||||||
|
{ id: 3, active: true },
|
||||||
|
{ id: 4, active: false },
|
||||||
|
];
|
||||||
|
const [active, inactive] = splitArray(input, item => item.active);
|
||||||
|
|
||||||
|
expect(active).toEqual([
|
||||||
|
{ id: 1, active: true },
|
||||||
|
{ id: 3, active: true },
|
||||||
|
]);
|
||||||
|
expect(inactive).toEqual([
|
||||||
|
{ id: 2, active: false },
|
||||||
|
{ id: 4, active: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Type Safety', () => {
|
||||||
|
it('should work with number arrays', () => {
|
||||||
|
const [pass, fail] = splitArray([1, 2, 3], n => n > 1);
|
||||||
|
|
||||||
|
expect(pass).toEqual([2, 3]);
|
||||||
|
expect(fail).toEqual([1]);
|
||||||
|
|
||||||
|
// Type check - should be numbers
|
||||||
|
const sum = pass[0] + pass[1];
|
||||||
|
expect(sum).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with string arrays', () => {
|
||||||
|
const [pass, fail] = splitArray(['a', 'bb', 'ccc'], s => s.length > 1);
|
||||||
|
|
||||||
|
expect(pass).toEqual(['bb', 'ccc']);
|
||||||
|
expect(fail).toEqual(['a']);
|
||||||
|
|
||||||
|
// Type check - should be strings
|
||||||
|
const concatenated = pass.join('');
|
||||||
|
expect(concatenated).toBe('bbccc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with boolean arrays', () => {
|
||||||
|
const [pass, fail] = splitArray([true, false, true], b => b);
|
||||||
|
|
||||||
|
expect(pass).toEqual([true, true]);
|
||||||
|
expect(fail).toEqual([false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with generic objects', () => {
|
||||||
|
interface Person {
|
||||||
|
name: string;
|
||||||
|
age: number;
|
||||||
|
}
|
||||||
|
const people: Person[] = [
|
||||||
|
{ name: 'Alice', age: 25 },
|
||||||
|
{ name: 'Bob', age: 30 },
|
||||||
|
{ name: 'Charlie', age: 20 },
|
||||||
|
];
|
||||||
|
const [adults, minors] = splitArray(people, p => p.age >= 21);
|
||||||
|
|
||||||
|
expect(adults).toEqual([
|
||||||
|
{ name: 'Alice', age: 25 },
|
||||||
|
{ name: 'Bob', age: 30 },
|
||||||
|
]);
|
||||||
|
expect(minors).toEqual([{ name: 'Charlie', age: 20 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with null and undefined', () => {
|
||||||
|
const input = [null, undefined, 1, 0, ''];
|
||||||
|
const [truthy, falsy] = splitArray(input, item => !!item);
|
||||||
|
|
||||||
|
expect(truthy).toEqual([1]);
|
||||||
|
expect(falsy).toEqual([null, undefined, 0, '']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Callback Functions', () => {
|
||||||
|
it('should support arrow function syntax', () => {
|
||||||
|
const [pass, fail] = splitArray([1, 2, 3, 4], x => x % 2 === 0);
|
||||||
|
|
||||||
|
expect(pass).toEqual([2, 4]);
|
||||||
|
expect(fail).toEqual([1, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support regular function syntax', () => {
|
||||||
|
const [pass, fail] = splitArray([1, 2, 3, 4], function(x) {
|
||||||
|
return x % 2 === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pass).toEqual([2, 4]);
|
||||||
|
expect(fail).toEqual([1, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support inline conditions', () => {
|
||||||
|
const input = [1, 2, 3, 4, 5];
|
||||||
|
const [greaterThan3, others] = splitArray(input, x => x > 3);
|
||||||
|
|
||||||
|
expect(greaterThan3).toEqual([4, 5]);
|
||||||
|
expect(others).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Order Preservation', () => {
|
||||||
|
it('should maintain order within each resulting array', () => {
|
||||||
|
const input = [5, 1, 4, 2, 3];
|
||||||
|
const [greaterThan2, lessOrEqual] = splitArray(input, n => n > 2);
|
||||||
|
|
||||||
|
expect(greaterThan2).toEqual([5, 4, 3]);
|
||||||
|
expect(lessOrEqual).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve relative order for complex objects', () => {
|
||||||
|
interface Item {
|
||||||
|
id: number;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
const input: Item[] = [
|
||||||
|
{ id: 1, value: 'a' },
|
||||||
|
{ id: 2, value: 'b' },
|
||||||
|
{ id: 3, value: 'c' },
|
||||||
|
{ id: 4, value: 'd' },
|
||||||
|
];
|
||||||
|
const [evenIds, oddIds] = splitArray(input, item => item.id % 2 === 0);
|
||||||
|
|
||||||
|
expect(evenIds).toEqual([
|
||||||
|
{ id: 2, value: 'b' },
|
||||||
|
{ id: 4, value: 'd' },
|
||||||
|
]);
|
||||||
|
expect(oddIds).toEqual([
|
||||||
|
{ id: 1, value: 'a' },
|
||||||
|
{ id: 3, value: 'c' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle single element array (truthy)', () => {
|
||||||
|
const [pass, fail] = splitArray([1], () => true);
|
||||||
|
|
||||||
|
expect(pass).toEqual([1]);
|
||||||
|
expect(fail).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single element array (falsy)', () => {
|
||||||
|
const [pass, fail] = splitArray([1], () => false);
|
||||||
|
|
||||||
|
expect(pass).toEqual([]);
|
||||||
|
expect(fail).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle two element array', () => {
|
||||||
|
const [pass, fail] = splitArray([1, 2], n => n === 1);
|
||||||
|
|
||||||
|
expect(pass).toEqual([1]);
|
||||||
|
expect(fail).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle array with duplicate values', () => {
|
||||||
|
const [pass, fail] = splitArray([1, 1, 2, 2, 1, 1], n => n === 1);
|
||||||
|
|
||||||
|
expect(pass).toEqual([1, 1, 1, 1]);
|
||||||
|
expect(fail).toEqual([2, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero values', () => {
|
||||||
|
const [truthy, falsy] = splitArray([0, 1, 0, 2], Boolean);
|
||||||
|
|
||||||
|
expect(truthy).toEqual([1, 2]);
|
||||||
|
expect(falsy).toEqual([0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle NaN values', () => {
|
||||||
|
const input = [1, NaN, 2, NaN, 3];
|
||||||
|
const [numbers, nans] = splitArray(input, n => !Number.isNaN(n));
|
||||||
|
|
||||||
|
expect(numbers).toEqual([1, 2, 3]);
|
||||||
|
expect(nans).toEqual([NaN, NaN]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Large Arrays', () => {
|
||||||
|
it('should handle large arrays efficiently', () => {
|
||||||
|
const largeArray = Array.from({ length: 10000 }, (_, i) => i);
|
||||||
|
const [even, odd] = splitArray(largeArray, n => n % 2 === 0);
|
||||||
|
|
||||||
|
expect(even).toHaveLength(5000);
|
||||||
|
expect(odd).toHaveLength(5000);
|
||||||
|
expect(even[0]).toBe(0);
|
||||||
|
expect(even[9999]).toBeUndefined();
|
||||||
|
expect(even[4999]).toBe(9998);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain correct results for all elements in large array', () => {
|
||||||
|
const input = Array.from({ length: 1000 }, (_, i) => i);
|
||||||
|
const [multiplesOf3, others] = splitArray(input, n => n % 3 === 0);
|
||||||
|
|
||||||
|
// Verify counts
|
||||||
|
expect(multiplesOf3).toHaveLength(334); // 0, 3, 6, ..., 999
|
||||||
|
expect(others).toHaveLength(666);
|
||||||
|
|
||||||
|
// Verify all multiples of 3 are in correct array
|
||||||
|
multiplesOf3.forEach(n => {
|
||||||
|
expect(n % 3).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify no multiples of 3 are in others
|
||||||
|
others.forEach(n => {
|
||||||
|
expect(n % 3).not.toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Real-World Use Cases', () => {
|
||||||
|
it('should separate valid from invalid emails', () => {
|
||||||
|
const emails = [
|
||||||
|
'valid@example.com',
|
||||||
|
'invalid',
|
||||||
|
'another@test.org',
|
||||||
|
'not-an-email',
|
||||||
|
'user@domain.co.uk',
|
||||||
|
];
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
const [valid, invalid] = splitArray(emails, email => emailRegex.test(email));
|
||||||
|
|
||||||
|
expect(valid).toEqual([
|
||||||
|
'valid@example.com',
|
||||||
|
'another@test.org',
|
||||||
|
'user@domain.co.uk',
|
||||||
|
]);
|
||||||
|
expect(invalid).toEqual(['invalid', 'not-an-email']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should separate completed from pending tasks', () => {
|
||||||
|
interface Task {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
completed: boolean;
|
||||||
|
}
|
||||||
|
const tasks: Task[] = [
|
||||||
|
{ id: 1, title: 'Task 1', completed: true },
|
||||||
|
{ id: 2, title: 'Task 2', completed: false },
|
||||||
|
{ id: 3, title: 'Task 3', completed: true },
|
||||||
|
{ id: 4, title: 'Task 4', completed: false },
|
||||||
|
];
|
||||||
|
const [completed, pending] = splitArray(tasks, task => task.completed);
|
||||||
|
|
||||||
|
expect(completed).toHaveLength(2);
|
||||||
|
expect(pending).toHaveLength(2);
|
||||||
|
expect(completed.every(t => t.completed)).toBe(true);
|
||||||
|
expect(pending.every(t => !t.completed)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should separate adults from minors by age', () => {
|
||||||
|
interface Person {
|
||||||
|
name: string;
|
||||||
|
age: number;
|
||||||
|
}
|
||||||
|
const people: Person[] = [
|
||||||
|
{ name: 'Alice', age: 17 },
|
||||||
|
{ name: 'Bob', age: 25 },
|
||||||
|
{ name: 'Charlie', age: 16 },
|
||||||
|
{ name: 'Diana', age: 30 },
|
||||||
|
{ name: 'Eve', age: 18 },
|
||||||
|
];
|
||||||
|
const [adults, minors] = splitArray(people, person => person.age >= 18);
|
||||||
|
|
||||||
|
expect(adults).toEqual([
|
||||||
|
{ name: 'Bob', age: 25 },
|
||||||
|
{ name: 'Diana', age: 30 },
|
||||||
|
{ name: 'Eve', age: 18 },
|
||||||
|
]);
|
||||||
|
expect(minors).toEqual([
|
||||||
|
{ name: 'Alice', age: 17 },
|
||||||
|
{ name: 'Charlie', age: 16 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should separate truthy from falsy values', () => {
|
||||||
|
const mixed = [0, 1, false, true, '', 'hello', null, undefined, [], [0]];
|
||||||
|
const [truthy, falsy] = splitArray(mixed, Boolean);
|
||||||
|
|
||||||
|
expect(truthy).toEqual([1, true, 'hello', [], [0]]);
|
||||||
|
expect(falsy).toEqual([0, false, '', null, undefined]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
319
src/shared/lib/utils/throttle/throttle.test.ts
Normal file
319
src/shared/lib/utils/throttle/throttle.test.ts
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import { throttle } from './throttle';
|
||||||
|
|
||||||
|
describe('throttle', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Basic Functionality', () => {
|
||||||
|
it('should execute function immediately on first call', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttled = throttle(mockFn, 300);
|
||||||
|
|
||||||
|
throttled('arg1', 'arg2');
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throttle subsequent calls within wait period', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttled = throttle(mockFn, 300);
|
||||||
|
|
||||||
|
throttled('first');
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Call again within wait period - should not execute
|
||||||
|
throttled('second');
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Advance time past wait period
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
// Now trailing call executes
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFn).toHaveBeenLastCalledWith('second');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow execution after wait period expires', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttled = throttle(mockFn, 100);
|
||||||
|
|
||||||
|
throttled('first');
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
throttled('second');
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Trailing Edge Execution', () => {
|
||||||
|
it('should execute throttled call after wait period', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttled = throttle(mockFn, 300);
|
||||||
|
|
||||||
|
throttled('first');
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
throttled('second');
|
||||||
|
throttled('third');
|
||||||
|
// Still 1 because these are throttled
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
|
||||||
|
// Trailing call executes
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFn).toHaveBeenLastCalledWith('third');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel previous trailing call on new invocation', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttled = throttle(mockFn, 100);
|
||||||
|
|
||||||
|
throttled('first');
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
throttled('second');
|
||||||
|
vi.advanceTimersByTime(30);
|
||||||
|
throttled('third');
|
||||||
|
|
||||||
|
// At this point only first call executed
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Advance to trigger trailing call
|
||||||
|
vi.advanceTimersByTime(70);
|
||||||
|
|
||||||
|
// First call + trailing (third)
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFn).toHaveBeenLastCalledWith('third');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Arguments and Context', () => {
|
||||||
|
it('should pass the correct arguments from the last throttled call', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttled = throttle(mockFn, 100);
|
||||||
|
|
||||||
|
throttled('arg1', 'arg2');
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
throttled('arg3', 'arg4');
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFn).toHaveBeenLastCalledWith('arg3', 'arg4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle no arguments', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttled = throttle(mockFn, 100);
|
||||||
|
|
||||||
|
throttled();
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single argument', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttled = throttle(mockFn, 100);
|
||||||
|
|
||||||
|
throttled('single');
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockFn).toHaveBeenCalledWith('single');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple arguments', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttled = throttle(mockFn, 100);
|
||||||
|
|
||||||
|
throttled(1, 2, 3, 'four', { five: 5 });
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockFn).toHaveBeenCalledWith(1, 2, 3, 'four', { five: 5 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Timing', () => {
|
||||||
|
it('should handle very short wait times (1ms)', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttled = throttle(mockFn, 1);
|
||||||
|
|
||||||
|
throttled('first');
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
throttled('second');
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle longer wait times (1000ms)', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttled = throttle(mockFn, 1000);
|
||||||
|
|
||||||
|
throttled('first');
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
throttled('second');
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rapid Calls', () => {
|
||||||
|
it('should handle rapid successive calls correctly', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttled = throttle(mockFn, 100);
|
||||||
|
|
||||||
|
throttled('call1');
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
throttled('call2');
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
throttled('call3');
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockFn).toHaveBeenCalledWith('call1');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockFn).toHaveBeenLastCalledWith('call3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should execute function at most once per wait period plus trailing', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttled = throttle(mockFn, 100);
|
||||||
|
|
||||||
|
// Make many rapid calls
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
vi.advanceTimersByTime(5);
|
||||||
|
throttled(`call${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should execute immediately
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
// Plus trailing call
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
it('should handle zero wait time', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttled = throttle(mockFn, 0);
|
||||||
|
|
||||||
|
throttled('first');
|
||||||
|
|
||||||
|
// With zero wait time, function may execute synchronously
|
||||||
|
// but the internal timing may still prevent immediate re-execution
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle being called at exactly wait boundary', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttled = throttle(mockFn, 100);
|
||||||
|
|
||||||
|
throttled('first');
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
throttled('second');
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Return Value', () => {
|
||||||
|
it('should not return anything (void)', () => {
|
||||||
|
const mockFn = vi.fn().mockReturnValue('result');
|
||||||
|
const throttled = throttle(mockFn, 100);
|
||||||
|
|
||||||
|
const result = throttled('arg');
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Real-World Scenarios', () => {
|
||||||
|
it('should throttle scroll-like events', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttledScroll = throttle(mockFn, 100);
|
||||||
|
|
||||||
|
throttledScroll();
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
throttledScroll();
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
throttledScroll();
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throttle resize-like events', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttledResize = throttle(mockFn, 200);
|
||||||
|
|
||||||
|
throttledResize();
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
throttledResize();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Comparison Characteristics', () => {
|
||||||
|
it('should execute immediately on first call', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const throttled = throttle(mockFn, 300);
|
||||||
|
|
||||||
|
throttled('first');
|
||||||
|
|
||||||
|
// Throttle executes immediately (unlike debounce)
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow execution during continuous calls at intervals', () => {
|
||||||
|
const mockFn = vi.fn();
|
||||||
|
const waitTime = 100;
|
||||||
|
const throttled = throttle(mockFn, waitTime);
|
||||||
|
|
||||||
|
throttled('call1');
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(waitTime);
|
||||||
|
throttled('call2');
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(waitTime);
|
||||||
|
throttled('call3');
|
||||||
|
expect(mockFn).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
32
src/shared/lib/utils/throttle/throttle.ts
Normal file
32
src/shared/lib/utils/throttle/throttle.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Throttle function execution to a maximum frequency.
|
||||||
|
*
|
||||||
|
* @param fn Function to throttle.
|
||||||
|
* @param wait Maximum time between function calls.
|
||||||
|
* @returns Throttled function.
|
||||||
|
*/
|
||||||
|
export function throttle<T extends (...args: any[]) => any>(
|
||||||
|
fn: T,
|
||||||
|
wait: number,
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let lastCall = 0;
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastCall = now - lastCall;
|
||||||
|
|
||||||
|
if (timeSinceLastCall >= wait) {
|
||||||
|
lastCall = now;
|
||||||
|
fn(...args);
|
||||||
|
} else {
|
||||||
|
// Schedule for end of wait period
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
lastCall = Date.now();
|
||||||
|
fn(...args);
|
||||||
|
timeoutId = null;
|
||||||
|
}, wait - timeSinceLastCall);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
7
src/shared/shadcn/ui/drawer/drawer-close.svelte
Normal file
7
src/shared/shadcn/ui/drawer/drawer-close.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.CloseProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPrimitive.Close bind:ref data-slot="drawer-close" {...restProps} />
|
||||||
39
src/shared/shadcn/ui/drawer/drawer-content.svelte
Normal file
39
src/shared/shadcn/ui/drawer/drawer-content.svelte
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||||
|
import type { WithoutChildrenOrChild } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||||
|
import DrawerOverlay from './drawer-overlay.svelte';
|
||||||
|
import DrawerPortal from './drawer-portal.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: DrawerPrimitive.ContentProps & {
|
||||||
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DrawerPortal>>;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPortal {...portalProps}>
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="drawer-content"
|
||||||
|
class={cn(
|
||||||
|
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
|
||||||
|
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
|
||||||
|
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
|
||||||
|
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:end-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-s data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||||
|
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:start-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-e data-[vaul-drawer-direction=left]:sm:max-w-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<div class="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block">
|
||||||
|
</div>
|
||||||
|
{@render children?.()}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
17
src/shared/shadcn/ui/drawer/drawer-description.svelte
Normal file
17
src/shared/shadcn/ui/drawer/drawer-description.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||||
|
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DrawerPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
data-slot="drawer-description"
|
||||||
|
class={cn('text-muted-foreground text-sm', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
23
src/shared/shadcn/ui/drawer/drawer-footer.svelte
Normal file
23
src/shared/shadcn/ui/drawer/drawer-footer.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
type WithElementRef,
|
||||||
|
cn,
|
||||||
|
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
class={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
23
src/shared/shadcn/ui/drawer/drawer-header.svelte
Normal file
23
src/shared/shadcn/ui/drawer/drawer-header.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
type WithElementRef,
|
||||||
|
cn,
|
||||||
|
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="drawer-header"
|
||||||
|
class={cn('flex flex-col gap-1.5 p-4', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
12
src/shared/shadcn/ui/drawer/drawer-nested.svelte
Normal file
12
src/shared/shadcn/ui/drawer/drawer-nested.svelte
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
shouldScaleBackground = true,
|
||||||
|
open = $bindable(false),
|
||||||
|
activeSnapPoint = $bindable(null),
|
||||||
|
...restProps
|
||||||
|
}: DrawerPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPrimitive.NestedRoot {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />
|
||||||
20
src/shared/shadcn/ui/drawer/drawer-overlay.svelte
Normal file
20
src/shared/shadcn/ui/drawer/drawer-overlay.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||||
|
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DrawerPrimitive.OverlayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
class={cn(
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
7
src/shared/shadcn/ui/drawer/drawer-portal.svelte
Normal file
7
src/shared/shadcn/ui/drawer/drawer-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||||
|
|
||||||
|
let { ...restProps }: DrawerPrimitive.PortalProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPrimitive.Portal {...restProps} />
|
||||||
17
src/shared/shadcn/ui/drawer/drawer-title.svelte
Normal file
17
src/shared/shadcn/ui/drawer/drawer-title.svelte
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||||
|
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DrawerPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
data-slot="drawer-title"
|
||||||
|
class={cn('text-foreground font-semibold', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
7
src/shared/shadcn/ui/drawer/drawer-trigger.svelte
Normal file
7
src/shared/shadcn/ui/drawer/drawer-trigger.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPrimitive.Trigger bind:ref data-slot="drawer-trigger" {...restProps} />
|
||||||
12
src/shared/shadcn/ui/drawer/drawer.svelte
Normal file
12
src/shared/shadcn/ui/drawer/drawer.svelte
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
shouldScaleBackground = true,
|
||||||
|
open = $bindable(false),
|
||||||
|
activeSnapPoint = $bindable(null),
|
||||||
|
...restProps
|
||||||
|
}: DrawerPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerPrimitive.Root {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />
|
||||||
37
src/shared/shadcn/ui/drawer/index.ts
Normal file
37
src/shared/shadcn/ui/drawer/index.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import Close from './drawer-close.svelte';
|
||||||
|
import Content from './drawer-content.svelte';
|
||||||
|
import Description from './drawer-description.svelte';
|
||||||
|
import Footer from './drawer-footer.svelte';
|
||||||
|
import Header from './drawer-header.svelte';
|
||||||
|
import NestedRoot from './drawer-nested.svelte';
|
||||||
|
import Overlay from './drawer-overlay.svelte';
|
||||||
|
import Portal from './drawer-portal.svelte';
|
||||||
|
import Title from './drawer-title.svelte';
|
||||||
|
import Trigger from './drawer-trigger.svelte';
|
||||||
|
import Root from './drawer.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Close,
|
||||||
|
Close as DrawerClose,
|
||||||
|
Content,
|
||||||
|
Content as DrawerContent,
|
||||||
|
Description,
|
||||||
|
Description as DrawerDescription,
|
||||||
|
Footer,
|
||||||
|
Footer as DrawerFooter,
|
||||||
|
Header,
|
||||||
|
Header as DrawerHeader,
|
||||||
|
NestedRoot,
|
||||||
|
NestedRoot as DrawerNestedRoot,
|
||||||
|
Overlay,
|
||||||
|
Overlay as DrawerOverlay,
|
||||||
|
Portal,
|
||||||
|
Portal as DrawerPortal,
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Drawer,
|
||||||
|
Title,
|
||||||
|
Title as DrawerTitle,
|
||||||
|
Trigger,
|
||||||
|
Trigger as DrawerTrigger,
|
||||||
|
};
|
||||||
@@ -44,7 +44,7 @@ let {
|
|||||||
<SliderPrimitive.Thumb
|
<SliderPrimitive.Thumb
|
||||||
data-slot="slider-thumb"
|
data-slot="slider-thumb"
|
||||||
index={thumb}
|
index={thumb}
|
||||||
class="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
class="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-background shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
1
src/shared/shadcn/ui/spinner/index.ts
Normal file
1
src/shared/shadcn/ui/spinner/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as Spinner } from './spinner.svelte';
|
||||||
14
src/shared/shadcn/ui/spinner/spinner.svelte
Normal file
14
src/shared/shadcn/ui/spinner/spinner.svelte
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||||
|
import Loader2Icon from '@lucide/svelte/icons/loader-2';
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
|
let { class: className, ...restProps }: ComponentProps<typeof Loader2Icon> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Loader2Icon
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading"
|
||||||
|
class={cn('size-4 animate-spin', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -54,7 +54,7 @@ const hasSelection = $derived(selectedCount > 0);
|
|||||||
class="w-full bg-card transition-colors hover:bg-accent/5"
|
class="w-full bg-card transition-colors hover:bg-accent/5"
|
||||||
>
|
>
|
||||||
<!-- Trigger row: title, expand indicator, and optional count badge -->
|
<!-- Trigger row: title, expand indicator, and optional count badge -->
|
||||||
<div class="flex items-center justify-between px-4 py-2">
|
<div class="flex items-center justify-between px-3 sm:px-4 py-2">
|
||||||
<CollapsibleTrigger
|
<CollapsibleTrigger
|
||||||
class={buttonVariants({
|
class={buttonVariants({
|
||||||
variant: 'ghost',
|
variant: 'ghost',
|
||||||
@@ -62,14 +62,14 @@ const hasSelection = $derived(selectedCount > 0);
|
|||||||
class: 'flex-1 justify-between gap-2 hover:bg-transparent focus-visible:ring-1 focus-visible:ring-ring',
|
class: 'flex-1 justify-between gap-2 hover:bg-transparent focus-visible:ring-1 focus-visible:ring-ring',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<h4 class="text-sm font-semibold">{displayedLabel}</h4>
|
<h4 class="text-xs sm:text-sm font-semibold">{displayedLabel}</h4>
|
||||||
|
|
||||||
<!-- Badge only appears when items are selected to avoid clutter -->
|
<!-- Badge only appears when items are selected to avoid clutter -->
|
||||||
{#if hasSelection}
|
{#if hasSelection}
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
data-testid="badge"
|
data-testid="badge"
|
||||||
class="mr-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums"
|
class="mr-auto h-4 sm:h-5 min-w-4 sm:min-w-5 px-1 sm:px-1.5 text-[10px] sm:text-xs font-medium tabular-nums"
|
||||||
>
|
>
|
||||||
{selectedCount}
|
{selectedCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -81,7 +81,7 @@ const hasSelection = $derived(selectedCount > 0);
|
|||||||
class="shrink-0 transition-transform duration-200 ease-out"
|
class="shrink-0 transition-transform duration-200 ease-out"
|
||||||
style:transform={isOpen ? 'rotate(0deg)' : 'rotate(-90deg)'}
|
style:transform={isOpen ? 'rotate(0deg)' : 'rotate(-90deg)'}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon class="h-4 w-4" />
|
<ChevronDownIcon class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import PlusIcon from '@lucide/svelte/icons/plus';
|
|||||||
import type { ChangeEventHandler } from 'svelte/elements';
|
import type { ChangeEventHandler } from 'svelte/elements';
|
||||||
import IconButton from '../IconButton/IconButton.svelte';
|
import IconButton from '../IconButton/IconButton.svelte';
|
||||||
|
|
||||||
interface ComboControlProps {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Text for increase button aria-label
|
* Text for increase button aria-label
|
||||||
*/
|
*/
|
||||||
@@ -43,6 +43,10 @@ interface ComboControlProps {
|
|||||||
* Control instance
|
* Control instance
|
||||||
*/
|
*/
|
||||||
control: TypographyControl;
|
control: TypographyControl;
|
||||||
|
/**
|
||||||
|
* Reduced amount of controls
|
||||||
|
*/
|
||||||
|
reduced?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -50,7 +54,8 @@ const {
|
|||||||
decreaseLabel,
|
decreaseLabel,
|
||||||
increaseLabel,
|
increaseLabel,
|
||||||
controlLabel,
|
controlLabel,
|
||||||
}: ComboControlProps = $props();
|
reduced = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
// Local state for the slider to prevent infinite loops
|
// Local state for the slider to prevent infinite loops
|
||||||
// svelte-ignore state_referenced_locally - $state captures initial value, $effect syncs updates
|
// svelte-ignore state_referenced_locally - $state captures initial value, $effect syncs updates
|
||||||
@@ -80,23 +85,25 @@ const handleSliderChange = (newValue: number) => {
|
|||||||
<TooltipRoot>
|
<TooltipRoot>
|
||||||
<ButtonGroupRoot class="bg-transparent border-none shadow-none">
|
<ButtonGroupRoot class="bg-transparent border-none shadow-none">
|
||||||
<TooltipTrigger class="flex items-center">
|
<TooltipTrigger class="flex items-center">
|
||||||
<IconButton
|
{#if !reduced}
|
||||||
onclick={control.decrease}
|
<IconButton
|
||||||
disabled={control.isAtMin}
|
onclick={control.decrease}
|
||||||
aria-label={decreaseLabel}
|
disabled={control.isAtMin}
|
||||||
rotation="counterclockwise"
|
aria-label={decreaseLabel}
|
||||||
>
|
rotation="counterclockwise"
|
||||||
{#snippet icon({ className })}
|
>
|
||||||
<MinusIcon class={className} />
|
{#snippet icon({ className })}
|
||||||
{/snippet}
|
<MinusIcon class={className} />
|
||||||
</IconButton>
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
<PopoverRoot>
|
<PopoverRoot>
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<Button
|
<Button
|
||||||
{...props}
|
{...props}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="hover:bg-white/50 hover:font-bold bg-white/20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer"
|
class="hover:bg-background-50 hover:font-bold bg-background-20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label={controlLabel}
|
aria-label={controlLabel}
|
||||||
>
|
>
|
||||||
@@ -127,16 +134,18 @@ const handleSliderChange = (newValue: number) => {
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</PopoverRoot>
|
</PopoverRoot>
|
||||||
|
|
||||||
<IconButton
|
{#if !reduced}
|
||||||
aria-label={increaseLabel}
|
<IconButton
|
||||||
onclick={control.increase}
|
aria-label={increaseLabel}
|
||||||
disabled={control.isAtMax}
|
onclick={control.increase}
|
||||||
rotation="clockwise"
|
disabled={control.isAtMax}
|
||||||
>
|
rotation="clockwise"
|
||||||
{#snippet icon({ className })}
|
>
|
||||||
<PlusIcon class={className} />
|
{#snippet icon({ className })}
|
||||||
{/snippet}
|
<PlusIcon class={className} />
|
||||||
</IconButton>
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
{/if}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</ButtonGroupRoot>
|
</ButtonGroupRoot>
|
||||||
{#if controlLabel}
|
{#if controlLabel}
|
||||||
|
|||||||
111
src/shared/ui/ComboControlV2/ComboControlV2.stories.svelte
Normal file
111
src/shared/ui/ComboControlV2/ComboControlV2.stories.svelte
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<script module>
|
||||||
|
import { createTypographyControl } from '$shared/lib';
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import ComboControlV2 from './ComboControlV2.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Shared/ComboControlV2',
|
||||||
|
component: ComboControlV2,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'ComboControl with input field and slider. Simplified version without increase/decrease buttons.',
|
||||||
|
},
|
||||||
|
story: { inline: false }, // Render stories in iframe for state isolation
|
||||||
|
},
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
orientation: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['horizontal', 'vertical'],
|
||||||
|
description: 'Orientation of the ComboControl',
|
||||||
|
defaultValue: 'vertical',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Label for the ComboControl',
|
||||||
|
},
|
||||||
|
control: {
|
||||||
|
control: 'object',
|
||||||
|
description: 'TypographyControl instance managing the value and bounds',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
const horizontalControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
|
||||||
|
const verticalControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
|
||||||
|
const floatControl = createTypographyControl({ min: 0, max: 1, step: 0.01, value: 0.5 });
|
||||||
|
const atMinControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 0 });
|
||||||
|
const atMaxControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 100 });
|
||||||
|
const largeRangeControl = createTypographyControl({ min: 0, max: 1000, step: 10, value: 500 });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="Horizontal"
|
||||||
|
args={{
|
||||||
|
control: horizontalControl,
|
||||||
|
orientation: 'horizontal',
|
||||||
|
label: 'Size',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ComboControlV2 control={horizontalControl} orientation="horizontal" label="Size" />
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="Vertical"
|
||||||
|
args={{
|
||||||
|
control: verticalControl,
|
||||||
|
orientation: 'vertical',
|
||||||
|
label: 'Size',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ComboControlV2 control={verticalControl} orientation="vertical" class="h-48" label="Size" />
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="With Float Values"
|
||||||
|
args={{
|
||||||
|
control: floatControl,
|
||||||
|
orientation: 'vertical',
|
||||||
|
label: 'Opacity',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ComboControlV2 control={floatControl} orientation="vertical" class="h-48" label="Opacity" />
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="At Minimum"
|
||||||
|
args={{
|
||||||
|
control: atMinControl,
|
||||||
|
orientation: 'horizontal',
|
||||||
|
label: 'Size',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ComboControlV2 control={atMinControl} orientation="horizontal" label="Size" />
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="At Maximum"
|
||||||
|
args={{
|
||||||
|
control: atMaxControl,
|
||||||
|
orientation: 'horizontal',
|
||||||
|
label: 'Size',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ComboControlV2 control={atMaxControl} orientation="horizontal" label="Size" />
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="Large Range"
|
||||||
|
args={{
|
||||||
|
control: largeRangeControl,
|
||||||
|
orientation: 'horizontal',
|
||||||
|
label: 'Scale',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ComboControlV2 control={largeRangeControl} orientation="horizontal" label="Scale" />
|
||||||
|
</Story>
|
||||||
@@ -4,69 +4,228 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { TypographyControl } from '$shared/lib';
|
import type { TypographyControl } from '$shared/lib';
|
||||||
import { Input } from '$shared/shadcn/ui/input';
|
import { Button } from '$shared/shadcn/ui/button';
|
||||||
import { Slider } from '$shared/shadcn/ui/slider';
|
import { Root as ButtonGroupRoot } from '$shared/shadcn/ui/button-group';
|
||||||
|
import {
|
||||||
|
Content as PopoverContent,
|
||||||
|
Root as PopoverRoot,
|
||||||
|
Trigger as PopoverTrigger,
|
||||||
|
} from '$shared/shadcn/ui/popover';
|
||||||
|
import {
|
||||||
|
Content as TooltipContent,
|
||||||
|
Root as TooltipRoot,
|
||||||
|
Trigger as TooltipTrigger,
|
||||||
|
} from '$shared/shadcn/ui/tooltip';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import type { Snippet } from 'svelte';
|
import { Input } from '$shared/ui';
|
||||||
|
import { Slider } from '$shared/ui';
|
||||||
|
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||||
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
|
import {
|
||||||
|
type Orientation,
|
||||||
|
REGEXP_ONLY_DIGITS,
|
||||||
|
} from 'bits-ui';
|
||||||
import type { ChangeEventHandler } from 'svelte/elements';
|
import type { ChangeEventHandler } from 'svelte/elements';
|
||||||
|
import IconButton from '../IconButton/IconButton.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Control instance
|
||||||
|
*/
|
||||||
control: TypographyControl;
|
control: TypographyControl;
|
||||||
ref?: Snippet;
|
/**
|
||||||
|
* Orientation
|
||||||
|
*/
|
||||||
|
orientation?: Orientation;
|
||||||
|
/**
|
||||||
|
* Label text
|
||||||
|
*/
|
||||||
|
label?: string;
|
||||||
|
/**
|
||||||
|
* CSS class
|
||||||
|
*/
|
||||||
|
class?: string;
|
||||||
|
/**
|
||||||
|
* Show scale flag
|
||||||
|
*/
|
||||||
|
showScale?: boolean;
|
||||||
|
/**
|
||||||
|
* Flag that change component appearance
|
||||||
|
* from the one with increase/decrease buttons and popover with input + slider
|
||||||
|
* to just input + slider
|
||||||
|
*/
|
||||||
|
reduced?: boolean;
|
||||||
|
/**
|
||||||
|
* Text for increase button aria-label
|
||||||
|
*/
|
||||||
|
increaseLabel?: string;
|
||||||
|
/**
|
||||||
|
* Text for decrease button aria-label
|
||||||
|
*/
|
||||||
|
decreaseLabel?: string;
|
||||||
|
/**
|
||||||
|
* Text for control button aria-label
|
||||||
|
*/
|
||||||
|
controlLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
control,
|
control,
|
||||||
ref = $bindable(),
|
orientation = 'vertical',
|
||||||
|
label,
|
||||||
|
class: className,
|
||||||
|
showScale = true,
|
||||||
|
reduced = false,
|
||||||
|
increaseLabel = 'Increase',
|
||||||
|
decreaseLabel = 'Decrease',
|
||||||
|
controlLabel,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let sliderValue = $state(Number(control.value));
|
let inputValue = $state(String(control.value));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
sliderValue = Number(control.value);
|
inputValue = String(control.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
|
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
|
||||||
const parsedValue = parseFloat(event.currentTarget.value);
|
const parsedValue = parseFloat(event.currentTarget.value);
|
||||||
if (!isNaN(parsedValue)) {
|
if (!isNaN(parsedValue)) {
|
||||||
control.value = parsedValue;
|
control.value = parsedValue;
|
||||||
|
inputValue = String(parsedValue);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSliderChange = (newValue: number) => {
|
function calculateScale(index: number): number | string {
|
||||||
control.value = newValue;
|
const calculate = () =>
|
||||||
};
|
orientation === 'horizontal'
|
||||||
|
? control.min + (index * (control.max - control.min)) / 4
|
||||||
// Shared glass button class for consistency
|
: control.max - (index * (control.max - control.min)) / 4;
|
||||||
// const glassBtnClass = cn(
|
return Number.isInteger(control.step)
|
||||||
// 'border-none transition-all duration-200',
|
? Math.round(calculate())
|
||||||
// 'bg-white/10 hover:bg-white/40 active:scale-90',
|
: calculate().toFixed(2);
|
||||||
// 'text-slate-900 font-medium',
|
}
|
||||||
// );
|
|
||||||
|
|
||||||
// const ghostStyle = cn(
|
|
||||||
// 'flex items-center justify-center transition-all duration-300 ease-out',
|
|
||||||
// 'text-slate-900/40 hover:text-slate-950 hover:bg-white/20 active:scale-90',
|
|
||||||
// 'disabled:opacity-10 disabled:pointer-events-none',
|
|
||||||
// );
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col items-center gap-4">
|
{#snippet ComboControl()}
|
||||||
<Input
|
<div
|
||||||
value={control.value}
|
class={cn(
|
||||||
onchange={handleInputChange}
|
'flex gap-4 sm:py-4 sm:px-1 rounded-xl transition-all duration-300',
|
||||||
min={control.min}
|
'',
|
||||||
max={control.max}
|
orientation === 'horizontal'
|
||||||
class="w-14 h-8 text-xs text-center bg-white/40 border-none rounded-lg focus-visible:ring-indigo-500/50"
|
? 'flex-row items-end w-full'
|
||||||
/>
|
: 'flex-col items-center h-full',
|
||||||
<Slider
|
className,
|
||||||
min={control.min}
|
)}
|
||||||
max={control.max}
|
>
|
||||||
step={control.step}
|
<div class={cn('relative', orientation === 'horizontal' ? 'w-full' : 'h-full')}>
|
||||||
value={sliderValue}
|
{#if showScale}
|
||||||
onValueChange={handleSliderChange}
|
<div
|
||||||
type="single"
|
class={cn(
|
||||||
orientation="vertical"
|
'absolute flex justify-between',
|
||||||
class="h-30"
|
orientation === 'horizontal'
|
||||||
/>
|
? 'flex-row w-full -top-8 px-0.5'
|
||||||
</div>
|
: 'flex-col h-full -left-5 py-0.5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{#each Array(5) as _, i}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'flex items-center gap-1.5',
|
||||||
|
orientation === 'horizontal' ? 'flex-col' : 'flex-row',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span class="font-mono text-[0.375rem] text-text-muted tabular-nums">
|
||||||
|
{calculateScale(i)}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'bg-border-muted',
|
||||||
|
orientation === 'horizontal' ? 'w-px h-1' : 'h-px w-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Slider
|
||||||
|
class={cn(orientation === 'horizontal' ? 'w-full' : 'h-full')}
|
||||||
|
bind:value={control.value}
|
||||||
|
min={control.min}
|
||||||
|
max={control.max}
|
||||||
|
step={control.step}
|
||||||
|
{label}
|
||||||
|
{orientation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !reduced}
|
||||||
|
<Input
|
||||||
|
class="h-10 rounded-lg w-12 pl-1 pr-1 sm:pr-1 md:pr-1 sm:pl-1 md:pl-1 text-center"
|
||||||
|
value={inputValue}
|
||||||
|
onchange={handleInputChange}
|
||||||
|
min={control.min}
|
||||||
|
max={control.max}
|
||||||
|
step={control.step}
|
||||||
|
pattern={REGEXP_ONLY_DIGITS}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if reduced}
|
||||||
|
{@render ComboControl()}
|
||||||
|
{:else}
|
||||||
|
<TooltipRoot>
|
||||||
|
<ButtonGroupRoot class="bg-transparent border-none shadow-none">
|
||||||
|
<TooltipTrigger class="flex items-center">
|
||||||
|
<IconButton
|
||||||
|
onclick={control.decrease}
|
||||||
|
disabled={control.isAtMin}
|
||||||
|
aria-label={decreaseLabel}
|
||||||
|
rotation="counterclockwise"
|
||||||
|
>
|
||||||
|
{#snippet icon({ className })}
|
||||||
|
<MinusIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
<PopoverRoot>
|
||||||
|
<PopoverTrigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
variant="ghost"
|
||||||
|
class="hover:bg-background-50 hover:font-bold bg-background-20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer"
|
||||||
|
size="icon"
|
||||||
|
aria-label={controlLabel}
|
||||||
|
>
|
||||||
|
{control.value}
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-auto h-64 sm:px-1 py-0">
|
||||||
|
{@render ComboControl()}
|
||||||
|
</PopoverContent>
|
||||||
|
</PopoverRoot>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
aria-label={increaseLabel}
|
||||||
|
onclick={control.increase}
|
||||||
|
disabled={control.isAtMax}
|
||||||
|
rotation="clockwise"
|
||||||
|
>
|
||||||
|
{#snippet icon({ className })}
|
||||||
|
<PlusIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
</ButtonGroupRoot>
|
||||||
|
{#if controlLabel}
|
||||||
|
<TooltipContent>
|
||||||
|
{controlLabel}
|
||||||
|
</TooltipContent>
|
||||||
|
{/if}
|
||||||
|
</TooltipRoot>
|
||||||
|
{/if}
|
||||||
|
|||||||
41
src/shared/ui/Drawer/Drawer.svelte
Normal file
41
src/shared/ui/Drawer/Drawer.svelte
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!-- Component: Drawer -->
|
||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$shared/shadcn/ui/button';
|
||||||
|
import {
|
||||||
|
Content as DrawerContent,
|
||||||
|
Footer as DrawerFooter,
|
||||||
|
Header as DrawerHeader,
|
||||||
|
Root as DrawerRoot,
|
||||||
|
Trigger as DrawerTrigger,
|
||||||
|
} from '$shared/shadcn/ui/drawer';
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen?: boolean;
|
||||||
|
trigger?: Snippet<[{ isOpen: boolean; onClick: () => void }]>;
|
||||||
|
content?: Snippet<[{ isOpen: boolean; className?: string }]>;
|
||||||
|
contentClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { isOpen = $bindable(false), trigger, content, contentClassName }: Props = $props();
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
isOpen = !isOpen;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DrawerRoot bind:open={isOpen}>
|
||||||
|
<DrawerTrigger>
|
||||||
|
{#if trigger}
|
||||||
|
{@render trigger({ isOpen, onClick: handleClick })}
|
||||||
|
{:else}
|
||||||
|
<Button onclick={handleClick}>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent>
|
||||||
|
{@render content?.({ isOpen, className: cn('min-h-60 px-2 pt-4 pb-8', contentClassName) })}
|
||||||
|
</DrawerContent>
|
||||||
|
</DrawerRoot>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
Animated wrapper for content that can be expanded and collapsed.
|
Animated wrapper for content that can be expanded and collapsed.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { debounce } from '$shared/lib/utils';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
@@ -38,6 +39,10 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
|
|||||||
* Optional badge to render
|
* Optional badge to render
|
||||||
*/
|
*/
|
||||||
badge?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
|
badge?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
|
||||||
|
/**
|
||||||
|
* Callback for when the element's size changes
|
||||||
|
*/
|
||||||
|
onResize?: (rect: DOMRectReadOnly) => void;
|
||||||
/**
|
/**
|
||||||
* Rotation animation direction
|
* Rotation animation direction
|
||||||
* @default 'clockwise'
|
* @default 'clockwise'
|
||||||
@@ -56,6 +61,7 @@ let {
|
|||||||
visibleContent,
|
visibleContent,
|
||||||
hiddenContent,
|
hiddenContent,
|
||||||
badge,
|
badge,
|
||||||
|
onResize,
|
||||||
rotation = 'clockwise',
|
rotation = 'clockwise',
|
||||||
class: className = '',
|
class: className = '',
|
||||||
containerClassName = '',
|
containerClassName = '',
|
||||||
@@ -64,7 +70,7 @@ let {
|
|||||||
|
|
||||||
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
|
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
export const xSpring = new Spring(0, {
|
const xSpring = new Spring(0, {
|
||||||
stiffness: 0.14, // Lower is slower
|
stiffness: 0.14, // Lower is slower
|
||||||
damping: 0.5, // Settle
|
damping: 0.5, // Settle
|
||||||
});
|
});
|
||||||
@@ -79,7 +85,7 @@ const scaleSpring = new Spring(1, {
|
|||||||
damping: 0.65,
|
damping: 0.65,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const rotateSpring = new Spring(0, {
|
const rotateSpring = new Spring(0, {
|
||||||
stiffness: 0.12,
|
stiffness: 0.12,
|
||||||
damping: 0.55,
|
damping: 0.55,
|
||||||
});
|
});
|
||||||
@@ -107,6 +113,9 @@ function handleKeyDown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create debounced recize callback
|
||||||
|
const debouncedResize = debounce((entry: ResizeObserverEntry) => onResize?.(entry.contentRect), 50);
|
||||||
|
|
||||||
// Elevation and scale on activation
|
// Elevation and scale on activation
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (expanded && !disabled) {
|
if (expanded && !disabled) {
|
||||||
@@ -149,6 +158,21 @@ $effect(() => {
|
|||||||
expanded = false;
|
expanded = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Use an effect to watch the element's actual physical size
|
||||||
|
$effect(() => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(entries => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (entry) {
|
||||||
|
debouncedResize(entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -158,7 +182,7 @@ $effect(() => {
|
|||||||
role="button"
|
role="button"
|
||||||
tabindex={0}
|
tabindex={0}
|
||||||
class={cn(
|
class={cn(
|
||||||
'will-change-transform duration-300',
|
'will-change-[transform, width, height] duration-300',
|
||||||
disabled ? 'pointer-events-none' : 'pointer-events-auto',
|
disabled ? 'pointer-events-none' : 'pointer-events-auto',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -173,10 +197,10 @@ $effect(() => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
'relative p-2 rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5 backdrop-blur-lg',
|
'relative p-0.5 sm:p-2 rounded-lg sm:rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5 backdrop-blur-lg',
|
||||||
expanded
|
expanded
|
||||||
? 'bg-white/5 border-indigo-400/40 shadow-[0_30px_70px_-10px_rgba(99,102,241,0.25)]'
|
? 'bg-background-20 border-indigo-400/40 shadow-[0_30px_70px_-10px_rgba(99,102,241,0.25)]'
|
||||||
: ' bg-white/25 border-white/40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]',
|
: 'bg-background-40 border-background-40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]',
|
||||||
disabled && 'opacity-80 grayscale-[0.2]',
|
disabled && 'opacity-80 grayscale-[0.2]',
|
||||||
containerClassName,
|
containerClassName,
|
||||||
)}
|
)}
|
||||||
|
|||||||
31
src/shared/ui/Footnote/Footnote.stories.svelte
Normal file
31
src/shared/ui/Footnote/Footnote.stories.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script module>
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import Footnote from './Footnote.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Shared/Footnote',
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: 'Styles footnote text',
|
||||||
|
},
|
||||||
|
story: { inline: false }, // Render stories in iframe for state isolation
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story name="Default">
|
||||||
|
<Footnote>
|
||||||
|
Footnote
|
||||||
|
</Footnote>
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="With custom render">
|
||||||
|
<Footnote>
|
||||||
|
{#snippet render({ class: className })}
|
||||||
|
<span class={className}>Footnote</span>
|
||||||
|
{/snippet}
|
||||||
|
</Footnote>
|
||||||
|
</Story>
|
||||||
37
src/shared/ui/Footnote/Footnote.svelte
Normal file
37
src/shared/ui/Footnote/Footnote.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<!--
|
||||||
|
Component: Footnote
|
||||||
|
Provides classes for styling footnotes
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children?: Snippet;
|
||||||
|
class?: string;
|
||||||
|
/**
|
||||||
|
* Custom render function for full control
|
||||||
|
*/
|
||||||
|
render?: Snippet<[{ class: string }]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { children, class: className, render }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if render}
|
||||||
|
{@render render({
|
||||||
|
class: cn(
|
||||||
|
'font-mono text-[0.5625rem] sm:text-[0.625rem] lowercase tracking-[0.2em] text-text-soft',
|
||||||
|
className,
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
{:else if children}
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
'font-mono text-[0.5625rem] sm:text-[0.625rem] lowercase tracking-[0.2em] text-text-soft',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
101
src/shared/ui/IconButton/IconButton.stories.svelte
Normal file
101
src/shared/ui/IconButton/IconButton.stories.svelte
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<script module>
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import IconButton from './IconButton.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Shared/IconButton',
|
||||||
|
component: IconButton,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'Icon button with rotation animation on click. Features clockwise/counterclockwise rotation options and icon snippet support for flexible icon rendering.',
|
||||||
|
},
|
||||||
|
story: { inline: false },
|
||||||
|
},
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
rotation: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['clockwise', 'counterclockwise'],
|
||||||
|
description: 'Direction of rotation animation on click',
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
control: 'object',
|
||||||
|
description: 'Icon snippet to render (required)',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: 'Disable the button',
|
||||||
|
},
|
||||||
|
onclick: {
|
||||||
|
action: 'clicked',
|
||||||
|
description: 'Click handler',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||||
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
|
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||||
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
|
import SettingsIcon from '@lucide/svelte/icons/settings';
|
||||||
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet chevronRightIcon({ className }: { className: string })}
|
||||||
|
<ChevronRight class={className} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet chevronLeftIcon({ className }: { className: string })}
|
||||||
|
<ChevronLeft class={className} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet plusIcon({ className }: { className: string })}
|
||||||
|
<PlusIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet minusIcon({ className }: { className: string })}
|
||||||
|
<MinusIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet settingsIcon({ className }: { className: string })}
|
||||||
|
<SettingsIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet xIcon({ className }: { className: string })}
|
||||||
|
<XIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="Default"
|
||||||
|
args={{
|
||||||
|
icon: chevronRightIcon,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton onclick={() => console.log('Default clicked')}>
|
||||||
|
{#snippet icon({ className })}
|
||||||
|
<ChevronRight class={className} />
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="Disabled"
|
||||||
|
args={{
|
||||||
|
icon: chevronRightIcon,
|
||||||
|
disabled: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4 items-center">
|
||||||
|
<IconButton disabled>
|
||||||
|
{#snippet icon({ className })}
|
||||||
|
<ChevronRight class={className} />
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</Story>
|
||||||
@@ -29,7 +29,7 @@ let { rotation = 'clockwise', icon, ...rest }: Props = $props();
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="
|
class="
|
||||||
group relative border-none size-9
|
group relative border-none size-9
|
||||||
bg-white/20 hover:bg-white/60
|
bg-background-20 hover:bg-background-60
|
||||||
backdrop-blur-3xl
|
backdrop-blur-3xl
|
||||||
transition-all duration-200 ease-out
|
transition-all duration-200 ease-out
|
||||||
will-change-transform
|
will-change-transform
|
||||||
@@ -41,10 +41,12 @@ let { rotation = 'clockwise', icon, ...rest }: Props = $props();
|
|||||||
size="icon"
|
size="icon"
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{@render icon({
|
{@render icon?.({
|
||||||
className: cn(
|
className: cn(
|
||||||
'size-4 transition-all duration-200 stroke-[1.5] stroke-gray-500 group-hover:stroke-gray-900 group-hover:scale-110 group-hover:stroke-3 group-active:scale-90 group-disabled:stroke-transparent',
|
'size-4 transition-all duration-200 stroke-[1.5] stroke-text-muted group-hover:stroke-foreground group-hover:scale-110 group-hover:stroke-2 group-active:scale-90 group-disabled:stroke-transparent',
|
||||||
rotation === 'clockwise' ? 'group-active:rotate-6' : 'group-active:-rotate-6',
|
rotation === 'clockwise'
|
||||||
|
? 'group-active:rotate-6'
|
||||||
|
: 'group-active:-rotate-6',
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
98
src/shared/ui/Input/Input.stories.svelte
Normal file
98
src/shared/ui/Input/Input.stories.svelte
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<script module>
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import Input from './Input.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Shared/Input',
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: 'Styled input component with size and variant options',
|
||||||
|
},
|
||||||
|
story: { inline: false }, // Render stories in iframe for state isolation
|
||||||
|
},
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
placeholder: {
|
||||||
|
control: 'text',
|
||||||
|
description: "input's placeholder",
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
control: 'text',
|
||||||
|
description: "input's value",
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['default', 'ghost'],
|
||||||
|
description: 'Visual style variant',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['sm', 'md', 'lg'],
|
||||||
|
description: 'Size variant',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
let valueDefault = $state('Initial value');
|
||||||
|
let valueSm = $state('');
|
||||||
|
let valueMd = $state('');
|
||||||
|
let valueLg = $state('');
|
||||||
|
let valueGhostSm = $state('');
|
||||||
|
let valueGhostMd = $state('');
|
||||||
|
let valueGhostLg = $state('');
|
||||||
|
const placeholder = 'Enter text';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Default Story -->
|
||||||
|
<Story name="Default" args={{ placeholder }}>
|
||||||
|
<Input bind:value={valueDefault} {placeholder} />
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<!-- Size Variants -->
|
||||||
|
<Story name="Small" args={{ placeholder }}>
|
||||||
|
<Input bind:value={valueSm} {placeholder} size="sm" />
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Medium" args={{ placeholder }}>
|
||||||
|
<Input bind:value={valueMd} {placeholder} size="md" />
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Large" args={{ placeholder }}>
|
||||||
|
<Input bind:value={valueLg} {placeholder} size="lg" />
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<!-- Ghost Variant with Sizes -->
|
||||||
|
<Story name="Ghost Small" args={{ placeholder }}>
|
||||||
|
<Input bind:value={valueGhostSm} {placeholder} variant="ghost" size="sm" />
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Ghost Medium" args={{ placeholder }}>
|
||||||
|
<Input bind:value={valueGhostMd} {placeholder} variant="ghost" size="md" />
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Ghost Large" args={{ placeholder }}>
|
||||||
|
<Input bind:value={valueGhostLg} {placeholder} variant="ghost" size="lg" />
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<!-- Size Comparison -->
|
||||||
|
<Story name="All Sizes" tags={['!autodocs']}>
|
||||||
|
<div class="flex flex-col gap-4 w-full max-w-md p-8">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-sm font-medium text-text-muted">Small</span>
|
||||||
|
<Input placeholder="Small input" size="sm" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-sm font-medium text-text-muted">Medium</span>
|
||||||
|
<Input placeholder="Medium input" size="md" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span class="text-sm font-medium text-text-muted">Large</span>
|
||||||
|
<Input placeholder="Large input" size="lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Story>
|
||||||
90
src/shared/ui/Input/Input.svelte
Normal file
90
src/shared/ui/Input/Input.svelte
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<!--
|
||||||
|
Component: Input
|
||||||
|
Provides styled input component with all the shadcn input props
|
||||||
|
-->
|
||||||
|
<script lang="ts" module>
|
||||||
|
import { Input as BaseInput } from '$shared/shadcn/ui/input';
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import {
|
||||||
|
type VariantProps,
|
||||||
|
tv,
|
||||||
|
} from 'tailwind-variants';
|
||||||
|
|
||||||
|
export const inputVariants = tv({
|
||||||
|
base: [
|
||||||
|
'w-full backdrop-blur-md border font-medium transition-all duration-200',
|
||||||
|
'focus-visible:border-border-soft focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-muted/30 focus-visible:bg-background-95',
|
||||||
|
'hover:bg-background-95 hover:border-border-soft',
|
||||||
|
'text-foreground placeholder:text-text-muted placeholder:font-mono placeholder:tracking-wide',
|
||||||
|
],
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-background-80 border-border-muted shadow-[0_1px_3px_rgba(0,0,0,0.04)]',
|
||||||
|
ghost: 'bg-transparent border-transparent shadow-none',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: [
|
||||||
|
'h-9 sm:h-10 md:h-11 rounded-lg',
|
||||||
|
'px-3 sm:px-3.5 md:px-4',
|
||||||
|
'text-xs sm:text-sm md:text-base',
|
||||||
|
'placeholder:text-[0.75rem] sm:placeholder:text-sm md:placeholder:text-base',
|
||||||
|
],
|
||||||
|
md: [
|
||||||
|
'h-10 sm:h-12 md:h-14 rounded-xl',
|
||||||
|
'px-3.5 sm:px-4 md:px-5',
|
||||||
|
'text-sm sm:text-base md:text-lg',
|
||||||
|
'placeholder:text-[0.75rem] sm:placeholder:text-sm md:placeholder:text-base',
|
||||||
|
],
|
||||||
|
lg: [
|
||||||
|
'h-12 sm:h-14 md:h-16 rounded-2xl',
|
||||||
|
'px-4 sm:px-5 md:px-6',
|
||||||
|
'text-sm sm:text-base md:text-lg',
|
||||||
|
'placeholder:text-[0.75rem] sm:placeholder:text-sm md:placeholder:text-base',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'lg',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type InputVariant = VariantProps<typeof inputVariants>['variant'];
|
||||||
|
type InputSize = VariantProps<typeof inputVariants>['size'];
|
||||||
|
|
||||||
|
export type InputProps = {
|
||||||
|
/**
|
||||||
|
* Current search value (bindable)
|
||||||
|
*/
|
||||||
|
value?: string;
|
||||||
|
/**
|
||||||
|
* Additional CSS classes for the container
|
||||||
|
*/
|
||||||
|
class?: string;
|
||||||
|
/**
|
||||||
|
* Visual style variant
|
||||||
|
*/
|
||||||
|
variant?: InputVariant;
|
||||||
|
/**
|
||||||
|
* Size variant
|
||||||
|
*/
|
||||||
|
size?: InputSize;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
class: className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'lg',
|
||||||
|
...rest
|
||||||
|
}: InputProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BaseInput
|
||||||
|
bind:value
|
||||||
|
class={cn(inputVariants({ variant, size }), className)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user