Compare commits
408 Commits
8d1d1cd60f
...
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 | ||
| a9aba10f09 | |||
|
|
778839d35e | ||
|
|
92fb314615 | ||
|
|
6f0b69ff45 | ||
|
|
2cd38797b9 | ||
|
|
6f231999e0 | ||
|
|
31a72d90ea | ||
|
|
072690270f | ||
|
|
eaf9d069c5 | ||
|
|
4a94f7bd09 | ||
|
|
918e792e41 | ||
|
|
c9c8b9abfc | ||
|
|
a392b575cc | ||
|
|
961475dea0 | ||
|
|
5496fd2680 | ||
|
|
f90f1e39e0 | ||
|
|
ca161dfbd4 | ||
|
|
ac2d0c32a4 | ||
|
|
54d22d650d | ||
|
|
a9c63f2544 | ||
|
|
70f57283a8 | ||
|
|
d43c873dc9 | ||
|
|
9501dbf281 | ||
|
|
0ac6acd174 | ||
|
|
5bb41c7e4c | ||
|
|
eed3339b0d | ||
|
|
d94e3cefb2 | ||
|
|
cfb586f539 | ||
|
|
6e975e5f8e | ||
|
|
142e4f0a19 | ||
|
|
59b85eead0 | ||
|
|
010643e398 | ||
|
|
27f637531b | ||
|
|
91fa08074b | ||
|
|
c246f70fe9 | ||
|
|
b1ce734f19 | ||
|
|
3add50a190 | ||
|
|
ef48d9815c | ||
|
|
818dfdb55e | ||
|
|
42e1271647 | ||
|
|
8ef9226dd2 | ||
|
|
f0c0a9de45 | ||
|
|
730eba138d | ||
|
|
18f265974e | ||
|
|
705723b009 | ||
|
|
75ea5ab382 | ||
|
|
f07b699926 | ||
|
|
b031e560af | ||
|
|
fbaf596fef | ||
|
|
1a2c44fb97 | ||
|
|
04602f0372 | ||
|
|
433fd2f7e6 | ||
|
|
87c4e04458 | ||
|
|
fb843c87af | ||
|
|
b2af3683bc | ||
|
|
90f11d8d16 | ||
|
|
a3f9bc12a0 | ||
|
|
6634f6df1e | ||
|
|
3f7ce63736 | ||
|
|
c665a579be | ||
|
|
ac7f094d13 | ||
|
|
c06aad1a8a | ||
|
|
471e186e70 | ||
|
|
dc72b9e048 | ||
|
|
07a37af71a | ||
|
|
d6607e5705 | ||
|
|
10801a641a | ||
|
|
98eab35615 | ||
|
|
7fbeef68e2 | ||
|
|
7078cb6f8c | ||
|
|
0b0489fa26 | ||
|
|
2022213921 | ||
|
|
6725a3b391 | ||
|
|
2eddb656a9 | ||
|
|
5973d241aa | ||
|
|
75a9c16070 | ||
|
|
31e4c64193 | ||
|
|
48e25fffa7 | ||
|
|
407c741349 | ||
|
|
13e114fafe | ||
|
|
1484ea024e | ||
|
|
67db6e22a7 | ||
|
|
192ce2d34a | ||
|
|
2b820230bc | ||
|
|
9b8ebed1c3 | ||
|
|
3d11f7317d | ||
|
|
c07800cc96 | ||
|
|
b49bf0d397 | ||
|
|
ed4ee8bb44 | ||
|
|
8a2059ac4a | ||
|
|
7ffc5d6a34 | ||
|
|
08cccc5ede | ||
|
|
71266f8b22 | ||
|
|
d5221ad449 | ||
|
|
873b697e8c | ||
|
|
3dce409034 | ||
|
|
cf08f7adfa | ||
|
|
4b01b1592d | ||
|
|
ecb4bea642 | ||
|
|
e89c6369cb | ||
|
|
18a311c6b1 | ||
|
|
732f77f504 | ||
|
|
b7992ca138 | ||
|
|
32b1367877 | ||
|
|
59b0d9c620 | ||
|
|
be13a5c8a0 | ||
|
|
80efa49ad0 | ||
|
|
7e9675be80 | ||
|
|
ac979c816c | ||
|
|
272c2c2d22 | ||
|
|
a9e2898945 | ||
|
|
1712134f64 | ||
|
|
52111ee941 | ||
|
|
e4970e43ba | ||
|
|
b41c48da67 | ||
|
|
1d0ca31262 | ||
|
|
a5380333eb | ||
|
|
46de3c6e87 | ||
|
|
91300bdc25 | ||
|
|
2ee66316f7 | ||
|
|
c6d20aae3d | ||
|
|
a0f184665d | ||
|
|
d4d2d68d9a | ||
|
|
55a560b785 | ||
|
|
c2542026a4 | ||
|
|
3f8fd357d8 | ||
|
|
1bd2a4f2f8 | ||
|
|
746a377038 | ||
|
|
1b76284237 | ||
|
|
b5ad3249ae | ||
| fb190f82b9 | |||
|
|
c0eed67618 | ||
|
|
e7f4304391 | ||
|
|
488857e0ec | ||
|
|
cca69a73ce | ||
|
|
2444e05bb7 | ||
|
|
72cc441c6f | ||
|
|
06cb155b47 | ||
|
|
50c7511698 | ||
| 993c63a39d | |||
|
|
8591985f62 | ||
|
|
9cbf4fdc48 | ||
|
|
8356e99382 | ||
|
|
7ca45c2e63 | ||
|
|
20f6e193f2 | ||
|
|
c04518300b | ||
|
|
ee074036f6 | ||
|
|
ba883ef9a8 | ||
|
|
28a71452d1 | ||
|
|
b7ce100407 | ||
|
|
96b26fb055 | ||
|
|
5ef8d609ab | ||
|
|
f457e5116f | ||
|
|
e0e0d929bb | ||
|
|
37ab7f795e | ||
|
|
af2ef77c30 | ||
|
|
ad18a19c4b | ||
|
|
ef259c6fce | ||
|
|
5d23a2af55 | ||
|
|
df8eca6ef2 | ||
|
|
7e62acce49 | ||
|
|
86e7b2c1ec | ||
|
|
da0612942c | ||
|
|
0444f8c114 | ||
|
|
6b4e0dbbd0 | ||
|
|
7389ec779d | ||
|
|
4d04761d88 | ||
|
|
32da012b26 | ||
|
|
71d320535e | ||
|
|
71c068bad2 | ||
|
|
247b683c87 | ||
|
|
8c0c91deb7 | ||
|
|
261c19db69 | ||
|
|
a85b3cf217 | ||
|
|
f02b19eff5 | ||
|
|
4dbf91f600 | ||
|
|
0daf0bf3bf | ||
|
|
14f9b87680 | ||
|
|
3cd9b36411 | ||
|
|
4c8b5764b3 | ||
|
|
62ae0799cc | ||
|
|
53c71a437f | ||
|
|
1976affdff | ||
|
|
f3de6c49a3 | ||
|
|
42e941083a | ||
|
|
86adec01a0 | ||
|
|
b0812ff606 | ||
|
|
deaf38f8ec | ||
| fefaf3f4c7 | |||
|
|
56e6e450e8 | ||
|
|
824581551f | ||
|
|
f97904f165 | ||
|
|
6129ad61f4 | ||
|
|
462abdd2bc | ||
|
|
429a9a0877 | ||
|
|
925d2eec3e | ||
|
|
211ed073e6 | ||
|
|
976672ce5e | ||
|
|
83397f3786 | ||
|
|
a72c0e8136 | ||
|
|
61dd62af2d | ||
|
|
147ddd226a | ||
|
|
c6b18f6dd3 | ||
| c10bbb681a | |||
|
|
7678ab271d | ||
| 3302e4a012 | |||
|
|
f730dbc782 | ||
|
|
8b704f1f82 | ||
|
|
36ed19e195 | ||
|
|
b209e051e5 | ||
|
|
f49e116408 |
562
.gitea/README.md
562
.gitea/README.md
@@ -1,562 +0,0 @@
|
|||||||
# Gitea Actions CI/CD Setup
|
|
||||||
|
|
||||||
This document describes the CI/CD pipeline configuration for the GlyphDiff project using Gitea Actions (GitHub Actions compatible).
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
- [Overview](#overview)
|
|
||||||
- [Workflow Files](#workflow-files)
|
|
||||||
- [Workflow Triggers](#workflow-triggers)
|
|
||||||
- [Setup Instructions](#setup-instructions)
|
|
||||||
- [Self-Hosted Runner Setup](#self-hosted-runner-setup)
|
|
||||||
- [Caching Strategy](#caching-strategy)
|
|
||||||
- [Environment Variables](#environment-variables)
|
|
||||||
- [Troubleshooting](#troubleshooting)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The CI/CD pipeline consists of four main workflows:
|
|
||||||
|
|
||||||
1. **Lint** - Code quality checks (oxlint, dprint formatting)
|
|
||||||
2. **Test** - Type checking and E2E tests (Playwright)
|
|
||||||
3. **Build** - Production build verification
|
|
||||||
4. **Deploy** - Deployment automation (optional/template)
|
|
||||||
|
|
||||||
All workflows are designed to run on both push and pull request events, with appropriate branch filtering and concurrency controls.
|
|
||||||
|
|
||||||
## Workflow Files
|
|
||||||
|
|
||||||
### `.gitea/workflows/lint.yml`
|
|
||||||
|
|
||||||
**Purpose**: Run code quality checks to ensure code style and formatting standards.
|
|
||||||
|
|
||||||
**Checks performed**:
|
|
||||||
|
|
||||||
- `oxlint` - Fast JavaScript/TypeScript linter
|
|
||||||
- `dprint check` - Code formatting verification
|
|
||||||
|
|
||||||
**Triggers**:
|
|
||||||
|
|
||||||
- Push to `main`, `develop`, `feature/*` branches
|
|
||||||
- Pull requests to `main` or `develop`
|
|
||||||
- Manual workflow dispatch
|
|
||||||
|
|
||||||
**Cache**: Node modules and Yarn cache
|
|
||||||
|
|
||||||
**Concurrency**: Cancels in-progress runs for the same branch when a new commit is pushed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `.gitea/workflows/test.yml`
|
|
||||||
|
|
||||||
**Purpose**: Run type checking and end-to-end tests.
|
|
||||||
|
|
||||||
**Jobs**:
|
|
||||||
|
|
||||||
#### 1. `type-check` job
|
|
||||||
|
|
||||||
- `tsc --noEmit` - TypeScript type checking
|
|
||||||
- `svelte-check --threshold warning` - Svelte component type checking
|
|
||||||
|
|
||||||
#### 2. `e2e-tests` job
|
|
||||||
|
|
||||||
- Installs Playwright browsers with system dependencies
|
|
||||||
- Runs E2E tests using Playwright
|
|
||||||
- Uploads test report artifacts (retained for 7 days)
|
|
||||||
- Uploads screenshots on test failure for debugging
|
|
||||||
|
|
||||||
**Triggers**: Same as lint workflow
|
|
||||||
|
|
||||||
**Cache**: Node modules and Yarn cache
|
|
||||||
|
|
||||||
**Artifacts**:
|
|
||||||
|
|
||||||
- `playwright-report` - Test execution report
|
|
||||||
- `playwright-screenshots` - Screenshots from failed tests
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `.gitea/workflows/build.yml`
|
|
||||||
|
|
||||||
**Purpose**: Verify that the production build completes successfully.
|
|
||||||
|
|
||||||
**Steps**:
|
|
||||||
|
|
||||||
1. Checkout repository
|
|
||||||
2. Setup Node.js v20 with Yarn caching
|
|
||||||
3. Install dependencies with `--frozen-lockfile`
|
|
||||||
4. Run `svelte-kit sync` to prepare SvelteKit
|
|
||||||
5. Build the project with `NODE_ENV=production`
|
|
||||||
6. Upload build artifacts (`.svelte-kit/output`, `.svelte-kit/build`)
|
|
||||||
7. Run the preview server and verify it responds (health check)
|
|
||||||
|
|
||||||
**Triggers**:
|
|
||||||
|
|
||||||
- Push to `main` or `develop` branches
|
|
||||||
- Pull requests to `main` or `develop`
|
|
||||||
- Manual workflow dispatch
|
|
||||||
|
|
||||||
**Cache**: Node modules and Yarn cache
|
|
||||||
|
|
||||||
**Artifacts**:
|
|
||||||
|
|
||||||
- `build-artifacts` - Compiled SvelteKit output (retained for 7 days)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### `.gitea/workflows/deploy.yml`
|
|
||||||
|
|
||||||
**Purpose**: Automated deployment pipeline (template configuration).
|
|
||||||
|
|
||||||
**Current state**: Placeholder configuration. Uncomment and customize one of the deployment examples.
|
|
||||||
|
|
||||||
**Pre-deployment checks**:
|
|
||||||
|
|
||||||
- Must pass linting workflow
|
|
||||||
- Must pass testing workflow
|
|
||||||
- Must pass build workflow
|
|
||||||
|
|
||||||
**Deployment examples included**:
|
|
||||||
|
|
||||||
1. **Docker container registry** - Build and push Docker image
|
|
||||||
2. **SSH deployment** - Deploy to server via SSH
|
|
||||||
3. **Vercel** - Deploy to Vercel platform
|
|
||||||
|
|
||||||
**Triggers**:
|
|
||||||
|
|
||||||
- Push to `main` branch
|
|
||||||
- Manual workflow dispatch with environment selection (staging/production)
|
|
||||||
|
|
||||||
**Secrets required** (configure in Gitea):
|
|
||||||
|
|
||||||
- For Docker: `REGISTRY_URL`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`
|
|
||||||
- For SSH: `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`
|
|
||||||
- For Vercel: `VERCEL_TOKEN`, `VERCEL_ORG_ID`, `VERCEL_PROJECT_ID`
|
|
||||||
|
|
||||||
## Workflow Triggers
|
|
||||||
|
|
||||||
### Branch-Specific Behavior
|
|
||||||
|
|
||||||
| Workflow | Push Triggers | PR Triggers | Runs on Merge |
|
|
||||||
| -------- | ------------------------------ | -------------------- | ------------- |
|
|
||||||
| Lint | `main`, `develop`, `feature/*` | To `main`, `develop` | Yes |
|
|
||||||
| Test | `main`, `develop`, `feature/*` | To `main`, `develop` | Yes |
|
|
||||||
| Build | `main`, `develop` | To `main`, `develop` | Yes |
|
|
||||||
| Deploy | `main` only | None | Yes |
|
|
||||||
|
|
||||||
### Concurrency Strategy
|
|
||||||
|
|
||||||
All workflows use concurrency groups based on the workflow name and branch reference:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true # or false for deploy workflow
|
|
||||||
```
|
|
||||||
|
|
||||||
This ensures:
|
|
||||||
|
|
||||||
- For lint/test/build: New commits cancel in-progress runs (saves resources)
|
|
||||||
- For deploy: Prevents concurrent deployments (ensures safety)
|
|
||||||
|
|
||||||
## Setup Instructions
|
|
||||||
|
|
||||||
### Step 1: Verify Gitea Actions is Enabled
|
|
||||||
|
|
||||||
1. Navigate to your Gitea instance
|
|
||||||
2. Go to **Site Administration** → **Actions**
|
|
||||||
3. Ensure Actions is enabled
|
|
||||||
4. Configure default runner settings if needed
|
|
||||||
|
|
||||||
### Step 2: Configure Repository Settings
|
|
||||||
|
|
||||||
1. Go to your repository in Gitea
|
|
||||||
2. Click **Settings** → **Actions**
|
|
||||||
3. Enable Actions for the repository if not already enabled
|
|
||||||
4. Set appropriate permissions for read/write access
|
|
||||||
|
|
||||||
### Step 3: Push Workflows to Repository
|
|
||||||
|
|
||||||
The workflow files are already in `.gitea/workflows/`. Commit and push them:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add .gitea/workflows/
|
|
||||||
git commit -m "Add Gitea Actions CI/CD workflows"
|
|
||||||
git push origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Verify Workflows Run
|
|
||||||
|
|
||||||
1. Navigate to **Actions** tab in your repository
|
|
||||||
2. You should see the workflows trigger on the next push
|
|
||||||
3. Click into a workflow run to view logs and status
|
|
||||||
|
|
||||||
### Step 5: Configure Secrets (Optional - for deployment)
|
|
||||||
|
|
||||||
1. Go to repository **Settings** → **Secrets** → **Actions**
|
|
||||||
2. Click **Add New Secret**
|
|
||||||
3. Add secrets required for your deployment method
|
|
||||||
|
|
||||||
Example secrets for SSH deployment:
|
|
||||||
|
|
||||||
```
|
|
||||||
DEPLOY_HOST=your-server.com
|
|
||||||
DEPLOY_USER=deploy
|
|
||||||
DEPLOY_SSH_KEY=-----BEGIN OPENSSH PRIVATE KEY-----
|
|
||||||
...
|
|
||||||
-----END OPENSSH PRIVATE KEY-----
|
|
||||||
```
|
|
||||||
|
|
||||||
## Self-Hosted Runner Setup
|
|
||||||
|
|
||||||
### Option 1: Using Gitea's Built-in Act Runner (Recommended)
|
|
||||||
|
|
||||||
Gitea provides `act_runner` (compatible with GitHub Actions runner).
|
|
||||||
|
|
||||||
#### Install act_runner
|
|
||||||
|
|
||||||
On Linux (Debian/Ubuntu):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wget -O /usr/local/bin/act_runner https://gitea.com/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-amd64
|
|
||||||
chmod +x /usr/local/bin/act_runner
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify installation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
act_runner --version
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Register the Runner
|
|
||||||
|
|
||||||
1. In Gitea, navigate to repository **Settings** → **Actions** → **Runners**
|
|
||||||
2. Click **New Runner**
|
|
||||||
3. Copy the registration token
|
|
||||||
4. Run the registration command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
act_runner register \
|
|
||||||
--instance https://your-gitea-instance.com \
|
|
||||||
--token YOUR_REGISTRATION_TOKEN \
|
|
||||||
--name "linux-runner-1" \
|
|
||||||
--labels ubuntu-latest,linux,docker \
|
|
||||||
--no-interactive
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Start the Runner as a Service
|
|
||||||
|
|
||||||
Create a systemd service file at `/etc/systemd/system/gitea-runner.service`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Gitea Actions Runner
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=git
|
|
||||||
WorkingDirectory=/var/lib/gitea-runner
|
|
||||||
ExecStart=/usr/local/bin/act_runner daemon
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5s
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
Enable and start the service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable gitea-runner
|
|
||||||
sudo systemctl start gitea-runner
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Check Runner Status
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl status gitea-runner
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify in Gitea: The runner should appear as **Online** with the `ubuntu-latest` label.
|
|
||||||
|
|
||||||
### Option 2: Using Self-Hosted Runners with Docker
|
|
||||||
|
|
||||||
If you prefer Docker-based execution:
|
|
||||||
|
|
||||||
#### Install Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
|
||||||
sudo sh get-docker.sh
|
|
||||||
sudo usermod -aG docker $USER
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Configure Runner to Use Docker
|
|
||||||
|
|
||||||
Ensure the runner has access to the Docker socket:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo usermod -aG docker act_runner_user
|
|
||||||
```
|
|
||||||
|
|
||||||
The workflows will now run containers inside the runner's Docker environment.
|
|
||||||
|
|
||||||
### Option 3: Using External Runners (GitHub Actions Runner Compatible)
|
|
||||||
|
|
||||||
If you want to use standard GitHub Actions runners:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Download and configure GitHub Actions runner
|
|
||||||
mkdir actions-runner && cd actions-runner
|
|
||||||
curl -o actions-runner-linux-x64-2.311.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.311.0/actions-runner-linux-x64-2.311.0.tar.gz
|
|
||||||
tar xzf ./actions-runner-linux-x64-2.311.0.tar.gz
|
|
||||||
|
|
||||||
# Configure to point to Gitea instance
|
|
||||||
./config.sh --url https://your-gitea-instance.com --token YOUR_TOKEN
|
|
||||||
```
|
|
||||||
|
|
||||||
## Caching Strategy
|
|
||||||
|
|
||||||
### Node.js and Yarn Cache
|
|
||||||
|
|
||||||
All workflows use `actions/setup-node@v4` with built-in caching:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'yarn'
|
|
||||||
```
|
|
||||||
|
|
||||||
This caches:
|
|
||||||
|
|
||||||
- `node_modules` directory
|
|
||||||
- Yarn cache directory (`~/.yarn/cache`)
|
|
||||||
- Reduces installation time from minutes to seconds on subsequent runs
|
|
||||||
|
|
||||||
### Playwright Cache
|
|
||||||
|
|
||||||
Playwright browsers are installed fresh each time. To cache Playwright (optional optimization):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Cache Playwright binaries
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.cache/ms-playwright
|
|
||||||
key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-playwright-
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
### Default Environment Variables
|
|
||||||
|
|
||||||
The workflows use the following environment variables:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
NODE_ENV=production # For build workflow
|
|
||||||
NODE_VERSION=20 # Node.js version used across all workflows
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Environment Variables
|
|
||||||
|
|
||||||
To add custom environment variables:
|
|
||||||
|
|
||||||
1. Go to repository **Settings** → **Variables** → **Actions**
|
|
||||||
2. Click **Add New Variable**
|
|
||||||
3. Add variable name and value
|
|
||||||
4. Set scope (environment, repository, or organization)
|
|
||||||
|
|
||||||
Example for feature flags:
|
|
||||||
|
|
||||||
```
|
|
||||||
ENABLE_ANALYTICS=false
|
|
||||||
API_URL=https://api.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Access in workflow:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
env:
|
|
||||||
API_URL: ${{ vars.API_URL }}
|
|
||||||
ENABLE_ANALYTICS: ${{ vars.ENABLE_ANALYTICS }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Workflows Not Running
|
|
||||||
|
|
||||||
**Symptoms**: Workflows don't appear or don't trigger
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Verify Actions is enabled in Gitea site administration
|
|
||||||
2. Check repository Settings → Actions is enabled
|
|
||||||
3. Verify workflow files are in `.gitea/workflows/` directory
|
|
||||||
4. Check workflow YAML syntax (no indentation errors)
|
|
||||||
|
|
||||||
### Runner Offline
|
|
||||||
|
|
||||||
**Symptoms**: Runner shows as **Offline** or **Idle**
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Check runner service status: `sudo systemctl status gitea-runner`
|
|
||||||
2. Review runner logs: `journalctl -u gitea-runner -f`
|
|
||||||
3. Verify network connectivity to Gitea instance
|
|
||||||
4. Restart runner: `sudo systemctl restart gitea-runner`
|
|
||||||
|
|
||||||
### Linting Fails with Formatting Errors
|
|
||||||
|
|
||||||
**Symptoms**: `dprint check` fails on CI but passes locally
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Ensure dprint configuration (`dprint.json`) is committed
|
|
||||||
2. Run `yarn dprint fmt` locally before committing
|
|
||||||
3. Consider adding auto-fix workflow (see below)
|
|
||||||
|
|
||||||
### Playwright Tests Timeout
|
|
||||||
|
|
||||||
**Symptoms**: E2E tests fail with timeout errors
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Check `playwright.config.ts` timeout settings
|
|
||||||
2. Ensure preview server starts before tests run (built into config)
|
|
||||||
3. Increase timeout in workflow:
|
|
||||||
```yaml
|
|
||||||
- name: Run Playwright tests
|
|
||||||
run: yarn test:e2e
|
|
||||||
env:
|
|
||||||
PLAYWRIGHT_TIMEOUT: 60000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build Fails with Out of Memory
|
|
||||||
|
|
||||||
**Symptoms**: Build fails with memory allocation errors
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Increase Node.js memory limit:
|
|
||||||
```yaml
|
|
||||||
- name: Build project
|
|
||||||
run: yarn build
|
|
||||||
env:
|
|
||||||
NODE_OPTIONS: --max-old-space-size=4096
|
|
||||||
```
|
|
||||||
2. Ensure runner has sufficient RAM (minimum 2GB recommended)
|
|
||||||
|
|
||||||
### Permission Denied on Runner
|
|
||||||
|
|
||||||
**Symptoms**: Runner can't access repository or secrets
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Verify runner has read access to repository
|
|
||||||
2. Check secret names match exactly in workflow
|
|
||||||
3. Ensure runner user has file system permissions
|
|
||||||
|
|
||||||
### Yarn Install Fails with Lockfile Conflict
|
|
||||||
|
|
||||||
**Symptoms**: `yarn install --frozen-lockfile` fails
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Ensure `yarn.lock` is up-to-date locally
|
|
||||||
2. Run `yarn install` and commit updated `yarn.lock`
|
|
||||||
3. Do not use `--frozen-lockfile` if using different platforms (arm64 vs amd64)
|
|
||||||
|
|
||||||
### Slow Workflow Execution
|
|
||||||
|
|
||||||
**Symptoms**: Workflows take too long to complete
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Verify caching is working (check logs for "Cache restored")
|
|
||||||
2. Use `--frozen-lockfile` for faster dependency resolution
|
|
||||||
3. Consider matrix strategy for parallel execution (not currently used)
|
|
||||||
4. Optimize Playwright tests (reduce test count, increase timeouts only if needed)
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### 1. Keep Dependencies Updated
|
|
||||||
|
|
||||||
Regularly update action versions:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- uses: actions/checkout@v4 # Update from v3 to v4 when available
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Use Frozen Lockfile
|
|
||||||
|
|
||||||
Always use `--frozen-lockfile` in CI to ensure reproducible builds:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn install --frozen-lockfile
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Monitor Workflow Status
|
|
||||||
|
|
||||||
Set up notifications for workflow failures:
|
|
||||||
|
|
||||||
- Email notifications in Gitea user settings
|
|
||||||
- Integrate with Slack/Mattermost for team alerts
|
|
||||||
- Use status badges in README
|
|
||||||
|
|
||||||
### 4. Test Locally Before Pushing
|
|
||||||
|
|
||||||
Run the same checks locally:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn lint # oxlint
|
|
||||||
yarn dprint check # Formatting check
|
|
||||||
yarn tsc --noEmit # Type check
|
|
||||||
yarn test:e2e # E2E tests
|
|
||||||
yarn build # Build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Leverage Git Hooks
|
|
||||||
|
|
||||||
The project uses lefthook for pre-commit/pre-push checks. This catches issues before they reach CI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Pre-commit: Format code, lint staged files
|
|
||||||
# Pre-push: Full type check, format check, full lint
|
|
||||||
```
|
|
||||||
|
|
||||||
## Additional Resources
|
|
||||||
|
|
||||||
- [Gitea Actions Documentation](https://docs.gitea.com/usage/actions/overview)
|
|
||||||
- [Gitea act_runner Documentation](https://docs.gitea.com/usage/actions/act-runner)
|
|
||||||
- [GitHub Actions Documentation](https://docs.github.com/en/actions)
|
|
||||||
- [SvelteKit Deployment Guide](https://kit.svelte.dev/docs/adapters)
|
|
||||||
- [Playwright CI/CD Guide](https://playwright.dev/docs/ci)
|
|
||||||
|
|
||||||
## Status Badges
|
|
||||||
|
|
||||||
Add status badges to your README.md:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. **Customize deployment**: Modify `deploy.yml` with your deployment strategy
|
|
||||||
2. **Add notifications**: Set up workflow failure notifications
|
|
||||||
3. **Optimize caching**: Add Playwright cache if needed
|
|
||||||
4. **Add badges**: Include status badges in README
|
|
||||||
5. **Schedule tasks**: Add periodic tests or dependency updates (optional)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: December 30, 2025
|
|
||||||
**Version**: 1.0.0
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
name: Build
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, develop]
|
|
||||||
pull_request:
|
|
||||||
branches: [main, develop]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'yarn'
|
|
||||||
|
|
||||||
- name: Install
|
|
||||||
run: yarn install --frozen-lockfile --prefer-offline
|
|
||||||
|
|
||||||
- name: Build Svelte App
|
|
||||||
run: yarn build
|
|
||||||
|
|
||||||
- name: Upload Artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: build-artifacts
|
|
||||||
path: dist/
|
|
||||||
retention-days: 7
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
name: Deploy Pipeline
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
environment:
|
|
||||||
description: 'Target'
|
|
||||||
required: true
|
|
||||||
default: 'production'
|
|
||||||
type: choice
|
|
||||||
options: [staging, production]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
pipeline:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'yarn'
|
|
||||||
|
|
||||||
- name: Install
|
|
||||||
run: yarn install --frozen-lockfile --prefer-offline
|
|
||||||
|
|
||||||
- name: Validation
|
|
||||||
run: |
|
|
||||||
yarn oxlint .
|
|
||||||
yarn svelte-check
|
|
||||||
|
|
||||||
- name: Build for Production
|
|
||||||
run: yarn build
|
|
||||||
env:
|
|
||||||
NODE_ENV: production
|
|
||||||
|
|
||||||
- name: Deploy Step
|
|
||||||
run: |
|
|
||||||
echo "Deploying dist/ to ${{ github.event.inputs.environment || 'production' }}..."
|
|
||||||
# EXAMPLE: rsync -avz dist/ user@your-vps:/var/www/html/
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
name: Lint
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
- feature/*
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
name: Lint Code
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'yarn'
|
|
||||||
|
|
||||||
- name: Get yarn cache directory path
|
|
||||||
id: yarn-cache-dir-path
|
|
||||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Persistent Yarn Cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
id: yarn-cache
|
|
||||||
with:
|
|
||||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
|
||||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-yarn-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: yarn install --frozen-lockfile --prefer-offline
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
name: Test
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, develop, "feature/*"]
|
|
||||||
pull_request:
|
|
||||||
branches: [main, develop]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: Svelte Checks
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'yarn'
|
|
||||||
|
|
||||||
- name: Install
|
|
||||||
run: yarn install --frozen-lockfile --prefer-offline
|
|
||||||
|
|
||||||
- name: Type Check
|
|
||||||
run: yarn svelte-check --threshold warning
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: yarn oxlint .
|
|
||||||
|
|
||||||
# e2e-tests:
|
|
||||||
# name: E2E Tests (Playwright)
|
|
||||||
# runs-on: ubuntu-latest
|
|
||||||
#
|
|
||||||
# steps:
|
|
||||||
# - name: Checkout repository
|
|
||||||
# uses: actions/checkout@v4
|
|
||||||
#
|
|
||||||
# - name: Setup Node.js
|
|
||||||
# uses: actions/setup-node@v4
|
|
||||||
# with:
|
|
||||||
# node-version: '20'
|
|
||||||
# cache: 'yarn'
|
|
||||||
#
|
|
||||||
# - name: Install dependencies
|
|
||||||
# run: yarn install --frozen-lockfile
|
|
||||||
#
|
|
||||||
# - name: Install Playwright browsers
|
|
||||||
# run: yarn playwright install --with-deps
|
|
||||||
#
|
|
||||||
# - name: Run Playwright tests
|
|
||||||
# run: yarn test:e2e
|
|
||||||
#
|
|
||||||
# - name: Upload Playwright report
|
|
||||||
# if: always()
|
|
||||||
# uses: actions/upload-artifact@v4
|
|
||||||
# with:
|
|
||||||
# name: playwright-report
|
|
||||||
# path: playwright-report/
|
|
||||||
# retention-days: 7
|
|
||||||
#
|
|
||||||
# - name: Upload Playwright screenshots (on failure)
|
|
||||||
# if: failure()
|
|
||||||
# uses: actions/upload-artifact@v4
|
|
||||||
# with:
|
|
||||||
# name: playwright-screenshots
|
|
||||||
# path: test-results/
|
|
||||||
# retention-days: 7
|
|
||||||
#
|
|
||||||
# Note: E2E tests are disabled until Playwright setup is complete.
|
|
||||||
# Uncomment this job section when Playwright tests are ready to run.
|
|
||||||
60
.gitea/workflows/workflow.yml
Normal file
60
.gitea/workflows/workflow.yml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
name: Workflow
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, develop]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '25'
|
||||||
|
|
||||||
|
- name: Enable Corepack
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare yarn@stable --activate
|
||||||
|
|
||||||
|
- name: Persistent Yarn Cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
id: yarn-cache
|
||||||
|
with:
|
||||||
|
path: .yarn/cache
|
||||||
|
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||||
|
restore-keys: ${{ runner.os }}-yarn-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --immutable
|
||||||
|
|
||||||
|
- name: Build Svelte App
|
||||||
|
run: yarn build
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: yarn lint
|
||||||
|
|
||||||
|
- name: Type Check
|
||||||
|
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
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,6 +35,8 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
/docs
|
/docs
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
*storybook.log
|
*storybook.log
|
||||||
storybook-static
|
storybook-static
|
||||||
|
|||||||
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>
|
||||||
16
.storybook/StoryStage.svelte
Normal file
16
.storybook/StoryStage.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
children: import('svelte').Snippet;
|
||||||
|
width?: string; // Optional width override
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, width = 'max-w-3xl' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-h-screen w-full items-center justify-center bg-background p-8">
|
||||||
|
<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 text-foreground">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,4 +1,16 @@
|
|||||||
import type { StorybookConfig } from '@storybook/svelte-vite';
|
import type { StorybookConfig } from '@storybook/svelte-vite';
|
||||||
|
import {
|
||||||
|
dirname,
|
||||||
|
resolve,
|
||||||
|
} from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import {
|
||||||
|
loadConfigFromFile,
|
||||||
|
mergeConfig,
|
||||||
|
} from 'vite';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
'stories': [
|
'stories': [
|
||||||
@@ -9,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',
|
||||||
@@ -18,5 +31,17 @@ const config: StorybookConfig = {
|
|||||||
'@storybook/addon-docs',
|
'@storybook/addon-docs',
|
||||||
],
|
],
|
||||||
'framework': '@storybook/svelte-vite',
|
'framework': '@storybook/svelte-vite',
|
||||||
|
async viteFinal(config) {
|
||||||
|
// This attempts to find your actual vite.config.ts
|
||||||
|
const { config: userConfig } = await loadConfigFromFile(
|
||||||
|
{ command: 'serve', mode: 'development' },
|
||||||
|
resolve(__dirname, '../vite.config.ts'),
|
||||||
|
) || {};
|
||||||
|
|
||||||
|
return mergeConfig(config, {
|
||||||
|
// Merge only the resolve/alias parts if you want to be safe
|
||||||
|
resolve: userConfig?.resolve || {},
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
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,9 +1,11 @@
|
|||||||
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 '../src/app/styles/app.css';
|
import '../src/app/styles/app.css';
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
parameters: {
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
controls: {
|
controls: {
|
||||||
matchers: {
|
matchers: {
|
||||||
color: /(background|color)$/i,
|
color: /(background|color)$/i,
|
||||||
@@ -17,7 +19,47 @@ const preview: Preview = {
|
|||||||
// 'off' - skip a11y checks entirely
|
// 'off' - skip a11y checks entirely
|
||||||
test: 'todo',
|
test: 'todo',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
docs: {
|
||||||
|
story: {
|
||||||
|
// This sets the default height for the iframe in Autodocs
|
||||||
|
iframeHeight: '400px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
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: [
|
||||||
|
// Wrap with providers (TooltipProvider, ResponsiveManager)
|
||||||
|
story => ({
|
||||||
|
Component: Decorator,
|
||||||
|
props: {
|
||||||
|
children: story(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Wrap with StoryStage for presentation styling
|
||||||
|
story => ({
|
||||||
|
Component: StoryStage,
|
||||||
|
props: {
|
||||||
|
children: story(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default preview;
|
export default preview;
|
||||||
|
|||||||
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"]
|
||||||
84
README.md
84
README.md
@@ -1,38 +1,78 @@
|
|||||||
# sv
|
# GlyphDiff
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
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.
|
||||||
|
|
||||||
## Creating a project
|
## Features
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
- **Multi-Provider Catalog**: Browse fonts from Google Fonts and Fontshare in one place
|
||||||
|
- **Side-by-Side Comparison**: Compare up to 4 fonts simultaneously with customizable text, size, and typography settings
|
||||||
|
- **Advanced Filtering**: Filter by category, provider, character subsets, and weight
|
||||||
|
- **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
|
||||||
|
|
||||||
```sh
|
## Tech Stack
|
||||||
# create a new project in the current directory
|
|
||||||
npx sv create
|
|
||||||
|
|
||||||
# create a new project in my-app
|
- **Framework**: Svelte 5 with reactive primitives (runes)
|
||||||
npx sv create my-app
|
- **Styling**: Tailwind CSS v4
|
||||||
|
- **Components**: shadcn-svelte (via bits-ui)
|
||||||
|
- **State Management**: TanStack Query for async data
|
||||||
|
- **Architecture**: Feature-Sliced Design (FSD)
|
||||||
|
- **Quality**: oxlint (linting), dprint (formatting), lefthook (git hooks)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # App shell, layout, providers
|
||||||
|
├── widgets/ # Composed UI blocks (ComparisonSlider, SampleList, FontSearch)
|
||||||
|
├── features/ # Business features (filters, search, display)
|
||||||
|
├── entities/ # Domain models and stores (Font, Breadcrumb)
|
||||||
|
├── shared/ # Reusable utilities, UI components, helpers
|
||||||
|
└── routes/ # Page-level components
|
||||||
```
|
```
|
||||||
|
|
||||||
## Developing
|
## Quick Start
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
yarn install
|
||||||
|
|
||||||
```sh
|
# Start development server
|
||||||
npm run dev
|
yarn dev
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
# Build for production
|
||||||
npm run dev -- --open
|
yarn build
|
||||||
|
|
||||||
|
# Preview production build
|
||||||
|
yarn preview
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building
|
## Available Scripts
|
||||||
|
|
||||||
To create a production version of your app:
|
| 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 code with dprint |
|
||||||
|
| `yarn test:unit` | Run unit tests |
|
||||||
|
| `yarn test:unit:ui` | Run Vitest UI |
|
||||||
|
| `yarn storybook` | Start Storybook dev server |
|
||||||
|
|
||||||
```sh
|
## Code Style
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
- **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
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
## 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
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
|
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
|
||||||
],
|
],
|
||||||
"typescript": {
|
"typescript": {
|
||||||
"lineWidth": 100,
|
"lineWidth": 120,
|
||||||
"indentWidth": 4,
|
"indentWidth": 4,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"semiColons": "prefer",
|
"semiColons": "prefer",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"lineWidth": 100
|
"lineWidth": 100
|
||||||
},
|
},
|
||||||
"markup": {
|
"markup": {
|
||||||
"printWidth": 100,
|
"printWidth": 120,
|
||||||
"indentWidth": 4,
|
"indentWidth": 4,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"quotes": "double",
|
"quotes": "double",
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
"name": "glyphdiff",
|
"name": "glyphdiff",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
"packageManager": "yarn@4.11.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-check --tsconfig ./tsconfig.json || echo ''",
|
"prepare": "svelte-check --tsconfig ./tsconfig.json || echo ''",
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-check",
|
||||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"check:shadcn-excluded": "svelte-check --no-tsconfig --ignore \"src/shared/shadcn\"",
|
"check:shadcn-excluded": "svelte-check --no-tsconfig --ignore \"src/shared/shadcn\"",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
"test:component": "vitest run --config vitest.config.component.ts",
|
"test:component": "vitest run --config vitest.config.component.ts",
|
||||||
"test:component:browser": "vitest run --config vitest.config.browser.ts",
|
"test:component:browser": "vitest run --config vitest.config.browser.ts",
|
||||||
"test:component:browser:watch": "vitest --config vitest.config.browser.ts",
|
"test:component:browser:watch": "vitest --config vitest.config.browser.ts",
|
||||||
"test": "npm run test:e2e && npm run test:unit",
|
"test": "yarn run test:unit",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
@@ -60,12 +61,12 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/svelte-query": "^6.0.14",
|
"@tanstack/svelte-query": "^6.0.14"
|
||||||
"@tanstack/svelte-virtual": "^3.13.17"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +177,8 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||||
|
font-optical-sizing: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,3 +200,108 @@
|
|||||||
.peer:focus-visible ~ * {
|
.peer:focus-visible ~ * {
|
||||||
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes nudge {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0) scale(1) rotate(0deg);
|
||||||
|
}
|
||||||
|
2% {
|
||||||
|
transform: translateY(-2px) scale(1.1) rotate(-1deg);
|
||||||
|
}
|
||||||
|
4% {
|
||||||
|
transform: translateY(0) scale(1) rotate(1deg);
|
||||||
|
}
|
||||||
|
6% {
|
||||||
|
transform: translateY(-2px) scale(1.1) rotate(0deg);
|
||||||
|
}
|
||||||
|
8% {
|
||||||
|
transform: translateY(0) scale(1) rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-nudge {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,45 +3,105 @@
|
|||||||
* Layout Component
|
* Layout Component
|
||||||
*
|
*
|
||||||
* Root layout wrapper that provides the application shell structure. Handles favicon,
|
* Root layout wrapper that provides the application shell structure. Handles favicon,
|
||||||
* sidebar provider initialization, and renders child routes with consistent structure.
|
* toolbar provider initialization, and renders child routes with consistent structure.
|
||||||
*
|
*
|
||||||
* Layout structure:
|
* Layout structure:
|
||||||
* - Header area (currently empty, reserved for future use)
|
* - Header area (currently empty, reserved for future use)
|
||||||
* - Collapsible sidebar with main content area
|
|
||||||
* - Footer area (currently empty, reserved for future use)
|
|
||||||
*
|
*
|
||||||
* Uses Sidebar.Provider to enable mobile-responsive collapsible sidebar behavior
|
* - Footer area (currently empty, reserved for future use)
|
||||||
* throughout the application.
|
|
||||||
*/
|
*/
|
||||||
import favicon from '$shared/assets/favicon.svg';
|
import { BreadcrumbHeader } from '$entities/Breadcrumb';
|
||||||
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
|
import GD from '$shared/assets/GD.svg';
|
||||||
import { FiltersSidebar } from '$widgets/FiltersSidebar';
|
import { ResponsiveProvider } from '$shared/lib';
|
||||||
import TypographyMenu from '$widgets/TypographySettings/ui/TypographyMenu.svelte';
|
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
||||||
|
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||||
|
import {
|
||||||
|
type Snippet,
|
||||||
|
onMount,
|
||||||
|
} from 'svelte';
|
||||||
|
|
||||||
/** Slot content for route pages to render */
|
interface Props {
|
||||||
let { children } = $props();
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
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://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
|
||||||
|
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">
|
<ResponsiveProvider>
|
||||||
<header></header>
|
<div id="app-root" class="min-h-screen flex flex-col bg-background">
|
||||||
|
<header>
|
||||||
|
<BreadcrumbHeader />
|
||||||
|
</header>
|
||||||
|
|
||||||
<Sidebar.Provider>
|
<!-- <ScrollArea class="h-screen w-screen"> -->
|
||||||
<FiltersSidebar />
|
<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">
|
||||||
<main class="w-dvw">
|
<TooltipProvider>
|
||||||
<TypographyMenu />
|
{#if fontsReady}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
{/if}
|
||||||
|
</TooltipProvider>
|
||||||
</main>
|
</main>
|
||||||
</Sidebar.Provider>
|
<!-- </ScrollArea> -->
|
||||||
<footer></footer>
|
<footer></footer>
|
||||||
</div>
|
</div>
|
||||||
|
</ResponsiveProvider>
|
||||||
<style>
|
|
||||||
#app-root {
|
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
2
src/entities/Breadcrumb/index.ts
Normal file
2
src/entities/Breadcrumb/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { scrollBreadcrumbsStore } from './model';
|
||||||
|
export { BreadcrumbHeader } from './ui';
|
||||||
1
src/entities/Breadcrumb/model/index.ts
Normal file
1
src/entities/Breadcrumb/model/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './store/scrollBreadcrumbsStore.svelte';
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
/**
|
||||||
|
* Index of the item to display
|
||||||
|
*/
|
||||||
|
index: number;
|
||||||
|
/**
|
||||||
|
* ID of the item to navigate to
|
||||||
|
*/
|
||||||
|
id?: string;
|
||||||
|
/**
|
||||||
|
* Title snippet to render
|
||||||
|
*/
|
||||||
|
title: Snippet<[{ className?: string }]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScrollBreadcrumbsStore {
|
||||||
|
#items = $state<BreadcrumbItem[]>([]);
|
||||||
|
|
||||||
|
get items() {
|
||||||
|
// Keep them sorted by index for Swiss orderliness
|
||||||
|
return this.#items.sort((a, b) => a.index - b.index);
|
||||||
|
}
|
||||||
|
add(item: BreadcrumbItem) {
|
||||||
|
if (!this.#items.find(i => i.index === item.index)) {
|
||||||
|
this.#items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remove(index: number) {
|
||||||
|
this.#items = this.#items.filter(i => i.index !== index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScrollBreadcrumbsStore() {
|
||||||
|
return new ScrollBreadcrumbsStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!--
|
||||||
|
Component: BreadcrumbHeader
|
||||||
|
Fixed header for breadcrumbs navigation for sections in the page
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { smoothScroll } from '$shared/lib';
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import {
|
||||||
|
fly,
|
||||||
|
slide,
|
||||||
|
} from 'svelte/transition';
|
||||||
|
import { scrollBreadcrumbsStore } from '../../model';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if scrollBreadcrumbsStore.items.length > 0}
|
||||||
|
<div
|
||||||
|
transition:slide={{ duration: 200 }}
|
||||||
|
class="
|
||||||
|
fixed top-0 left-0 right-0 z-100
|
||||||
|
backdrop-blur-lg bg-background-20
|
||||||
|
border-b border-border-muted
|
||||||
|
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||||
|
h-10 sm:h-12
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="max-w-8xl mx-auto px-4 sm:px-6 h-full flex items-center gap-2 sm:gap-4">
|
||||||
|
<h1 class={cn('barlow font-extralight text-sm sm:text-base')}>
|
||||||
|
GLYPHDIFF
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="h-3.5 sm:h-4 w-px bg-border-subtle hidden sm:block"></div>
|
||||||
|
|
||||||
|
<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)}
|
||||||
|
<div
|
||||||
|
in:fly={{ duration: 300, y: -10, x: 100, opacity: 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-[8px] sm:text-[9px] text-text-muted tracking-wider">
|
||||||
|
{String(item.index).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<a href={`#${item.id}`} use:smoothScroll>
|
||||||
|
{@render item.title({
|
||||||
|
className: 'text-[9px] sm:text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-foreground',
|
||||||
|
})}</a>
|
||||||
|
|
||||||
|
{#if idx < scrollBreadcrumbsStore.items.length - 1}
|
||||||
|
<div class="flex items-center gap-0.5 opacity-40">
|
||||||
|
<div class="w-1 h-px bg-text-muted"></div>
|
||||||
|
<div class="w-1 h-px bg-text-muted"></div>
|
||||||
|
<div class="w-1 h-px bg-text-muted"></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1.5 sm:gap-2 opacity-50 ml-auto">
|
||||||
|
<div class="w-px h-2 sm:h-2.5 bg-border-subtle hidden sm:block"></div>
|
||||||
|
<span class="font-mono text-[7px] sm:text-[8px] text-text-muted tracking-wider">
|
||||||
|
[{scrollBreadcrumbsStore.items.length}]
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Hide scrollbar but keep functionality */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
3
src/entities/Breadcrumb/ui/index.ts
Normal file
3
src/entities/Breadcrumb/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import BreadcrumbHeader from './BreadcrumbHeader/BreadcrumbHeader.svelte';
|
||||||
|
|
||||||
|
export { BreadcrumbHeader };
|
||||||
@@ -4,6 +4,18 @@
|
|||||||
* Exports API clients and normalization utilities
|
* Exports API clients and normalization utilities
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Proxy API (PRIMARY - NEW)
|
||||||
|
export {
|
||||||
|
fetchFontsByIds,
|
||||||
|
fetchProxyFontById,
|
||||||
|
fetchProxyFonts,
|
||||||
|
} from './proxy/proxyFonts';
|
||||||
|
export type {
|
||||||
|
ProxyFontsParams,
|
||||||
|
ProxyFontsResponse,
|
||||||
|
} from './proxy/proxyFonts';
|
||||||
|
|
||||||
|
// Google Fonts API (DEPRECATED - kept for backward compatibility)
|
||||||
export {
|
export {
|
||||||
fetchGoogleFontFamily,
|
fetchGoogleFontFamily,
|
||||||
fetchGoogleFonts,
|
fetchGoogleFonts,
|
||||||
@@ -14,6 +26,7 @@ export type {
|
|||||||
GoogleFontsResponse,
|
GoogleFontsResponse,
|
||||||
} from './google/googleFonts';
|
} from './google/googleFonts';
|
||||||
|
|
||||||
|
// Fontshare API (DEPRECATED - kept for backward compatibility)
|
||||||
export {
|
export {
|
||||||
fetchAllFontshareFonts,
|
fetchAllFontshareFonts,
|
||||||
fetchFontshareFontBySlug,
|
fetchFontshareFontBySlug,
|
||||||
|
|||||||
279
src/entities/Font/api/proxy/proxyFonts.ts
Normal file
279
src/entities/Font/api/proxy/proxyFonts.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* Proxy API client
|
||||||
|
*
|
||||||
|
* Handles API requests to GlyphDiff proxy API for fetching font metadata.
|
||||||
|
* Provides error handling, pagination support, and type-safe responses.
|
||||||
|
*
|
||||||
|
* Proxy API normalizes font data from Google Fonts and Fontshare into a single
|
||||||
|
* unified format, eliminating the need for client-side normalization.
|
||||||
|
*
|
||||||
|
* Fallback: If proxy API fails, falls back to Fontshare API for development.
|
||||||
|
*
|
||||||
|
* @see https://api.glyphdiff.com/api/v1/fonts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '$shared/api/api';
|
||||||
|
import { buildQueryString } from '$shared/lib/utils';
|
||||||
|
import type { QueryParams } from '$shared/lib/utils';
|
||||||
|
import type { UnifiedFont } from '../../model/types';
|
||||||
|
import type {
|
||||||
|
FontCategory,
|
||||||
|
FontSubset,
|
||||||
|
} from '../../model/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy API base URL
|
||||||
|
*/
|
||||||
|
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to use proxy API (true) or fallback (false)
|
||||||
|
*
|
||||||
|
* Set to true when your proxy API is ready:
|
||||||
|
* const USE_PROXY_API = true;
|
||||||
|
*
|
||||||
|
* Set to false to use Fontshare API as fallback during development:
|
||||||
|
* const USE_PROXY_API = false;
|
||||||
|
*
|
||||||
|
* The app will automatically fall back to Fontshare API if the proxy fails.
|
||||||
|
*/
|
||||||
|
const USE_PROXY_API = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy API parameters
|
||||||
|
*
|
||||||
|
* Maps directly to the proxy API query parameters
|
||||||
|
*/
|
||||||
|
export interface ProxyFontsParams extends QueryParams {
|
||||||
|
/**
|
||||||
|
* Font provider filter ("google" or "fontshare")
|
||||||
|
* Omit to fetch from both providers
|
||||||
|
*/
|
||||||
|
provider?: 'google' | 'fontshare';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font category filter
|
||||||
|
*/
|
||||||
|
category?: FontCategory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Character subset filter
|
||||||
|
*/
|
||||||
|
subset?: FontSubset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search query (e.g., "roboto", "satoshi")
|
||||||
|
*/
|
||||||
|
q?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort order for results
|
||||||
|
* "name" - Alphabetical by font name
|
||||||
|
* "popularity" - Most popular first
|
||||||
|
* "lastModified" - Recently updated first
|
||||||
|
*/
|
||||||
|
sort?: 'name' | 'popularity' | 'lastModified';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of items to return (pagination)
|
||||||
|
*/
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of items to skip (pagination)
|
||||||
|
* Use for pagination: offset = (page - 1) * limit
|
||||||
|
*/
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy API response
|
||||||
|
*
|
||||||
|
* Includes pagination metadata alongside font data
|
||||||
|
*/
|
||||||
|
export interface ProxyFontsResponse {
|
||||||
|
/** Array of unified font objects */
|
||||||
|
fonts: UnifiedFont[];
|
||||||
|
|
||||||
|
/** Total number of fonts matching the query */
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
/** Limit used for this request */
|
||||||
|
limit: number;
|
||||||
|
|
||||||
|
/** Offset used for this request */
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch fonts from proxy API
|
||||||
|
*
|
||||||
|
* If proxy API fails or is unavailable, falls back to Fontshare API for development.
|
||||||
|
*
|
||||||
|
* @param params - Query parameters for filtering and pagination
|
||||||
|
* @returns Promise resolving to proxy API response
|
||||||
|
* @throws ApiError when request fails
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Fetch all sans-serif fonts from Google
|
||||||
|
* const response = await fetchProxyFonts({
|
||||||
|
* provider: 'google',
|
||||||
|
* category: 'sans-serif',
|
||||||
|
* limit: 50,
|
||||||
|
* offset: 0
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Search fonts across all providers
|
||||||
|
* const searchResponse = await fetchProxyFonts({
|
||||||
|
* q: 'roboto',
|
||||||
|
* limit: 20
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Fetch fonts with pagination
|
||||||
|
* const page1 = await fetchProxyFonts({ limit: 50, offset: 0 });
|
||||||
|
* const page2 = await fetchProxyFonts({ limit: 50, offset: 50 });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function fetchProxyFonts(
|
||||||
|
params: ProxyFontsParams = {},
|
||||||
|
): Promise<ProxyFontsResponse> {
|
||||||
|
// Try proxy API first if enabled
|
||||||
|
if (USE_PROXY_API) {
|
||||||
|
try {
|
||||||
|
const queryString = buildQueryString(params);
|
||||||
|
const url = `${PROXY_API_URL}${queryString}`;
|
||||||
|
|
||||||
|
console.log('[fetchProxyFonts] Fetching from proxy API', { params, url });
|
||||||
|
|
||||||
|
const response = await api.get<ProxyFontsResponse>(url);
|
||||||
|
|
||||||
|
// Validate response has fonts array
|
||||||
|
if (!response.data || !Array.isArray(response.data.fonts)) {
|
||||||
|
console.error('[fetchProxyFonts] Invalid response from proxy API', response.data);
|
||||||
|
throw new Error('Proxy API returned invalid response');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[fetchProxyFonts] Proxy API success', {
|
||||||
|
count: response.data.fonts.length,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[fetchProxyFonts] Proxy API failed, using fallback', error);
|
||||||
|
|
||||||
|
// Check if it's a network error or proxy not available
|
||||||
|
const isNetworkError = error instanceof Error
|
||||||
|
&& (error.message.includes('Failed to fetch')
|
||||||
|
|| error.message.includes('Network')
|
||||||
|
|| error.message.includes('404')
|
||||||
|
|| error.message.includes('500'));
|
||||||
|
|
||||||
|
if (isNetworkError) {
|
||||||
|
// Fall back to Fontshare API
|
||||||
|
console.log('[fetchProxyFonts] Using Fontshare API as fallback');
|
||||||
|
return await fetchFontshareFallback(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-throw other errors
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch fonts from proxy API: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Fontshare API directly
|
||||||
|
console.log('[fetchProxyFonts] Using Fontshare API (proxy disabled)');
|
||||||
|
return await fetchFontshareFallback(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback to Fontshare API when proxy is unavailable
|
||||||
|
*
|
||||||
|
* Maps proxy API params to Fontshare API params and normalizes response
|
||||||
|
*/
|
||||||
|
async function fetchFontshareFallback(
|
||||||
|
params: ProxyFontsParams,
|
||||||
|
): Promise<ProxyFontsResponse> {
|
||||||
|
// Import dynamically to avoid circular dependency
|
||||||
|
const { fetchFontshareFonts } = await import('$entities/Font/api/fontshare/fontshare');
|
||||||
|
const { normalizeFontshareFonts } = await import('$entities/Font/lib/normalize/normalize');
|
||||||
|
|
||||||
|
// Map proxy params to Fontshare params
|
||||||
|
const fontshareParams = {
|
||||||
|
q: params.q,
|
||||||
|
categories: params.category ? [params.category] : undefined,
|
||||||
|
page: params.offset ? Math.floor(params.offset / (params.limit || 50)) + 1 : undefined,
|
||||||
|
limit: params.limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetchFontshareFonts(fontshareParams);
|
||||||
|
const normalizedFonts = normalizeFontshareFonts(response.fonts);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fonts: normalizedFonts,
|
||||||
|
total: response.count_total,
|
||||||
|
limit: params.limit || response.count,
|
||||||
|
offset: params.offset || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch font by ID
|
||||||
|
*
|
||||||
|
* Convenience function for fetching a single font by ID
|
||||||
|
* Note: This fetches a page and filters client-side, which is not ideal
|
||||||
|
* For production, consider adding a dedicated endpoint to the proxy API
|
||||||
|
*
|
||||||
|
* @param id - Font ID (family name for Google, slug for Fontshare)
|
||||||
|
* @returns Promise resolving to font or undefined
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const roboto = await fetchProxyFontById('Roboto');
|
||||||
|
* const satoshi = await fetchProxyFontById('satoshi');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function fetchProxyFontById(
|
||||||
|
id: string,
|
||||||
|
): Promise<UnifiedFont | undefined> {
|
||||||
|
const response = await fetchProxyFonts({ limit: 1000, q: id });
|
||||||
|
|
||||||
|
if (!response || !response.fonts) {
|
||||||
|
console.error('[fetchProxyFontById] No fonts in response', { response });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.fonts.find(font => font.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch multiple fonts by their IDs
|
||||||
|
*
|
||||||
|
* @param ids - Array of font IDs to fetch
|
||||||
|
* @returns Promise resolving to an array of fonts
|
||||||
|
*/
|
||||||
|
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
|
// Use proxy API if enabled
|
||||||
|
if (USE_PROXY_API) {
|
||||||
|
const queryString = ids.join(',');
|
||||||
|
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get<UnifiedFont[]>(url);
|
||||||
|
return response.data ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[fetchFontsByIds] Proxy API batch fetch failed, falling back', error);
|
||||||
|
// Fallthrough to fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Fetch individually (not efficient but functional for fallback)
|
||||||
|
const results = await Promise.all(
|
||||||
|
ids.map(id => fetchProxyFontById(id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.filter((f): f is UnifiedFont => !!f);
|
||||||
|
}
|
||||||
@@ -1,3 +1,15 @@
|
|||||||
|
// Proxy API (PRIMARY)
|
||||||
|
export {
|
||||||
|
fetchFontsByIds,
|
||||||
|
fetchProxyFontById,
|
||||||
|
fetchProxyFonts,
|
||||||
|
} from './api/proxy/proxyFonts';
|
||||||
|
export type {
|
||||||
|
ProxyFontsParams,
|
||||||
|
ProxyFontsResponse,
|
||||||
|
} from './api/proxy/proxyFonts';
|
||||||
|
|
||||||
|
// Fontshare API (DEPRECATED)
|
||||||
export {
|
export {
|
||||||
fetchAllFontshareFonts,
|
fetchAllFontshareFonts,
|
||||||
fetchFontshareFontBySlug,
|
fetchFontshareFontBySlug,
|
||||||
@@ -7,6 +19,8 @@ export type {
|
|||||||
FontshareParams,
|
FontshareParams,
|
||||||
FontshareResponse,
|
FontshareResponse,
|
||||||
} from './api/fontshare/fontshare';
|
} from './api/fontshare/fontshare';
|
||||||
|
|
||||||
|
// Google Fonts API (DEPRECATED)
|
||||||
export {
|
export {
|
||||||
fetchGoogleFontFamily,
|
fetchGoogleFontFamily,
|
||||||
fetchGoogleFonts,
|
fetchGoogleFonts,
|
||||||
@@ -42,7 +56,6 @@ export type {
|
|||||||
FontshareFont,
|
FontshareFont,
|
||||||
FontshareLink,
|
FontshareLink,
|
||||||
FontsharePublisher,
|
FontsharePublisher,
|
||||||
FontshareStore,
|
|
||||||
FontshareStyle,
|
FontshareStyle,
|
||||||
FontshareStyleProperties,
|
FontshareStyleProperties,
|
||||||
FontshareTag,
|
FontshareTag,
|
||||||
@@ -60,16 +73,64 @@ export type {
|
|||||||
} from './model';
|
} from './model';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createFontshareStore,
|
appliedFontsManager,
|
||||||
fetchFontshareFontsQuery,
|
createUnifiedFontStore,
|
||||||
fontshareStore,
|
unifiedFontStore,
|
||||||
} from './model';
|
} from './model';
|
||||||
|
|
||||||
// Stores
|
// Mock data helpers for Storybook and testing
|
||||||
export {
|
export {
|
||||||
createGoogleFontsStore,
|
createCategoriesFilter,
|
||||||
GoogleFontsStore,
|
createErrorState,
|
||||||
} from './model/services/fetchGoogleFonts.svelte';
|
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 { FontList } from './ui';
|
export {
|
||||||
|
FontApplicator,
|
||||||
|
FontListItem,
|
||||||
|
FontVirtualList,
|
||||||
|
} from './ui';
|
||||||
|
|||||||
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -24,11 +24,9 @@ describe('Font Normalization', () => {
|
|||||||
subsets: ['latin', 'latin-ext'],
|
subsets: ['latin', 'latin-ext'],
|
||||||
files: {
|
files: {
|
||||||
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||||
'700':
|
'700': 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
||||||
'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
|
||||||
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
|
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
|
||||||
'700italic':
|
'700italic': 'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
|
||||||
'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
|
|
||||||
},
|
},
|
||||||
version: 'v30',
|
version: 'v30',
|
||||||
lastModified: '2022-01-01',
|
lastModified: '2022-01-01',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ export type {
|
|||||||
UnifiedFontVariant,
|
UnifiedFontVariant,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export { fetchFontshareFontsQuery } from './services';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createFontshareStore,
|
appliedFontsManager,
|
||||||
type FontshareStore,
|
createUnifiedFontStore,
|
||||||
fontshareStore,
|
type FontConfigRequest,
|
||||||
|
type UnifiedFontStore,
|
||||||
|
unifiedFontStore,
|
||||||
} from './store';
|
} from './store';
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import {
|
|
||||||
type FontshareParams,
|
|
||||||
fetchFontshareFonts,
|
|
||||||
} from '../../api';
|
|
||||||
import { normalizeFontshareFonts } from '../../lib';
|
|
||||||
import type { UnifiedFont } from '../types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query function for fetching fonts from Fontshare.
|
|
||||||
*
|
|
||||||
* @param params - The parameters for fetching fonts from Fontshare (E.g. search query, page number, etc.).
|
|
||||||
* @returns A promise that resolves with an array of UnifiedFont objects representing the fonts found in Fontshare.
|
|
||||||
*/
|
|
||||||
export async function fetchFontshareFontsQuery(params: FontshareParams): Promise<UnifiedFont[]> {
|
|
||||||
try {
|
|
||||||
const response = await fetchFontshareFonts(params);
|
|
||||||
return normalizeFontshareFonts(response.fonts);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.message.includes('Failed to fetch')) {
|
|
||||||
throw new Error(
|
|
||||||
'Unable to connect to Fontshare. Please check your internet connection.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (error.message.includes('404')) {
|
|
||||||
throw new Error('Font not found in Fontshare catalog.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('Failed to load fonts from Fontshare.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
/**
|
|
||||||
* Service for fetching Google Fonts with Svelte 5 runes + TanStack Query
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
type CreateQueryResult,
|
|
||||||
createQuery,
|
|
||||||
useQueryClient,
|
|
||||||
} from '@tanstack/svelte-query';
|
|
||||||
import {
|
|
||||||
type GoogleFontsParams,
|
|
||||||
fetchGoogleFonts,
|
|
||||||
} from '../../api';
|
|
||||||
import { normalizeGoogleFonts } from '../../lib';
|
|
||||||
import type {
|
|
||||||
FontCategory,
|
|
||||||
FontSubset,
|
|
||||||
} from '../types';
|
|
||||||
import type { UnifiedFont } from '../types/normalize';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query key factory
|
|
||||||
*/
|
|
||||||
function getGoogleFontsQueryKey(params: GoogleFontsParams) {
|
|
||||||
return ['googleFonts', params] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query function
|
|
||||||
*/
|
|
||||||
export async function fetchGoogleFontsQuery(params: GoogleFontsParams): Promise<UnifiedFont[]> {
|
|
||||||
try {
|
|
||||||
const response = await fetchGoogleFonts({
|
|
||||||
category: params.category,
|
|
||||||
subset: params.subset,
|
|
||||||
sort: params.sort,
|
|
||||||
});
|
|
||||||
return normalizeGoogleFonts(response.items);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.message.includes('Failed to fetch')) {
|
|
||||||
throw new Error(
|
|
||||||
'Unable to connect to Google Fonts. Please check your internet connection.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (error.message.includes('404')) {
|
|
||||||
throw new Error('Font not found in Google Fonts catalog.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('Failed to load fonts from Google Fonts.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Google Fonts store wrapping TanStack Query with runes
|
|
||||||
*/
|
|
||||||
export class GoogleFontsStore {
|
|
||||||
params = $state<GoogleFontsParams>({});
|
|
||||||
private query: CreateQueryResult<UnifiedFont[], Error>;
|
|
||||||
private queryClient = useQueryClient();
|
|
||||||
|
|
||||||
constructor(initialParams: GoogleFontsParams = {}) {
|
|
||||||
this.params = initialParams;
|
|
||||||
|
|
||||||
// Create the query - automatically reactive
|
|
||||||
this.query = createQuery(() => ({
|
|
||||||
queryKey: getGoogleFontsQueryKey(this.params),
|
|
||||||
queryFn: () => fetchGoogleFontsQuery(this.params),
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proxy TanStack Query's reactive state
|
|
||||||
get fonts() {
|
|
||||||
return this.query.data ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
get isLoading() {
|
|
||||||
return this.query.isLoading;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isFetching() {
|
|
||||||
return this.query.isFetching;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isRefetching() {
|
|
||||||
return this.query.isRefetching;
|
|
||||||
}
|
|
||||||
|
|
||||||
get error() {
|
|
||||||
return this.query.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isError() {
|
|
||||||
return this.query.isError;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isSuccess() {
|
|
||||||
return this.query.isSuccess;
|
|
||||||
}
|
|
||||||
|
|
||||||
get status() {
|
|
||||||
return this.query.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derived helpers
|
|
||||||
get hasData() {
|
|
||||||
return this.fonts.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isEmpty() {
|
|
||||||
return !this.isLoading && this.fonts.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
get fontCount() {
|
|
||||||
return this.fonts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtered fonts by category (if you need additional client-side filtering)
|
|
||||||
get sansSerifFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'sans-serif');
|
|
||||||
}
|
|
||||||
|
|
||||||
get serifFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'serif');
|
|
||||||
}
|
|
||||||
|
|
||||||
get displayFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'display');
|
|
||||||
}
|
|
||||||
|
|
||||||
get handwritingFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'handwriting');
|
|
||||||
}
|
|
||||||
|
|
||||||
get monospaceFonts() {
|
|
||||||
return this.fonts.filter(f => f.category === 'monospace');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update parameters - TanStack Query will automatically refetch
|
|
||||||
*/
|
|
||||||
setParams(newParams: Partial<GoogleFontsParams>) {
|
|
||||||
this.params = { ...this.params, ...newParams };
|
|
||||||
}
|
|
||||||
|
|
||||||
setCategory(category: FontCategory | undefined) {
|
|
||||||
this.setParams({ category });
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubset(subset: FontSubset | undefined) {
|
|
||||||
this.setParams({ subset });
|
|
||||||
}
|
|
||||||
|
|
||||||
setSort(sort: 'popularity' | 'alpha' | 'date' | undefined) {
|
|
||||||
this.setParams({ sort });
|
|
||||||
}
|
|
||||||
|
|
||||||
setSearch(search: string) {
|
|
||||||
this.setParams({ search });
|
|
||||||
}
|
|
||||||
|
|
||||||
clearSearch() {
|
|
||||||
this.setParams({ search: undefined });
|
|
||||||
}
|
|
||||||
|
|
||||||
clearFilters() {
|
|
||||||
this.params = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually refetch
|
|
||||||
*/
|
|
||||||
async refetch() {
|
|
||||||
await this.query.refetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate cache and refetch
|
|
||||||
*/
|
|
||||||
invalidate() {
|
|
||||||
this.queryClient.invalidateQueries({
|
|
||||||
queryKey: getGoogleFontsQueryKey(this.params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate all Google Fonts queries
|
|
||||||
*/
|
|
||||||
invalidateAll() {
|
|
||||||
this.queryClient.invalidateQueries({
|
|
||||||
queryKey: ['googleFonts'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prefetch with different params (for hover states, pagination, etc.)
|
|
||||||
*/
|
|
||||||
async prefetch(params: GoogleFontsParams) {
|
|
||||||
await this.queryClient.prefetchQuery({
|
|
||||||
queryKey: getGoogleFontsQueryKey(params),
|
|
||||||
queryFn: () => fetchGoogleFontsQuery(params),
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prefetch next category (useful for tab switching)
|
|
||||||
*/
|
|
||||||
async prefetchCategory(category: FontCategory) {
|
|
||||||
await this.prefetch({ ...this.params, category });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel ongoing queries
|
|
||||||
*/
|
|
||||||
cancel() {
|
|
||||||
this.queryClient.cancelQueries({
|
|
||||||
queryKey: getGoogleFontsQueryKey(this.params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear cache for current params
|
|
||||||
*/
|
|
||||||
clearCache() {
|
|
||||||
this.queryClient.removeQueries({
|
|
||||||
queryKey: getGoogleFontsQueryKey(this.params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cached data without triggering fetch
|
|
||||||
*/
|
|
||||||
getCachedData() {
|
|
||||||
return this.queryClient.getQueryData<UnifiedFont[]>(
|
|
||||||
getGoogleFontsQueryKey(this.params),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if data exists in cache
|
|
||||||
*/
|
|
||||||
hasCache(params?: GoogleFontsParams) {
|
|
||||||
const key = params ? getGoogleFontsQueryKey(params) : getGoogleFontsQueryKey(this.params);
|
|
||||||
return this.queryClient.getQueryData(key) !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set data manually (optimistic updates)
|
|
||||||
*/
|
|
||||||
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
|
|
||||||
this.queryClient.setQueryData(
|
|
||||||
getGoogleFontsQueryKey(this.params),
|
|
||||||
updater,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get query state for debugging
|
|
||||||
*/
|
|
||||||
getQueryState() {
|
|
||||||
return this.queryClient.getQueryState(
|
|
||||||
getGoogleFontsQueryKey(this.params),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function to create Google Fonts store
|
|
||||||
*/
|
|
||||||
export function createGoogleFontsStore(params: GoogleFontsParams = {}) {
|
|
||||||
return new GoogleFontsStore(params);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { fetchFontshareFontsQuery } from './fetchFontshareFonts.svelte';
|
|
||||||
export { fetchGoogleFontsQuery } from './fetchGoogleFonts.svelte';
|
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/** Configuration for a font load request. */
|
||||||
|
export interface FontConfigRequest {
|
||||||
|
/**
|
||||||
|
* Unique identifier for the font (e.g., "lato", "roboto").
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Actual font family name recognized by the browser (e.g., "Lato", "Roboto").
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* URL pointing to the font file (typically .ttf or .woff2).
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* Numeric weight (100-900). Variable fonts load once per ID regardless of weight.
|
||||||
|
*/
|
||||||
|
weight: number;
|
||||||
|
/**
|
||||||
|
* Variable fonts load once per ID; static fonts load per weight.
|
||||||
|
*/
|
||||||
|
isVariable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages web font loading with caching, adaptive concurrency, and automatic cleanup.
|
||||||
|
*
|
||||||
|
* **Two-Phase Loading Strategy:**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
export class AppliedFontsManager {
|
||||||
|
// Loaded FontFace instances registered with document.fonts. Key: `{id}@{weight}` or `{id}@vf`
|
||||||
|
#loadedFonts = new Map<string, FontFace>();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Interval handle for periodic cleanup (runs every PURGE_INTERVAL)
|
||||||
|
#intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// 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>();
|
||||||
|
|
||||||
|
// Starts periodic cleanup timer (browser-only).
|
||||||
|
constructor() {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generates font key: `{id}@vf` for variable, `{id}@{weight}` for static.
|
||||||
|
#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[]) {
|
||||||
|
if (this.#abortController.signal.aborted) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
let hasNewItems = false;
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
const key = this.#getFontKey(config.id, config.weight, !!config.isVariable);
|
||||||
|
this.#usageTracker.set(key, now);
|
||||||
|
|
||||||
|
const status = this.statuses.get(key);
|
||||||
|
if (status === 'loaded' || status === 'loading' || this.#queue.has(key)) continue;
|
||||||
|
if (status === 'error' && (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES) continue;
|
||||||
|
|
||||||
|
this.#queue.set(key, config);
|
||||||
|
hasNewItems = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.#pendingType = null;
|
||||||
|
|
||||||
|
let entries = Array.from(this.#queue.entries());
|
||||||
|
if (!entries.length) return;
|
||||||
|
this.#queue.clear();
|
||||||
|
|
||||||
|
if (this.#shouldDeferNonCritical()) {
|
||||||
|
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Concurrent fetching (I/O bound, non-blocking)
|
||||||
|
const concurrency = this.#getEffectiveConcurrency();
|
||||||
|
const buffers = new Map<string, ArrayBuffer>();
|
||||||
|
|
||||||
|
for (let i = 0; i < entries.length; i += concurrency) {
|
||||||
|
const chunk = entries.slice(i, i + concurrency);
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
chunk.map(async ([key, config]) => {
|
||||||
|
this.statuses.set(key, 'loading');
|
||||||
|
const buffer = await this.#fetchFontBuffer(
|
||||||
|
config.url,
|
||||||
|
this.#abortController.signal,
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Sequential parsing (CPU-intensive, yields periodically)
|
||||||
|
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
||||||
|
let lastYield = performance.now();
|
||||||
|
const YIELD_INTERVAL = 8; // ms
|
||||||
|
|
||||||
|
for (const [key, config] of entries) {
|
||||||
|
const buffer = buffers.get(key);
|
||||||
|
if (!buffer) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const weightRange = config.isVariable ? '100 900' : `${config.weight}`;
|
||||||
|
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() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, lastUsed] of this.#usageTracker) {
|
||||||
|
if (now - lastUsed < this.#TTL) continue;
|
||||||
|
|
||||||
|
const font = this.#loadedFonts.get(key);
|
||||||
|
if (font) document.fonts.delete(font);
|
||||||
|
|
||||||
|
this.#loadedFonts.delete(key);
|
||||||
|
this.#usageTracker.delete(key);
|
||||||
|
this.statuses.delete(key);
|
||||||
|
this.#retryCounts.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns current loading status for a font, or undefined if never requested. */
|
||||||
|
getFontStatus(id: string, weight: number, isVariable = false) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#loadedFonts.clear();
|
||||||
|
this.#usageTracker.clear();
|
||||||
|
this.#retryCounts.clear();
|
||||||
|
this.statuses.clear();
|
||||||
|
this.#queue.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton instance — use throughout the application for unified font loading state. */
|
||||||
|
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,11 +55,12 @@ 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),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
|
gcTime: 10 * 60 * 1000,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { FontshareParams } from '../../api';
|
|
||||||
import { fetchFontshareFontsQuery } from '../services';
|
|
||||||
import type { UnifiedFont } from '../types';
|
|
||||||
import { BaseFontStore } from './baseFontStore.svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fontshare store wrapping TanStack Query with runes
|
|
||||||
*/
|
|
||||||
export class FontshareStore extends BaseFontStore<FontshareParams> {
|
|
||||||
constructor(initialParams: FontshareParams = {}) {
|
|
||||||
super(initialParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getQueryKey(params: FontshareParams) {
|
|
||||||
return ['fontshare', params] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async fetchFn(params: FontshareParams): Promise<UnifiedFont[]> {
|
|
||||||
return fetchFontshareFontsQuery(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider-specific methods (shortcuts)
|
|
||||||
setSearch(search: string) {
|
|
||||||
this.setParams({ q: search } as any);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createFontshareStore(params: FontshareParams = {}) {
|
|
||||||
return new FontshareStore(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fontshareStore = new FontshareStore();
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { GoogleFontsParams } from '../../api';
|
|
||||||
import { fetchGoogleFontsQuery } from '../services';
|
|
||||||
import type { UnifiedFont } from '../types';
|
|
||||||
import { BaseFontStore } from './baseFontStore.svelte';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Google Fonts store wrapping TanStack Query with runes
|
|
||||||
*/
|
|
||||||
export class GoogleFontsStore extends BaseFontStore<GoogleFontsParams> {
|
|
||||||
constructor(initialParams: GoogleFontsParams = {}) {
|
|
||||||
super(initialParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getQueryKey(params: GoogleFontsParams) {
|
|
||||||
return ['googleFonts', params] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async fetchFn(params: GoogleFontsParams): Promise<UnifiedFont[]> {
|
|
||||||
return fetchGoogleFontsQuery(params);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createFontshareStore(params: GoogleFontsParams = {}) {
|
|
||||||
return new GoogleFontsStore(params);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const googleFontsStore = new GoogleFontsStore();
|
|
||||||
@@ -6,14 +6,15 @@
|
|||||||
* Single export point for the unified font store infrastructure.
|
* Single export point for the unified font store infrastructure.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// export {
|
// Primary store (unified)
|
||||||
// createUnifiedFontStore,
|
|
||||||
// UNIFIED_FONT_STORE_KEY,
|
|
||||||
// type UnifiedFontStore,
|
|
||||||
// } from './unifiedFontStore.svelte';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createFontshareStore,
|
createUnifiedFontStore,
|
||||||
type FontshareStore,
|
type UnifiedFontStore,
|
||||||
fontshareStore,
|
unifiedFontStore,
|
||||||
} from './fontshareStore.svelte';
|
} from './unifiedFontStore.svelte';
|
||||||
|
|
||||||
|
// Applied fonts manager (CSS loading - unchanged)
|
||||||
|
export {
|
||||||
|
appliedFontsManager,
|
||||||
|
type FontConfigRequest,
|
||||||
|
} from './appliedFontsStore/appliedFontsStore.svelte';
|
||||||
|
|||||||
@@ -1,29 +1,377 @@
|
|||||||
import {
|
/**
|
||||||
type Filter,
|
* Unified font store
|
||||||
type FilterModel,
|
*
|
||||||
createFilter,
|
* Single source of truth for font data, powered by the proxy API.
|
||||||
} from '$shared/lib';
|
* Extends BaseFontStore for TanStack Query integration and reactivity.
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
*
|
||||||
import type { FontProvider } from '../types';
|
* Key features:
|
||||||
import type { CheckboxFilter } from '../types/common';
|
* - Provider-agnostic (proxy API handles provider logic)
|
||||||
import type { BaseFontStore } from './baseFontStore.svelte';
|
* - Reactive to filter changes
|
||||||
import { createFontshareStore } from './fontshareStore.svelte';
|
* - Optimistic updates via TanStack Query
|
||||||
import type { ProviderParams } from './types';
|
* - Pagination support
|
||||||
|
* - Provider-specific shortcuts for common operations
|
||||||
|
*/
|
||||||
|
|
||||||
export class UnitedFontStore {
|
import type { QueryObserverOptions } from '@tanstack/query-core';
|
||||||
private sources: Partial<Record<FontProvider, BaseFontStore<ProviderParams>>>;
|
import type { ProxyFontsParams } from '../../api';
|
||||||
|
import { fetchProxyFonts } from '../../api';
|
||||||
|
import type { UnifiedFont } from '../types';
|
||||||
|
import { BaseFontStore } from './baseFontStore.svelte';
|
||||||
|
|
||||||
filters: SvelteMap<CheckboxFilter, Filter>;
|
/**
|
||||||
queryValue = $state('');
|
* Unified font store wrapping TanStack Query with Svelte 5 runes
|
||||||
|
*
|
||||||
|
* Extends BaseFontStore to provide:
|
||||||
|
* - Reactive state management
|
||||||
|
* - TanStack Query integration for caching
|
||||||
|
* - Dynamic parameter binding for filters
|
||||||
|
* - Pagination support
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const store = new UnifiedFontStore({
|
||||||
|
* provider: 'google',
|
||||||
|
* category: 'sans-serif',
|
||||||
|
* limit: 50
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Access reactive state
|
||||||
|
* $effect(() => {
|
||||||
|
* console.log(store.fonts);
|
||||||
|
* console.log(store.isLoading);
|
||||||
|
* console.log(store.pagination);
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Update parameters
|
||||||
|
* store.setCategory('serif');
|
||||||
|
* store.nextPage();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
||||||
|
/**
|
||||||
|
* Store pagination metadata separately from fonts
|
||||||
|
* This is a workaround for TanStack Query's type system
|
||||||
|
*/
|
||||||
|
#paginationMetadata = $state<
|
||||||
|
{
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
} | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
constructor(initialConfig: Partial<Record<FontProvider, ProviderParams>> = {}) {
|
/**
|
||||||
this.sources = {
|
* Accumulated fonts from all pages (for infinite scroll)
|
||||||
fontshare: createFontshareStore(initialConfig?.fontshare),
|
*/
|
||||||
|
#accumulatedFonts = $state<UnifiedFont[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination metadata (derived from proxy API response)
|
||||||
|
*/
|
||||||
|
readonly pagination = $derived.by(() => {
|
||||||
|
if (this.#paginationMetadata) {
|
||||||
|
const { total, limit, offset } = this.#paginationMetadata;
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore: offset + limit < total,
|
||||||
|
page: Math.floor(offset / limit) + 1,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
limit: this.params.limit || 50,
|
||||||
|
offset: this.params.offset || 0,
|
||||||
|
hasMore: false,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 0,
|
||||||
};
|
};
|
||||||
this.filters = new SvelteMap();
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track previous filter params to detect changes and reset pagination
|
||||||
|
*/
|
||||||
|
#previousFilterParams = $state<string>('');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup function for the filter tracking effect
|
||||||
|
*/
|
||||||
|
#filterCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(initialParams: ProxyFontsParams = {}) {
|
||||||
|
super(initialParams);
|
||||||
|
|
||||||
|
// Track filter params (excluding pagination params)
|
||||||
|
// Wrapped in $effect.root() to prevent effect_orphan error
|
||||||
|
this.#filterCleanup = $effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
const filterParams = JSON.stringify({
|
||||||
|
provider: this.params.provider,
|
||||||
|
category: this.params.category,
|
||||||
|
subset: this.params.subset,
|
||||||
|
q: this.params.q,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If filters changed, reset offset to 0
|
||||||
|
if (filterParams !== this.#previousFilterParams) {
|
||||||
|
if (this.#previousFilterParams && this.params.offset !== 0) {
|
||||||
|
this.setParams({ offset: 0 });
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get fonts() {
|
/**
|
||||||
return Object.values(this.sources).map(store => store.fonts).flat();
|
* Clean up both parent and child effects
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
// Call parent cleanup (TanStack observer effect)
|
||||||
|
super.destroy();
|
||||||
|
|
||||||
|
// Call filter tracking effect cleanup
|
||||||
|
if (this.#filterCleanup) {
|
||||||
|
this.#filterCleanup();
|
||||||
|
this.#filterCleanup = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query key for TanStack Query caching
|
||||||
|
* Normalizes params to treat empty arrays/strings as undefined
|
||||||
|
*/
|
||||||
|
protected getQueryKey(params: ProxyFontsParams) {
|
||||||
|
// Normalize params to treat empty arrays/strings as undefined
|
||||||
|
const normalized = Object.entries(params).reduce((acc, [key, value]) => {
|
||||||
|
if (value === '' || value === undefined || (Array.isArray(value) && value.length === 0)) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
return { ...acc, [key]: value };
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Return a consistent key
|
||||||
|
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
|
||||||
|
* Returns the full response including pagination metadata
|
||||||
|
*/
|
||||||
|
protected async fetchFn(params: ProxyFontsParams): Promise<UnifiedFont[]> {
|
||||||
|
const response = await fetchProxyFonts(params);
|
||||||
|
|
||||||
|
// Validate response structure
|
||||||
|
if (!response) {
|
||||||
|
console.error('[UnifiedFontStore] fetchProxyFonts returned undefined', { params });
|
||||||
|
throw new Error('Proxy API returned undefined response');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.fonts) {
|
||||||
|
console.error('[UnifiedFontStore] response.fonts is undefined', { response });
|
||||||
|
throw new Error('Proxy API response missing fonts array');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(response.fonts)) {
|
||||||
|
console.error('[UnifiedFontStore] response.fonts is not an array', {
|
||||||
|
fonts: response.fonts,
|
||||||
|
});
|
||||||
|
throw new Error('Proxy API fonts is not an array');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store pagination metadata separately for derived values
|
||||||
|
this.#paginationMetadata = {
|
||||||
|
total: response.total ?? 0,
|
||||||
|
limit: response.limit ?? this.params.limit ?? 50,
|
||||||
|
offset: response.offset ?? this.params.offset ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Accumulate fonts for infinite scroll
|
||||||
|
// Note: For offset === 0, we rely on the $effect above to handle the reset/init
|
||||||
|
// This prevents race conditions and double-setting.
|
||||||
|
if (params.offset !== 0) {
|
||||||
|
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.fonts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Getters (proxied from BaseFontStore) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all accumulated fonts (for infinite scroll)
|
||||||
|
*/
|
||||||
|
get fonts(): UnifiedFont[] {
|
||||||
|
return this.#accumulatedFonts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if loading initial data
|
||||||
|
*/
|
||||||
|
get isLoading(): boolean {
|
||||||
|
return this.result.isLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if fetching (including background refetches)
|
||||||
|
*/
|
||||||
|
get isFetching(): boolean {
|
||||||
|
return this.result.isFetching;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error occurred
|
||||||
|
*/
|
||||||
|
get isError(): boolean {
|
||||||
|
return this.result.isError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if result is empty (not loading and no fonts)
|
||||||
|
*/
|
||||||
|
get isEmpty(): boolean {
|
||||||
|
return !this.isLoading && this.fonts.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Provider-specific shortcuts ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set provider filter
|
||||||
|
*/
|
||||||
|
setProvider(provider: 'google' | 'fontshare' | undefined) {
|
||||||
|
this.setParams({ provider });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set category filter
|
||||||
|
*/
|
||||||
|
setCategory(category: ProxyFontsParams['category']) {
|
||||||
|
this.setParams({ category });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set subset filter
|
||||||
|
*/
|
||||||
|
setSubset(subset: ProxyFontsParams['subset']) {
|
||||||
|
this.setParams({ subset });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set search query
|
||||||
|
*/
|
||||||
|
setSearch(search: string) {
|
||||||
|
this.setParams({ q: search || undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set sort order
|
||||||
|
*/
|
||||||
|
setSort(sort: ProxyFontsParams['sort']) {
|
||||||
|
this.setParams({ sort });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pagination methods ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to next page
|
||||||
|
*/
|
||||||
|
nextPage() {
|
||||||
|
if (this.pagination.hasMore) {
|
||||||
|
this.setParams({
|
||||||
|
offset: this.pagination.offset + this.pagination.limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to previous page
|
||||||
|
*/
|
||||||
|
prevPage() {
|
||||||
|
if (this.pagination.page > 1) {
|
||||||
|
this.setParams({
|
||||||
|
offset: this.pagination.offset - this.pagination.limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go to specific page
|
||||||
|
*/
|
||||||
|
goToPage(page: number) {
|
||||||
|
if (page >= 1 && page <= this.pagination.totalPages) {
|
||||||
|
this.setParams({
|
||||||
|
offset: (page - 1) * this.pagination.limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set limit (items per page)
|
||||||
|
*/
|
||||||
|
setLimit(limit: number) {
|
||||||
|
this.setParams({ limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Category shortcuts (for convenience) ---
|
||||||
|
|
||||||
|
get sansSerifFonts() {
|
||||||
|
return this.fonts.filter(f => f.category === 'sans-serif');
|
||||||
|
}
|
||||||
|
|
||||||
|
get serifFonts() {
|
||||||
|
return this.fonts.filter(f => f.category === 'serif');
|
||||||
|
}
|
||||||
|
|
||||||
|
get displayFonts() {
|
||||||
|
return this.fonts.filter(f => f.category === 'display');
|
||||||
|
}
|
||||||
|
|
||||||
|
get handwritingFonts() {
|
||||||
|
return this.fonts.filter(f => f.category === 'handwriting');
|
||||||
|
}
|
||||||
|
|
||||||
|
get monospaceFonts() {
|
||||||
|
return this.fonts.filter(f => f.category === 'monospace');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create unified font store
|
||||||
|
*/
|
||||||
|
export function createUnifiedFontStore(params: ProxyFontsParams = {}) {
|
||||||
|
return new UnifiedFontStore(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton instance for global use
|
||||||
|
* Initialized with a default limit to prevent fetching all fonts at once
|
||||||
|
*/
|
||||||
|
export const unifiedFontStore = new UnifiedFontStore({
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
77
src/entities/Font/ui/FontApplicator/FontApplicator.svelte
Normal file
77
src/entities/Font/ui/FontApplicator/FontApplicator.svelte
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<!--
|
||||||
|
Component: FontApplicator
|
||||||
|
Loads fonts from fontshare with link tag
|
||||||
|
- Loads font only if it's not already applied
|
||||||
|
- Reacts to font load status to show/hide content
|
||||||
|
- Adds smooth transition when font appears
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { prefersReducedMotion } from 'svelte/motion';
|
||||||
|
import {
|
||||||
|
type UnifiedFont,
|
||||||
|
appliedFontsManager,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Applied font
|
||||||
|
*/
|
||||||
|
font: UnifiedFont;
|
||||||
|
/**
|
||||||
|
* Font weight
|
||||||
|
*/
|
||||||
|
weight?: number;
|
||||||
|
/**
|
||||||
|
* Additional classes
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Children
|
||||||
|
*/
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
font,
|
||||||
|
weight = 400,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const status = $derived(
|
||||||
|
appliedFontsManager.getFontStatus(
|
||||||
|
font.id,
|
||||||
|
weight,
|
||||||
|
font.features.isVariable,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The "Show" condition: Font is loaded OR it errored out OR it's a noTouch preview (like in search)
|
||||||
|
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
||||||
|
|
||||||
|
const transitionClasses = $derived(
|
||||||
|
prefersReducedMotion.current
|
||||||
|
? 'transition-none' // Disable CSS transitions if motion is reduced
|
||||||
|
: 'transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style:font-family={shouldReveal
|
||||||
|
? `'${font.name}'`
|
||||||
|
: 'system-ui, -apple-system, sans-serif'}
|
||||||
|
class={cn(
|
||||||
|
transitionClasses,
|
||||||
|
// If reduced motion is on, we skip the transform/blur entirely
|
||||||
|
!shouldReveal
|
||||||
|
&& !prefersReducedMotion.current
|
||||||
|
&& 'opacity-50 scale-[0.95] blur-sm',
|
||||||
|
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
|
||||||
|
shouldReveal && 'opacity-100 scale-100 blur-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { fontshareStore } from '$entities/Font/model/store/fontshareStore.svelte';
|
|
||||||
import type { UnifiedFont } from '$entities/Font/model/types/normalize';
|
|
||||||
import {
|
|
||||||
Content as ItemContent,
|
|
||||||
Root as ItemRoot,
|
|
||||||
Title as ItemTitle,
|
|
||||||
} from '$shared/shadcn/ui/item';
|
|
||||||
import { VirtualList } from '$shared/ui';
|
|
||||||
/**
|
|
||||||
* FontList
|
|
||||||
*
|
|
||||||
* Displays a virtualized list of fonts with loading, empty, and error states.
|
|
||||||
* Uses unifiedFontStore from context for data, but can accept explicit fonts via props.
|
|
||||||
*/
|
|
||||||
interface FontListProps {
|
|
||||||
/** Font items to display (defaults to filtered fonts from store) */
|
|
||||||
fonts?: UnifiedFont[];
|
|
||||||
/** Show loading state */
|
|
||||||
loading?: boolean;
|
|
||||||
/** Show empty state when no results */
|
|
||||||
showEmpty?: boolean;
|
|
||||||
/** Custom error message to display */
|
|
||||||
errorMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
fonts,
|
|
||||||
loading,
|
|
||||||
showEmpty = true,
|
|
||||||
errorMessage,
|
|
||||||
}: FontListProps = $props();
|
|
||||||
|
|
||||||
// const fontshareStore = getFontshareContext();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#each fontshareStore.fonts as font (font.id)}
|
|
||||||
<ItemRoot>
|
|
||||||
<ItemContent>
|
|
||||||
<ItemTitle>{font.name}</ItemTitle>
|
|
||||||
<span class="text-xs text-muted-foreground">
|
|
||||||
{font.category} • {font.provider}
|
|
||||||
</span>
|
|
||||||
</ItemContent>
|
|
||||||
</ItemRoot>
|
|
||||||
{/each}
|
|
||||||
39
src/entities/Font/ui/FontListItem/FontListItem.svelte
Normal file
39
src/entities/Font/ui/FontListItem/FontListItem.svelte
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { type UnifiedFont } from '../../model';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Object with information about font
|
||||||
|
*/
|
||||||
|
font: UnifiedFont;
|
||||||
|
/**
|
||||||
|
* Is element fully visible
|
||||||
|
*/
|
||||||
|
isFullyVisible: boolean;
|
||||||
|
/**
|
||||||
|
* Is element partially visible
|
||||||
|
*/
|
||||||
|
isPartiallyVisible: boolean;
|
||||||
|
/**
|
||||||
|
* From 0 to 1
|
||||||
|
*/
|
||||||
|
proximity: number;
|
||||||
|
/**
|
||||||
|
* Children snippet
|
||||||
|
*/
|
||||||
|
children: Snippet<[font: UnifiedFont]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { font, children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'pb-1 will-change-transform transition-transform duration-200 ease-out',
|
||||||
|
'hover:scale-[0.98]', // Simple CSS hover effect
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{@render children?.(font)}
|
||||||
|
</div>
|
||||||
129
src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte
Normal file
129
src/entities/Font/ui/FontVirtualList/FontVirtualList.svelte
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<!--
|
||||||
|
Component: FontVirtualList
|
||||||
|
- Renders a virtualized list of fonts
|
||||||
|
- Handles font registration with the manager
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
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,
|
||||||
|
appliedFontsManager,
|
||||||
|
unifiedFontStore,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
|
interface Props extends
|
||||||
|
Omit<
|
||||||
|
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;
|
||||||
|
/**
|
||||||
|
* Skeleton snippet
|
||||||
|
*/
|
||||||
|
skeleton?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
onVisibleItemsChange,
|
||||||
|
weight,
|
||||||
|
skeleton,
|
||||||
|
...rest
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
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
|
||||||
|
appliedFontsManager.touch(configs);
|
||||||
|
|
||||||
|
// Forward the call to any external listener
|
||||||
|
// onVisibleItemsChange?.(visibleItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load more fonts by moving to the next page
|
||||||
|
*/
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div class="relative w-full h-full">
|
||||||
|
{#if skeleton && isLoading && unifiedFontStore.fonts.length === 0}
|
||||||
|
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
|
||||||
|
<div transition:fade={{ duration: 300 }}>
|
||||||
|
{@render skeleton()}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||||
|
<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>
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
import FontList from './FontList/FontList.svelte';
|
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
||||||
|
import FontListItem from './FontListItem/FontListItem.svelte';
|
||||||
|
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
||||||
|
|
||||||
export { FontList };
|
export {
|
||||||
|
FontApplicator,
|
||||||
|
FontListItem,
|
||||||
|
FontVirtualList,
|
||||||
|
};
|
||||||
|
|||||||
1
src/features/DisplayFont/index.ts
Normal file
1
src/features/DisplayFont/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { FontSampler } from './ui';
|
||||||
110
src/features/DisplayFont/ui/FontSampler/FontSampler.svelte
Normal file
110
src/features/DisplayFont/ui/FontSampler/FontSampler.svelte
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<!--
|
||||||
|
Component: FontSampler
|
||||||
|
Displays a sample text with a given font in a contenteditable element.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
FontApplicator,
|
||||||
|
type UnifiedFont,
|
||||||
|
} from '$entities/Font';
|
||||||
|
import { controlManager } from '$features/SetupFont';
|
||||||
|
import {
|
||||||
|
ContentEditable,
|
||||||
|
Footnote,
|
||||||
|
// IconButton,
|
||||||
|
} from '$shared/ui';
|
||||||
|
// import XIcon from '@lucide/svelte/icons/x';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Font info
|
||||||
|
*/
|
||||||
|
font: UnifiedFont;
|
||||||
|
/**
|
||||||
|
* Text to display
|
||||||
|
*/
|
||||||
|
text: string;
|
||||||
|
/**
|
||||||
|
* Index of the font sampler
|
||||||
|
*/
|
||||||
|
index?: number;
|
||||||
|
/**
|
||||||
|
* Font settings
|
||||||
|
*/
|
||||||
|
fontSize?: number;
|
||||||
|
lineHeight?: number;
|
||||||
|
letterSpacing?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { font, text = $bindable(), index = 0, ...restProps }: Props = $props();
|
||||||
|
|
||||||
|
const fontWeight = $derived(controlManager.weight);
|
||||||
|
const fontSize = $derived(controlManager.renderedSize);
|
||||||
|
const lineHeight = $derived(controlManager.height);
|
||||||
|
const letterSpacing = $derived(controlManager.spacing);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="
|
||||||
|
w-full h-full rounded-xl sm:rounded-2xl
|
||||||
|
flex flex-col
|
||||||
|
bg-background-80
|
||||||
|
border border-border-muted
|
||||||
|
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||||
|
relative overflow-hidden
|
||||||
|
"
|
||||||
|
style:font-weight={fontWeight}
|
||||||
|
>
|
||||||
|
<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 sm:gap-2.5">
|
||||||
|
<Footnote>
|
||||||
|
typeface_{String(index).padStart(3, '0')}
|
||||||
|
</Footnote>
|
||||||
|
<div class="w-px h-2 sm:h-2.5 bg-border-subtle"></div>
|
||||||
|
<div class="font-bold text-foreground">
|
||||||
|
{font.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
<IconButton
|
||||||
|
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}
|
||||||
|
</IconButton>
|
||||||
|
-->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 sm:p-5 md:p-8 relative z-10">
|
||||||
|
<FontApplicator {font} weight={fontWeight}>
|
||||||
|
<ContentEditable
|
||||||
|
bind:text
|
||||||
|
{...restProps}
|
||||||
|
{fontSize}
|
||||||
|
{lineHeight}
|
||||||
|
{letterSpacing}
|
||||||
|
/>
|
||||||
|
</FontApplicator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<Footnote class="text-[7px] sm:text-[8px] tracking-wider ml-auto">
|
||||||
|
SZ:{fontSize}PX
|
||||||
|
</Footnote>
|
||||||
|
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||||
|
<Footnote class="text-[7px] sm:text-[8px] tracking-wider">
|
||||||
|
WGT:{fontWeight}
|
||||||
|
</Footnote>
|
||||||
|
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||||
|
<Footnote class="text-[7px] sm:text-[8px] tracking-wider">
|
||||||
|
LH:{lineHeight?.toFixed(2)}
|
||||||
|
</Footnote>
|
||||||
|
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||||
|
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider">
|
||||||
|
LTR:{letterSpacing}
|
||||||
|
</Footnote>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
3
src/features/DisplayFont/ui/index.ts
Normal file
3
src/features/DisplayFont/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||||
|
|
||||||
|
export { FontSampler };
|
||||||
@@ -15,5 +15,4 @@ export { filterManager } from './model/state/manager.svelte';
|
|||||||
export {
|
export {
|
||||||
FilterControls,
|
FilterControls,
|
||||||
Filters,
|
Filters,
|
||||||
FontSearch,
|
|
||||||
} from './ui';
|
} from './ui';
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import type { FilterConfig } from '../../model';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a filter manager instance.
|
* Create a filter manager instance.
|
||||||
|
* - Uses debounce to update search query for better performance.
|
||||||
|
* - Manages filter instances for each group.
|
||||||
|
*
|
||||||
|
* @param config - Configuration for the filter manager.
|
||||||
|
* @returns - An instance of the filter manager.
|
||||||
*/
|
*/
|
||||||
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
|
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
|
||||||
const search = createDebouncedState(config.queryValue ?? '');
|
const search = createDebouncedState(config.queryValue ?? '');
|
||||||
|
|||||||
@@ -1,12 +1,54 @@
|
|||||||
import type { FontshareParams } from '$entities/Font';
|
import type { ProxyFontsParams } from '$entities/Font/api';
|
||||||
import type { FilterManager } from '../filterManager/filterManager.svelte';
|
import type { FilterManager } from '../filterManager/filterManager.svelte';
|
||||||
|
|
||||||
export function mapManagerToParams(manager: FilterManager): Partial<FontshareParams> {
|
/**
|
||||||
|
* Maps filter manager to proxy API parameters.
|
||||||
|
*
|
||||||
|
* Transforms UI filter state into proxy API query parameters.
|
||||||
|
* Handles conversion from filter groups to API-specific parameters.
|
||||||
|
*
|
||||||
|
* @param manager - Filter manager instance with reactive state
|
||||||
|
* @returns - Partial proxy API parameters ready for API call
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Example filter manager state:
|
||||||
|
* // {
|
||||||
|
* // queryValue: 'roboto',
|
||||||
|
* // providers: ['google'],
|
||||||
|
* // categories: ['sans-serif'],
|
||||||
|
* // subsets: ['latin']
|
||||||
|
* // }
|
||||||
|
*
|
||||||
|
* const params = mapManagerToParams(manager);
|
||||||
|
* // Returns: { provider: 'google', category: 'sans-serif', subset: 'latin', q: 'roboto' }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function mapManagerToParams(manager: FilterManager): Partial<ProxyFontsParams> {
|
||||||
|
const providers = manager.getGroup('providers')?.instance.selectedProperties.map(p => p.value);
|
||||||
|
const categories = manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value);
|
||||||
|
const subsets = manager.getGroup('subsets')?.instance.selectedProperties.map(p => p.value);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
q: manager.debouncedQueryValue,
|
// Search query (debounced)
|
||||||
// Map groups to specific API keys
|
q: manager.debouncedQueryValue || undefined,
|
||||||
categories: manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value)
|
|
||||||
?? [],
|
// Provider filter (single value - proxy API doesn't support array)
|
||||||
tags: manager.getGroup('tags')?.instance.selectedProperties.map(p => p.value) ?? [],
|
// Use first provider if multiple selected, or undefined if none/all selected
|
||||||
|
provider: providers && providers.length === 1
|
||||||
|
? (providers[0] as 'google' | 'fontshare')
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
// Category filter (single value - proxy API doesn't support array)
|
||||||
|
// Use first category if multiple selected, or undefined if none/all selected
|
||||||
|
category: categories && categories.length === 1
|
||||||
|
? (categories[0] as ProxyFontsParams['category'])
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
// Subset filter (single value - proxy API doesn't support array)
|
||||||
|
// Use first subset if multiple selected, or undefined if none/all selected
|
||||||
|
subset: subsets && subsets.length === 1
|
||||||
|
? (subsets[0] as ProxyFontsParams['subset'])
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
|
<!--
|
||||||
|
Component: Filters
|
||||||
|
Renders a list of CheckboxFilter components for each filter group.
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
|
||||||
* Filters Component
|
|
||||||
*
|
|
||||||
* Orchestrates all filter properties for the sidebar. Connects filter stores
|
|
||||||
* to CheckboxFilter components, organizing them by filter type:
|
|
||||||
*
|
|
||||||
* - Font provider: Google Fonts vs Fontshare
|
|
||||||
* - Font subset: Character subsets available (Latin, Latin Extended, etc.)
|
|
||||||
* - Font category: Serif, Sans-serif, Display, etc.
|
|
||||||
*
|
|
||||||
* This component handles reactive sync between filterManager selections
|
|
||||||
* and the unifiedFontStore using an $effect block to ensure filters are
|
|
||||||
* automatically synchronized whenever selections change.
|
|
||||||
*/
|
|
||||||
import { CheckboxFilter } from '$shared/ui';
|
import { CheckboxFilter } from '$shared/ui';
|
||||||
import { filterManager } from '../../model';
|
import { filterManager } from '../../model';
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,22 +1,46 @@
|
|||||||
|
<!--
|
||||||
|
Component: FiltersControl
|
||||||
|
Renders a group of action buttons for filter operations.
|
||||||
|
- Reset: Clears all active filters (outline variant for secondary action)
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$shared/shadcn/ui/button';
|
import { Button } from '$shared/shadcn/ui/button';
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import Rotate from '@lucide/svelte/icons/rotate-ccw';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
|
import { Tween } from 'svelte/motion';
|
||||||
import { filterManager } from '../../model';
|
import { filterManager } from '../../model';
|
||||||
|
|
||||||
/**
|
interface Props {
|
||||||
* Controls Component
|
class?: string;
|
||||||
*
|
}
|
||||||
* Action button group for filter operations. Provides two buttons:
|
|
||||||
*
|
const { class: className }: Props = $props();
|
||||||
* - Reset: Clears all active filters (outline variant for secondary action)
|
|
||||||
*/
|
const transform = new Tween(
|
||||||
|
{ scale: 1, rotate: 0 },
|
||||||
|
{ duration: 150, easing: cubicOut },
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
filterManager.deselectAllGlobal();
|
||||||
|
|
||||||
|
transform.set({ scale: 0.98, rotate: 1 }).then(() => {
|
||||||
|
transform.set({ scale: 1, rotate: 0 });
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-row gap-2">
|
<div
|
||||||
|
class={cn('flex flex-row gap-2', className)}
|
||||||
|
style:transform="scale({transform.current.scale}) rotate({transform.current.rotate}deg)"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
class="flex-1 cursor-pointer"
|
class="group flex flex-1 cursor-pointer gap-1"
|
||||||
onclick={filterManager.deselectAllGlobal}
|
onclick={handleClick}
|
||||||
>
|
>
|
||||||
|
<Rotate class="size-4 group-hover:-rotate-180 transition-transform duration-300" />
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
FontList,
|
|
||||||
fontshareStore,
|
|
||||||
} from '$entities/Font';
|
|
||||||
import { SearchBar } from '$shared/ui';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { mapManagerToParams } from '../../lib';
|
|
||||||
import { filterManager } from '../../model';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FontSearch
|
|
||||||
*
|
|
||||||
* Font search component with search input and font list display.
|
|
||||||
* Uses unifiedFontStore for all font operations and search state.
|
|
||||||
*/
|
|
||||||
onMount(() => {
|
|
||||||
/**
|
|
||||||
* The Pairing:
|
|
||||||
* We "plug" this manager into the global store.
|
|
||||||
* addBinding returns a function that removes this binding when the component unmounts.
|
|
||||||
*/
|
|
||||||
const unbind = fontshareStore.addBinding(() => mapManagerToParams(filterManager));
|
|
||||||
|
|
||||||
return unbind;
|
|
||||||
});
|
|
||||||
|
|
||||||
$inspect(filterManager.queryValue, filterManager.debouncedQueryValue);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<SearchBar
|
|
||||||
id="font-search"
|
|
||||||
class="w-full"
|
|
||||||
placeholder="Search fonts by name..."
|
|
||||||
bind:value={filterManager.queryValue}
|
|
||||||
>
|
|
||||||
<FontList />
|
|
||||||
</SearchBar>
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import Filters from './Filters/Filters.svelte';
|
import Filters from './Filters/Filters.svelte';
|
||||||
import FilterControls from './FiltersControl/FilterControls.svelte';
|
import FilterControls from './FiltersControl/FilterControls.svelte';
|
||||||
import FontSearch from './FontSearch/FontSearch.svelte';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
FilterControls,
|
FilterControls,
|
||||||
Filters,
|
Filters,
|
||||||
FontSearch,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +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_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,
|
||||||
@@ -14,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,22 +1,215 @@
|
|||||||
import {
|
import {
|
||||||
|
type ControlDataModel,
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
|
type PersistentStore,
|
||||||
|
type TypographyControl,
|
||||||
|
createPersistentStore,
|
||||||
createTypographyControl,
|
createTypographyControl,
|
||||||
} from '$shared/lib';
|
} from '$shared/lib';
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
import {
|
||||||
|
type ControlId,
|
||||||
|
DEFAULT_FONT_SIZE,
|
||||||
|
DEFAULT_FONT_WEIGHT,
|
||||||
|
DEFAULT_LETTER_SPACING,
|
||||||
|
DEFAULT_LINE_HEIGHT,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
export function createTypographyControlManager(configs: ControlModel[]) {
|
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
||||||
const controls = $state(
|
|
||||||
configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({
|
|
||||||
id,
|
|
||||||
increaseLabel,
|
|
||||||
decreaseLabel,
|
|
||||||
controlLabel,
|
|
||||||
instance: createTypographyControl(config),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
export interface Control extends ControlOnlyFields<ControlId> {
|
||||||
get controls() {
|
instance: TypographyControl;
|
||||||
return controls;
|
}
|
||||||
},
|
|
||||||
};
|
export class TypographyControlManager {
|
||||||
|
#controls = new SvelteMap<string, Control>();
|
||||||
|
#multiplier = $state(1);
|
||||||
|
#storage: PersistentStore<TypographySettings>;
|
||||||
|
#baseSize = $state(DEFAULT_FONT_SIZE);
|
||||||
|
|
||||||
|
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
||||||
|
this.#storage = storage;
|
||||||
|
|
||||||
|
// Initial Load
|
||||||
|
const saved = storage.value;
|
||||||
|
this.#baseSize = saved.fontSize;
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
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() {
|
||||||
|
return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
get height() {
|
||||||
|
return this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
get spacing() {
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* @param configs - Array of control configurations.
|
||||||
|
* @param storageId - Persistent storage identifier.
|
||||||
|
* @returns - Typography control manager instance.
|
||||||
|
*/
|
||||||
|
export function createTypographyControlManager(
|
||||||
|
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,7 +1,10 @@
|
|||||||
|
import type { ControlModel } from '$shared/lib';
|
||||||
|
import type { ControlId } from '..';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font size constants
|
* Font size constants
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_FONT_SIZE = 16;
|
export const DEFAULT_FONT_SIZE = 48;
|
||||||
export const MIN_FONT_SIZE = 8;
|
export const MIN_FONT_SIZE = 8;
|
||||||
export const MAX_FONT_SIZE = 100;
|
export const MAX_FONT_SIZE = 100;
|
||||||
export const FONT_SIZE_STEP = 1;
|
export const FONT_SIZE_STEP = 1;
|
||||||
@@ -21,3 +24,65 @@ export const DEFAULT_LINE_HEIGHT = 1.5;
|
|||||||
export const MIN_LINE_HEIGHT = 1;
|
export const MIN_LINE_HEIGHT = 1;
|
||||||
export const MAX_LINE_HEIGHT = 2;
|
export const MAX_LINE_HEIGHT = 2;
|
||||||
export const LINE_HEIGHT_STEP = 0.05;
|
export const LINE_HEIGHT_STEP = 0.05;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Letter spacing constants
|
||||||
|
*/
|
||||||
|
export const DEFAULT_LETTER_SPACING = 0;
|
||||||
|
export const MIN_LETTER_SPACING = -0.1;
|
||||||
|
export const MAX_LETTER_SPACING = 0.5;
|
||||||
|
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;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
export {
|
export {
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
|
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,
|
||||||
@@ -11,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,54 +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_LINE_HEIGHT,
|
|
||||||
FONT_SIZE_STEP,
|
|
||||||
FONT_WEIGHT_STEP,
|
|
||||||
LINE_HEIGHT_STEP,
|
|
||||||
MAX_FONT_SIZE,
|
|
||||||
MAX_FONT_WEIGHT,
|
|
||||||
MAX_LINE_HEIGHT,
|
|
||||||
MIN_FONT_SIZE,
|
|
||||||
MIN_FONT_WEIGHT,
|
|
||||||
MIN_LINE_HEIGHT,
|
|
||||||
} from '../const/const';
|
|
||||||
|
|
||||||
const controlData: ControlModel[] = [
|
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const controlManager = createTypographyControlManager(controlData);
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
/**
|
|
||||||
* Component containing controls for setting up font properties.
|
|
||||||
*/
|
|
||||||
import { Separator } from '$shared/shadcn/ui/separator/index';
|
|
||||||
import { Trigger as SidebarTrigger } from '$shared/shadcn/ui/sidebar';
|
|
||||||
import { ComboControl } from '$shared/ui';
|
|
||||||
import { controlManager } from '../model';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="p-2 flex flex-row items-center gap-2">
|
|
||||||
<SidebarTrigger />
|
|
||||||
<Separator orientation="vertical" class="h-full" />
|
|
||||||
<div class="flex flex-row gap-2">
|
|
||||||
{#each controlManager.controls as control (control.id)}
|
|
||||||
<ComboControl control={control.instance} />
|
|
||||||
{/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';
|
||||||
@@ -1,27 +1,152 @@
|
|||||||
|
<!--
|
||||||
|
Component: Page
|
||||||
|
Description: The main page component of the application.
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||||
* Page Component
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
*
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
* Main page route component. Displays the font list and allows testing
|
|
||||||
* the unified font store functionality. Fetches fonts on mount and displays
|
|
||||||
* them using the FontList component.
|
|
||||||
*
|
|
||||||
* Receives unifiedFontStore from context created in Layout.svelte.
|
|
||||||
*/
|
|
||||||
// import {
|
|
||||||
// UNIFIED_FONT_STORE_KEY,
|
|
||||||
// type UnifiedFontStore,
|
|
||||||
// } from '$entities/Font/model/store/unifiedFontStore.svelte';
|
|
||||||
import FontList from '$entities/Font/ui/FontList/FontList.svelte';
|
|
||||||
// import { applyFilters } from '$features/FontManagement';
|
|
||||||
import {
|
import {
|
||||||
|
Logo,
|
||||||
|
Section,
|
||||||
|
} from '$shared/ui';
|
||||||
|
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
|
||||||
|
import { FontSearch } from '$widgets/FontSearch';
|
||||||
|
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 ScanSearchIcon from '@lucide/svelte/icons/search';
|
||||||
|
import {
|
||||||
|
type Snippet,
|
||||||
getContext,
|
getContext,
|
||||||
onMount,
|
|
||||||
} from 'svelte';
|
} from 'svelte';
|
||||||
|
import { cubicIn } from 'svelte/easing';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
// Receive store from context (created in Layout.svelte)
|
let searchContainer: HTMLElement;
|
||||||
// const unifiedFontStore: UnifiedFontStore = getContext(UNIFIED_FONT_STORE_KEY);
|
|
||||||
|
let isExpanded = $state(true);
|
||||||
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
|
function handleTitleStatusChanged(
|
||||||
|
index: number,
|
||||||
|
isPast: boolean,
|
||||||
|
title?: Snippet<[{ className?: string }]>,
|
||||||
|
id?: string,
|
||||||
|
) {
|
||||||
|
if (isPast && title) {
|
||||||
|
scrollBreadcrumbsStore.add({ index, title, id });
|
||||||
|
} else {
|
||||||
|
scrollBreadcrumbsStore.remove(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollBreadcrumbsStore.remove(index);
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Font List -->
|
<!-- Font List -->
|
||||||
<FontList showEmpty={true} />
|
<div
|
||||||
|
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 })}
|
||||||
|
<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 title({ className })}
|
||||||
|
<h1 class={className}>
|
||||||
|
Optical<br />Comparator
|
||||||
|
</h1>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet content({ className })}
|
||||||
|
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||||
|
<ComparisonSlider />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<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 })}
|
||||||
|
<ScanSearchIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet title({ className })}
|
||||||
|
<h2 class={className}>
|
||||||
|
Query<br />Module
|
||||||
|
</h2>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet content({ className })}
|
||||||
|
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||||
|
<FontSearch bind:showFilters={isExpanded} />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<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 })}
|
||||||
|
<LineSquiggleIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
{#snippet title({ className })}
|
||||||
|
<h2 class={className}>
|
||||||
|
Sample<br />Set
|
||||||
|
</h2>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet content({ className })}
|
||||||
|
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||||
|
<SampleList />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.content {
|
||||||
|
/* Tells the browser to skip rendering off-screen content */
|
||||||
|
content-visibility: auto;
|
||||||
|
/* Helps the browser reserve space without calculating everything */
|
||||||
|
contain-intrinsic-size: 1px 1000px;
|
||||||
|
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -56,6 +56,5 @@ export const api = {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
delete: <T>(url: string, options?: RequestInit) =>
|
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
|
||||||
request<T>(url, { ...options, method: 'DELETE' }),
|
|
||||||
};
|
};
|
||||||
|
|||||||
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 |
@@ -1,445 +0,0 @@
|
|||||||
import { get } from 'svelte/store';
|
|
||||||
import {
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi,
|
|
||||||
} from 'vitest';
|
|
||||||
import {
|
|
||||||
type CacheItemInternalState,
|
|
||||||
type CacheOptions,
|
|
||||||
createCollectionCache,
|
|
||||||
} from './collectionCache';
|
|
||||||
|
|
||||||
describe('createCollectionCache', () => {
|
|
||||||
let cache: ReturnType<typeof createCollectionCache<number>>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
cache = createCollectionCache<number>();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('initialization', () => {
|
|
||||||
it('initializes with empty cache', () => {
|
|
||||||
const data = get(cache.data);
|
|
||||||
expect(data).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('initializes with default options', () => {
|
|
||||||
const stats = cache.getStats();
|
|
||||||
expect(stats.total).toBe(0);
|
|
||||||
expect(stats.cached).toBe(0);
|
|
||||||
expect(stats.fetching).toBe(0);
|
|
||||||
expect(stats.errors).toBe(0);
|
|
||||||
expect(stats.hits).toBe(0);
|
|
||||||
expect(stats.misses).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts custom cache options', () => {
|
|
||||||
const options: CacheOptions = {
|
|
||||||
defaultTTL: 10 * 60 * 1000, // 10 minutes
|
|
||||||
maxSize: 500,
|
|
||||||
};
|
|
||||||
const customCache = createCollectionCache<number>(options);
|
|
||||||
expect(customCache).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('set and get', () => {
|
|
||||||
it('sets a value in cache', () => {
|
|
||||||
cache.set('key1', 100);
|
|
||||||
const value = cache.get('key1');
|
|
||||||
expect(value).toBe(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets multiple values in cache', () => {
|
|
||||||
cache.set('key1', 100);
|
|
||||||
cache.set('key2', 200);
|
|
||||||
cache.set('key3', 300);
|
|
||||||
|
|
||||||
expect(cache.get('key1')).toBe(100);
|
|
||||||
expect(cache.get('key2')).toBe(200);
|
|
||||||
expect(cache.get('key3')).toBe(300);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates existing value', () => {
|
|
||||||
cache.set('key1', 100);
|
|
||||||
cache.set('key1', 150);
|
|
||||||
expect(cache.get('key1')).toBe(150);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined for non-existent key', () => {
|
|
||||||
const value = cache.get('non-existent');
|
|
||||||
expect(value).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks item as ready after set', () => {
|
|
||||||
cache.set('key1', 100);
|
|
||||||
const internalState = cache.getInternalState('key1');
|
|
||||||
expect(internalState?.ready).toBe(true);
|
|
||||||
expect(internalState?.fetching).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('has and hasFresh', () => {
|
|
||||||
it('returns false for non-existent key', () => {
|
|
||||||
expect(cache.has('non-existent')).toBe(false);
|
|
||||||
expect(cache.hasFresh('non-existent')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns true after setting value', () => {
|
|
||||||
cache.set('key1', 100);
|
|
||||||
expect(cache.has('key1')).toBe(true);
|
|
||||||
expect(cache.hasFresh('key1')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false for fetching items', () => {
|
|
||||||
cache.markFetching('key1');
|
|
||||||
expect(cache.has('key1')).toBe(false);
|
|
||||||
expect(cache.hasFresh('key1')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false for failed items', () => {
|
|
||||||
cache.markFailed('key1', 'Network error');
|
|
||||||
expect(cache.has('key1')).toBe(false);
|
|
||||||
expect(cache.hasFresh('key1')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('remove', () => {
|
|
||||||
it('removes a value from cache', () => {
|
|
||||||
cache.set('key1', 100);
|
|
||||||
cache.set('key2', 200);
|
|
||||||
|
|
||||||
cache.remove('key1');
|
|
||||||
|
|
||||||
expect(cache.get('key1')).toBeUndefined();
|
|
||||||
expect(cache.get('key2')).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes internal state', () => {
|
|
||||||
cache.set('key1', 100);
|
|
||||||
cache.remove('key1');
|
|
||||||
const state = cache.getInternalState('key1');
|
|
||||||
expect(state).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does nothing for non-existent key', () => {
|
|
||||||
expect(() => cache.remove('non-existent')).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('clear', () => {
|
|
||||||
it('clears all values from cache', () => {
|
|
||||||
cache.set('key1', 100);
|
|
||||||
cache.set('key2', 200);
|
|
||||||
cache.set('key3', 300);
|
|
||||||
|
|
||||||
cache.clear();
|
|
||||||
|
|
||||||
expect(cache.get('key1')).toBeUndefined();
|
|
||||||
expect(cache.get('key2')).toBeUndefined();
|
|
||||||
expect(cache.get('key3')).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clears internal state', () => {
|
|
||||||
cache.set('key1', 100);
|
|
||||||
cache.clear();
|
|
||||||
|
|
||||||
const state = cache.getInternalState('key1');
|
|
||||||
expect(state).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resets cache statistics', () => {
|
|
||||||
cache.set('key1', 100); // This increments hits
|
|
||||||
const statsBefore = cache.getStats();
|
|
||||||
|
|
||||||
cache.clear();
|
|
||||||
const statsAfter = cache.getStats();
|
|
||||||
|
|
||||||
expect(statsAfter.hits).toBe(0);
|
|
||||||
expect(statsAfter.misses).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('markFetching', () => {
|
|
||||||
it('marks item as fetching', () => {
|
|
||||||
cache.markFetching('key1');
|
|
||||||
|
|
||||||
expect(cache.isFetching('key1')).toBe(true);
|
|
||||||
|
|
||||||
const state = cache.getInternalState('key1');
|
|
||||||
expect(state?.fetching).toBe(true);
|
|
||||||
expect(state?.ready).toBe(false);
|
|
||||||
expect(state?.startTime).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates existing state when called again', () => {
|
|
||||||
cache.markFetching('key1');
|
|
||||||
const startTime1 = cache.getInternalState('key1')?.startTime;
|
|
||||||
|
|
||||||
// Wait a bit to ensure different timestamp
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
|
|
||||||
cache.markFetching('key1');
|
|
||||||
const startTime2 = cache.getInternalState('key1')?.startTime;
|
|
||||||
|
|
||||||
expect(startTime2).toBeGreaterThan(startTime1!);
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets endTime to undefined', () => {
|
|
||||||
cache.markFetching('key1');
|
|
||||||
const state = cache.getInternalState('key1');
|
|
||||||
expect(state?.endTime).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('markFailed', () => {
|
|
||||||
it('marks item as failed with error message', () => {
|
|
||||||
cache.markFailed('key1', 'Network error');
|
|
||||||
|
|
||||||
expect(cache.isFetching('key1')).toBe(false);
|
|
||||||
|
|
||||||
const error = cache.getError('key1');
|
|
||||||
expect(error).toBe('Network error');
|
|
||||||
|
|
||||||
const state = cache.getInternalState('key1');
|
|
||||||
expect(state?.fetching).toBe(false);
|
|
||||||
expect(state?.ready).toBe(false);
|
|
||||||
expect(state?.error).toBe('Network error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves start time from fetching state', () => {
|
|
||||||
cache.markFetching('key1');
|
|
||||||
const startTime = cache.getInternalState('key1')?.startTime;
|
|
||||||
|
|
||||||
cache.markFailed('key1', 'Error');
|
|
||||||
|
|
||||||
const state = cache.getInternalState('key1');
|
|
||||||
expect(state?.startTime).toBe(startTime);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets end time', () => {
|
|
||||||
cache.markFailed('key1', 'Error');
|
|
||||||
const state = cache.getInternalState('key1');
|
|
||||||
expect(state?.endTime).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('increments error counter', () => {
|
|
||||||
const statsBefore = cache.getStats();
|
|
||||||
|
|
||||||
cache.markFailed('key1', 'Error1');
|
|
||||||
const statsAfter1 = cache.getStats();
|
|
||||||
expect(statsAfter1.errors).toBe(statsBefore.errors + 1);
|
|
||||||
|
|
||||||
cache.markFailed('key2', 'Error2');
|
|
||||||
const statsAfter2 = cache.getStats();
|
|
||||||
expect(statsAfter2.errors).toBe(statsAfter1.errors + 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('markMiss', () => {
|
|
||||||
it('increments miss counter', () => {
|
|
||||||
const statsBefore = cache.getStats();
|
|
||||||
|
|
||||||
cache.markMiss();
|
|
||||||
|
|
||||||
const statsAfter = cache.getStats();
|
|
||||||
expect(statsAfter.misses).toBe(statsBefore.misses + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('increments miss counter multiple times', () => {
|
|
||||||
const statsBefore = cache.getStats();
|
|
||||||
|
|
||||||
cache.markMiss();
|
|
||||||
cache.markMiss();
|
|
||||||
cache.markMiss();
|
|
||||||
|
|
||||||
const statsAfter = cache.getStats();
|
|
||||||
expect(statsAfter.misses).toBe(statsBefore.misses + 3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('statistics', () => {
|
|
||||||
it('tracks total number of items', () => {
|
|
||||||
expect(cache.getStats().total).toBe(0);
|
|
||||||
|
|
||||||
cache.set('key1', 100);
|
|
||||||
expect(cache.getStats().total).toBe(1);
|
|
||||||
|
|
||||||
cache.set('key2', 200);
|
|
||||||
expect(cache.getStats().total).toBe(2);
|
|
||||||
|
|
||||||
cache.remove('key1');
|
|
||||||
expect(cache.getStats().total).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tracks number of cached (ready) items', () => {
|
|
||||||
expect(cache.getStats().cached).toBe(0);
|
|
||||||
|
|
||||||
cache.set('key1', 100);
|
|
||||||
expect(cache.getStats().cached).toBe(1);
|
|
||||||
|
|
||||||
cache.set('key2', 200);
|
|
||||||
expect(cache.getStats().cached).toBe(2);
|
|
||||||
|
|
||||||
cache.markFetching('key3');
|
|
||||||
expect(cache.getStats().cached).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tracks number of fetching items', () => {
|
|
||||||
expect(cache.getStats().fetching).toBe(0);
|
|
||||||
|
|
||||||
cache.markFetching('key1');
|
|
||||||
expect(cache.getStats().fetching).toBe(1);
|
|
||||||
|
|
||||||
cache.markFetching('key2');
|
|
||||||
expect(cache.getStats().fetching).toBe(2);
|
|
||||||
|
|
||||||
cache.set('key1', 100);
|
|
||||||
expect(cache.getStats().fetching).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tracks cache hits', () => {
|
|
||||||
const statsBefore = cache.getStats();
|
|
||||||
|
|
||||||
cache.set('key1', 100);
|
|
||||||
const statsAfter1 = cache.getStats();
|
|
||||||
expect(statsAfter1.hits).toBe(statsBefore.hits + 1);
|
|
||||||
|
|
||||||
cache.set('key2', 200);
|
|
||||||
const statsAfter2 = cache.getStats();
|
|
||||||
expect(statsAfter2.hits).toBe(statsAfter1.hits + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('provides derived stats store', () => {
|
|
||||||
cache.set('key1', 100);
|
|
||||||
cache.markFetching('key2');
|
|
||||||
|
|
||||||
const stats = get(cache.stats);
|
|
||||||
expect(stats.total).toBe(1);
|
|
||||||
expect(stats.cached).toBe(1);
|
|
||||||
expect(stats.fetching).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('store reactivity', () => {
|
|
||||||
it('updates data store reactively', () => {
|
|
||||||
let dataUpdates = 0;
|
|
||||||
const unsubscribe = cache.data.subscribe(() => {
|
|
||||||
dataUpdates++;
|
|
||||||
});
|
|
||||||
|
|
||||||
cache.set('key1', 100);
|
|
||||||
cache.set('key2', 200);
|
|
||||||
|
|
||||||
expect(dataUpdates).toBeGreaterThan(0);
|
|
||||||
unsubscribe();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates internal state store reactively', () => {
|
|
||||||
let internalUpdates = 0;
|
|
||||||
const unsubscribe = cache.internal.subscribe(() => {
|
|
||||||
internalUpdates++;
|
|
||||||
});
|
|
||||||
|
|
||||||
cache.markFetching('key1');
|
|
||||||
cache.set('key1', 100);
|
|
||||||
cache.markFailed('key2', 'Error');
|
|
||||||
|
|
||||||
expect(internalUpdates).toBeGreaterThan(0);
|
|
||||||
unsubscribe();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates stats store reactively', () => {
|
|
||||||
let statsUpdates = 0;
|
|
||||||
const unsubscribe = cache.stats.subscribe(() => {
|
|
||||||
statsUpdates++;
|
|
||||||
});
|
|
||||||
|
|
||||||
cache.set('key1', 100);
|
|
||||||
cache.markMiss();
|
|
||||||
|
|
||||||
expect(statsUpdates).toBeGreaterThan(0);
|
|
||||||
unsubscribe();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('handles complex types', () => {
|
|
||||||
interface ComplexType {
|
|
||||||
id: string;
|
|
||||||
value: number;
|
|
||||||
tags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const complexCache = createCollectionCache<ComplexType>();
|
|
||||||
const item: ComplexType = {
|
|
||||||
id: '1',
|
|
||||||
value: 42,
|
|
||||||
tags: ['a', 'b', 'c'],
|
|
||||||
};
|
|
||||||
|
|
||||||
complexCache.set('item1', item);
|
|
||||||
const retrieved = complexCache.get('item1');
|
|
||||||
|
|
||||||
expect(retrieved).toEqual(item);
|
|
||||||
expect(retrieved?.tags).toEqual(['a', 'b', 'c']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles special characters in keys', () => {
|
|
||||||
cache.set('key with spaces', 1);
|
|
||||||
cache.set('key/with/slashes', 2);
|
|
||||||
cache.set('key-with-dashes', 3);
|
|
||||||
|
|
||||||
expect(cache.get('key with spaces')).toBe(1);
|
|
||||||
expect(cache.get('key/with/slashes')).toBe(2);
|
|
||||||
expect(cache.get('key-with-dashes')).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles rapid set and remove operations', () => {
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
cache.set(`key${i}`, i);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < 100; i += 2) {
|
|
||||||
cache.remove(`key${i}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(cache.getStats().total).toBe(50);
|
|
||||||
expect(cache.get('key0')).toBeUndefined();
|
|
||||||
expect(cache.get('key1')).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('error handling', () => {
|
|
||||||
it('handles concurrent markFetching for same key', () => {
|
|
||||||
cache.markFetching('key1');
|
|
||||||
cache.markFetching('key1');
|
|
||||||
|
|
||||||
const state = cache.getInternalState('key1');
|
|
||||||
expect(state?.fetching).toBe(true);
|
|
||||||
expect(state?.startTime).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles marking failed without prior fetching', () => {
|
|
||||||
cache.markFailed('key1', 'Error');
|
|
||||||
|
|
||||||
const state = cache.getInternalState('key1');
|
|
||||||
expect(state?.fetching).toBe(false);
|
|
||||||
expect(state?.ready).toBe(false);
|
|
||||||
expect(state?.error).toBe('Error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles operations on removed keys', () => {
|
|
||||||
cache.set('key1', 100);
|
|
||||||
cache.remove('key1');
|
|
||||||
|
|
||||||
expect(() => cache.set('key1', 200)).not.toThrow();
|
|
||||||
expect(() => cache.remove('key1')).not.toThrow();
|
|
||||||
expect(() => cache.getError('key1')).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
/**
|
|
||||||
* Collection cache manager
|
|
||||||
*
|
|
||||||
* Provides key-based caching, deduplication, and request tracking
|
|
||||||
* for any collection type. Integrates with Svelte stores for reactive updates.
|
|
||||||
*
|
|
||||||
* Key features:
|
|
||||||
* - Key-based caching (any ID, query hash)
|
|
||||||
* - Request deduplication (prevents concurrent requests for same key)
|
|
||||||
* - Request state tracking (fetching, ready, error)
|
|
||||||
* - TTL/staleness management
|
|
||||||
* - Performance timing tracking
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
Readable,
|
|
||||||
Writable,
|
|
||||||
} from 'svelte/store';
|
|
||||||
import {
|
|
||||||
derived,
|
|
||||||
get,
|
|
||||||
writable,
|
|
||||||
} from 'svelte/store';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal state for a cached item
|
|
||||||
* Tracks request lifecycle (fetching → ready/error)
|
|
||||||
*/
|
|
||||||
export interface CacheItemInternalState {
|
|
||||||
/** Whether a fetch is currently in progress */
|
|
||||||
fetching: boolean;
|
|
||||||
/** Whether data is ready and cached */
|
|
||||||
ready: boolean;
|
|
||||||
/** Error message if fetch failed */
|
|
||||||
error?: string;
|
|
||||||
/** Request start timestamp (performance tracking) */
|
|
||||||
startTime?: number;
|
|
||||||
/** Request end timestamp (performance tracking) */
|
|
||||||
endTime?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache configuration options
|
|
||||||
*/
|
|
||||||
export interface CacheOptions {
|
|
||||||
/** Default time-to-live for cached items (in milliseconds) */
|
|
||||||
defaultTTL?: number;
|
|
||||||
/** Maximum number of items to cache (LRU eviction) */
|
|
||||||
maxSize?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Statistics about cache performance
|
|
||||||
*/
|
|
||||||
export interface CacheStats {
|
|
||||||
/** Total number of items in cache */
|
|
||||||
total: number;
|
|
||||||
/** Number of items marked as ready */
|
|
||||||
cached: number;
|
|
||||||
/** Number of items currently fetching */
|
|
||||||
fetching: number;
|
|
||||||
/** Number of items with errors */
|
|
||||||
errors: number;
|
|
||||||
/** Total cache hits (data returned from cache) */
|
|
||||||
hits: number;
|
|
||||||
/** Total cache misses (data fetched from API) */
|
|
||||||
misses: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache manager interface
|
|
||||||
* Type-safe interface for collection caching operations
|
|
||||||
*/
|
|
||||||
export interface CollectionCacheManager<T> {
|
|
||||||
/** Get an item from cache by key */
|
|
||||||
get: (key: string) => T | undefined;
|
|
||||||
/** Check if item exists in cache and is ready */
|
|
||||||
has: (key: string) => boolean;
|
|
||||||
/** Check if item exists and is not stale */
|
|
||||||
hasFresh: (key: string) => boolean;
|
|
||||||
/** Set an item in cache (manual cache write) */
|
|
||||||
set: (key: string, value: T, ttl?: number) => void;
|
|
||||||
/** Remove item from cache */
|
|
||||||
remove: (key: string) => void;
|
|
||||||
/** Clear all items from cache */
|
|
||||||
clear: () => void;
|
|
||||||
/** Check if key is currently being fetched */
|
|
||||||
isFetching: (key: string) => boolean;
|
|
||||||
/** Get error for a key */
|
|
||||||
getError: (key: string) => string | undefined;
|
|
||||||
/** Get internal state for a key (for debugging) */
|
|
||||||
getInternalState: (key: string) => CacheItemInternalState | undefined;
|
|
||||||
/** Get cache statistics */
|
|
||||||
getStats: () => CacheStats;
|
|
||||||
/** Mark item as fetching (used when starting API request) */
|
|
||||||
markFetching: (key: string) => void;
|
|
||||||
/** Mark item as failed (used when API request fails) */
|
|
||||||
markFailed: (key: string, error: string) => void;
|
|
||||||
/** Increment cache miss counter */
|
|
||||||
markMiss: () => void;
|
|
||||||
/** Store containing cached data */
|
|
||||||
data: Writable<Record<string, T>>;
|
|
||||||
/** Store containing internal state (fetching, ready, error) */
|
|
||||||
internal: Writable<Record<string, CacheItemInternalState>>;
|
|
||||||
/** Derived store containing cache statistics */
|
|
||||||
stats: Readable<CacheStats>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a collection cache manager
|
|
||||||
*
|
|
||||||
* @typeParam T - Type of data being cached (e.g., UnifiedFont, Product, User)
|
|
||||||
* @param options - Cache configuration options
|
|
||||||
* @returns Cache manager instance
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const fontCache = createCollectionCache<UnifiedFont>({
|
|
||||||
* defaultTTL: 5 * 60 * 1000, // 5 minutes
|
|
||||||
* maxSize: 1000
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Set font in cache
|
|
||||||
* fontCache.set('Roboto', robotoFont);
|
|
||||||
*
|
|
||||||
* // Get font from cache
|
|
||||||
* const font = fontCache.get('Roboto');
|
|
||||||
* if (fontCache.hasFresh('Roboto')) {
|
|
||||||
* // Use cached font
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function createCollectionCache<T>(options: CacheOptions = {}): CollectionCacheManager<T> {
|
|
||||||
const { defaultTTL = 5 * 60 * 1000, maxSize = 1000 } = options;
|
|
||||||
|
|
||||||
// Stores for reactive data
|
|
||||||
const data: Writable<Record<string, T>> = writable({});
|
|
||||||
const internal: Writable<Record<string, CacheItemInternalState>> = writable({});
|
|
||||||
|
|
||||||
// Cache statistics store
|
|
||||||
const statsState = writable<CacheStats>({
|
|
||||||
total: 0,
|
|
||||||
cached: 0,
|
|
||||||
fetching: 0,
|
|
||||||
errors: 0,
|
|
||||||
hits: 0,
|
|
||||||
misses: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derived stats store for reactive updates
|
|
||||||
const stats = derived([data, internal, statsState], ([$data, $internal, $statsState]) => ({
|
|
||||||
...$statsState,
|
|
||||||
total: Object.keys($data).length,
|
|
||||||
cached: Object.values($internal).filter(s => s.ready).length,
|
|
||||||
fetching: Object.values($internal).filter(s => s.fetching).length,
|
|
||||||
errors: Object.values($internal).filter(s => s.error).length,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
/**
|
|
||||||
* Get cached data by key
|
|
||||||
* Returns undefined if not found
|
|
||||||
*/
|
|
||||||
get: (key: string) => {
|
|
||||||
const currentData = get(data);
|
|
||||||
return currentData[key];
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if key exists in cache and is ready
|
|
||||||
*/
|
|
||||||
has: (key: string) => {
|
|
||||||
const currentInternal = get(internal);
|
|
||||||
const state = currentInternal[key];
|
|
||||||
return state?.ready === true;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if key exists and is not stale (still within TTL)
|
|
||||||
*/
|
|
||||||
hasFresh: (key: string) => {
|
|
||||||
const currentInternal = get(internal);
|
|
||||||
const currentData = get(data);
|
|
||||||
|
|
||||||
const state = currentInternal[key];
|
|
||||||
if (!state?.ready) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if item exists in data store
|
|
||||||
if (!currentData[key]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Implement TTL check with cachedAt timestamps
|
|
||||||
// For now, just check ready state
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set data in cache
|
|
||||||
* Marks entry as ready and stops fetching state
|
|
||||||
*/
|
|
||||||
set: (key: string, value: T, ttl?: number) => {
|
|
||||||
data.update(d => ({
|
|
||||||
...d,
|
|
||||||
[key]: value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
internal.update(i => {
|
|
||||||
const existingState = i[key];
|
|
||||||
return {
|
|
||||||
...i,
|
|
||||||
[key]: {
|
|
||||||
fetching: false,
|
|
||||||
ready: true,
|
|
||||||
error: undefined,
|
|
||||||
startTime: existingState?.startTime,
|
|
||||||
endTime: Date.now(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update statistics (cache hit)
|
|
||||||
statsState.update(s => ({ ...s, hits: s.hits + 1 }));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove item from cache
|
|
||||||
*/
|
|
||||||
remove: (key: string) => {
|
|
||||||
data.update(d => {
|
|
||||||
const { [key]: _, ...rest } = d;
|
|
||||||
return rest;
|
|
||||||
});
|
|
||||||
|
|
||||||
internal.update(i => {
|
|
||||||
const { [key]: _, ...rest } = i;
|
|
||||||
return rest;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all items from cache
|
|
||||||
*/
|
|
||||||
clear: () => {
|
|
||||||
data.set({});
|
|
||||||
internal.set({});
|
|
||||||
statsState.update(s => ({ ...s, hits: 0, misses: 0 }));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if key is currently being fetched
|
|
||||||
*/
|
|
||||||
isFetching: (key: string) => {
|
|
||||||
const currentInternal = get(internal);
|
|
||||||
return currentInternal[key]?.fetching === true;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get error for a key
|
|
||||||
*/
|
|
||||||
getError: (key: string) => {
|
|
||||||
const currentInternal = get(internal);
|
|
||||||
return currentInternal[key]?.error;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get internal state for debugging
|
|
||||||
*/
|
|
||||||
getInternalState: (key: string) => {
|
|
||||||
const currentInternal = get(internal);
|
|
||||||
return currentInternal[key];
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current cache statistics
|
|
||||||
*/
|
|
||||||
getStats: () => {
|
|
||||||
return get(stats);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark item as fetching (used when starting API request)
|
|
||||||
*/
|
|
||||||
markFetching: (key: string) => {
|
|
||||||
internal.update(internal => ({
|
|
||||||
...internal,
|
|
||||||
[key]: {
|
|
||||||
fetching: true,
|
|
||||||
ready: false,
|
|
||||||
error: undefined,
|
|
||||||
startTime: Date.now(),
|
|
||||||
endTime: undefined,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark item as failed (used when API request fails)
|
|
||||||
*/
|
|
||||||
markFailed: (key: string, error: string) => {
|
|
||||||
internal.update(internal => {
|
|
||||||
const existingState = internal[key];
|
|
||||||
return {
|
|
||||||
...internal,
|
|
||||||
[key]: {
|
|
||||||
fetching: false,
|
|
||||||
ready: false,
|
|
||||||
error,
|
|
||||||
startTime: existingState?.startTime,
|
|
||||||
endTime: Date.now(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update statistics
|
|
||||||
const currentStats = get(stats);
|
|
||||||
statsState.update(s => ({ ...s, errors: currentStats.errors + 1 }));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Increment cache miss counter
|
|
||||||
*/
|
|
||||||
markMiss: () => {
|
|
||||||
statsState.update(s => ({ ...s, misses: s.misses + 1 }));
|
|
||||||
},
|
|
||||||
|
|
||||||
// Expose stores for reactive binding
|
|
||||||
data,
|
|
||||||
internal,
|
|
||||||
stats,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared fetch layer exports
|
|
||||||
*
|
|
||||||
* Exports collection caching utilities and reactive patterns for Svelte 5
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { createCollectionCache } from './collectionCache';
|
|
||||||
export type {
|
|
||||||
CacheItemInternalState,
|
|
||||||
CacheOptions,
|
|
||||||
CacheStats,
|
|
||||||
CollectionCacheManager,
|
|
||||||
} from './collectionCache';
|
|
||||||
export { reactiveQueryArgs } from './reactiveQueryArgs';
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import type { Readable } from 'svelte/store';
|
|
||||||
import { writable } from 'svelte/store';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a reactive store that maintains stable references for query arguments
|
|
||||||
*
|
|
||||||
* This function wraps a callback in a Svelte store that updates via `$effect.pre()`,
|
|
||||||
* ensuring that the callback is called before DOM updates while maintaining object
|
|
||||||
* reference stability.
|
|
||||||
*
|
|
||||||
* @typeParam T - Type of query arguments (e.g., CreateQueryOptions)
|
|
||||||
* @param cb - Callback function that computes query arguments
|
|
||||||
* @returns Readable store containing current query arguments
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* const queryArgsStore = reactiveQueryArgs(() => ({
|
|
||||||
* queryKey: ['fonts', search],
|
|
||||||
* queryFn: fetchFonts,
|
|
||||||
* staleTime: 5000
|
|
||||||
* }));
|
|
||||||
*
|
|
||||||
* // Use in component with TanStack Query
|
|
||||||
* const query = createQuery(queryArgsStore);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export const reactiveQueryArgs = <T>(cb: () => T): Readable<T> => {
|
|
||||||
const store = writable<T>();
|
|
||||||
|
|
||||||
// Use $effect.pre() to run before DOM updates
|
|
||||||
// This ensures stable references while staying reactive
|
|
||||||
$effect.pre(() => {
|
|
||||||
store.set(cb());
|
|
||||||
});
|
|
||||||
|
|
||||||
return store;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
/**
|
||||||
|
* Interface representing a line of text with its measured width.
|
||||||
|
*/
|
||||||
|
export interface LineData {
|
||||||
|
/**
|
||||||
|
* Line's text
|
||||||
|
*/
|
||||||
|
text: string;
|
||||||
|
/**
|
||||||
|
* It's width
|
||||||
|
*/
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a helper for splitting text into lines and calculating character proximity.
|
||||||
|
* This is used by the ComparisonSlider (TestTen) to render morphing text.
|
||||||
|
*
|
||||||
|
* @param text - The text to split and measure
|
||||||
|
* @param fontA - The first font definition
|
||||||
|
* @param fontB - The second font definition
|
||||||
|
* @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState)
|
||||||
|
*/
|
||||||
|
export function createCharacterComparison<
|
||||||
|
T extends { name: string; id: string } | undefined = undefined,
|
||||||
|
>(
|
||||||
|
text: () => string,
|
||||||
|
fontA: () => T,
|
||||||
|
fontB: () => T,
|
||||||
|
weight: () => number,
|
||||||
|
size: () => number,
|
||||||
|
) {
|
||||||
|
let lines = $state<LineData[]>([]);
|
||||||
|
let containerWidth = $state(0);
|
||||||
|
|
||||||
|
function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T {
|
||||||
|
return font !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measures text width using a canvas context.
|
||||||
|
* @param ctx - Canvas rendering context
|
||||||
|
* @param text - Text string to measure
|
||||||
|
* @param fontFamily - Font family name
|
||||||
|
* @param fontSize - Font size in pixels
|
||||||
|
* @param fontWeight - Font weight
|
||||||
|
*/
|
||||||
|
function measureText(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
text: string,
|
||||||
|
fontSize: number,
|
||||||
|
fontWeight: number,
|
||||||
|
fontFamily?: string,
|
||||||
|
): number {
|
||||||
|
if (!fontFamily) return 0;
|
||||||
|
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
||||||
|
return ctx.measureText(text).width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the appropriate font size based on window width.
|
||||||
|
* Matches the Tailwind breakpoints used in the component.
|
||||||
|
*/
|
||||||
|
function getFontSize() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 64;
|
||||||
|
}
|
||||||
|
return window.innerWidth >= 1024
|
||||||
|
? 112
|
||||||
|
: window.innerWidth >= 768
|
||||||
|
? 96
|
||||||
|
: window.innerWidth >= 640
|
||||||
|
? 80
|
||||||
|
: 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breaks the text into lines based on the container width and measure canvas.
|
||||||
|
* Populates the `lines` state.
|
||||||
|
*
|
||||||
|
* @param container - The container element to measure width from
|
||||||
|
* @param measureCanvas - The canvas element used for text measurement
|
||||||
|
*/
|
||||||
|
|
||||||
|
function breakIntoLines(
|
||||||
|
container: HTMLElement | undefined,
|
||||||
|
measureCanvas: HTMLCanvasElement | undefined,
|
||||||
|
) {
|
||||||
|
if (!container || !measureCanvas || !fontA() || !fontB()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use offsetWidth instead of getBoundingClientRect() to avoid CSS transform scaling issues
|
||||||
|
// 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
|
||||||
|
const padding = window.innerWidth < 640 ? 48 : 96;
|
||||||
|
const availableWidth = width - padding;
|
||||||
|
const ctx = measureCanvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controlledFontSize = size();
|
||||||
|
const fontSize = getFontSize();
|
||||||
|
const currentWeight = weight(); // Get current weight
|
||||||
|
const words = text().split(' ');
|
||||||
|
const newLines: LineData[] = [];
|
||||||
|
let currentLineWords: string[] = [];
|
||||||
|
|
||||||
|
function pushLine(words: string[]) {
|
||||||
|
if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lineText = words.join(' ');
|
||||||
|
// Measure both fonts at the CURRENT weight
|
||||||
|
const widthA = measureText(
|
||||||
|
ctx!,
|
||||||
|
lineText,
|
||||||
|
Math.min(fontSize, controlledFontSize),
|
||||||
|
currentWeight,
|
||||||
|
fontA()?.name,
|
||||||
|
);
|
||||||
|
const widthB = measureText(
|
||||||
|
ctx!,
|
||||||
|
lineText,
|
||||||
|
Math.min(fontSize, controlledFontSize),
|
||||||
|
currentWeight,
|
||||||
|
fontB()?.name,
|
||||||
|
);
|
||||||
|
const maxWidth = Math.max(widthA, widthB);
|
||||||
|
newLines.push({ text: lineText, width: maxWidth });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
const testLine = currentLineWords.length > 0
|
||||||
|
? currentLineWords.join(' ') + ' ' + word
|
||||||
|
: word;
|
||||||
|
// Measure with both fonts and use the wider one to prevent layout shifts
|
||||||
|
const widthA = measureText(
|
||||||
|
ctx,
|
||||||
|
testLine,
|
||||||
|
Math.min(fontSize, controlledFontSize),
|
||||||
|
currentWeight,
|
||||||
|
fontA()?.name,
|
||||||
|
);
|
||||||
|
const widthB = measureText(
|
||||||
|
ctx,
|
||||||
|
testLine,
|
||||||
|
Math.min(fontSize, controlledFontSize),
|
||||||
|
currentWeight,
|
||||||
|
fontB()?.name,
|
||||||
|
);
|
||||||
|
const maxWidth = Math.max(widthA, widthB);
|
||||||
|
const isContainerOverflown = maxWidth > availableWidth;
|
||||||
|
|
||||||
|
if (isContainerOverflown) {
|
||||||
|
if (currentLineWords.length > 0) {
|
||||||
|
pushLine(currentLineWords);
|
||||||
|
currentLineWords = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordWidthA = measureText(
|
||||||
|
ctx,
|
||||||
|
word,
|
||||||
|
Math.min(fontSize, controlledFontSize),
|
||||||
|
currentWeight,
|
||||||
|
fontA()?.name,
|
||||||
|
);
|
||||||
|
const wordWidthB = measureText(
|
||||||
|
ctx,
|
||||||
|
word,
|
||||||
|
Math.min(fontSize, controlledFontSize),
|
||||||
|
currentWeight,
|
||||||
|
fontB()?.name,
|
||||||
|
);
|
||||||
|
const wordAloneWidth = Math.max(wordWidthA, wordWidthB);
|
||||||
|
|
||||||
|
if (wordAloneWidth <= availableWidth) {
|
||||||
|
// If word fits start new line with it
|
||||||
|
currentLineWords = [word];
|
||||||
|
} else {
|
||||||
|
let remainingWord = word;
|
||||||
|
while (remainingWord.length > 0) {
|
||||||
|
let low = 1;
|
||||||
|
let high = remainingWord.length;
|
||||||
|
let bestBreak = 1;
|
||||||
|
|
||||||
|
// Binary Search to find the maximum characters that fit
|
||||||
|
while (low <= high) {
|
||||||
|
const mid = Math.floor((low + high) / 2);
|
||||||
|
const testFragment = remainingWord.slice(0, mid);
|
||||||
|
|
||||||
|
const wA = measureText(
|
||||||
|
ctx,
|
||||||
|
testFragment,
|
||||||
|
fontSize,
|
||||||
|
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)]);
|
||||||
|
remainingWord = remainingWord.slice(bestBreak);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (maxWidth > availableWidth && currentLineWords.length > 0) {
|
||||||
|
pushLine(currentLineWords);
|
||||||
|
currentLineWords = [word];
|
||||||
|
} else {
|
||||||
|
currentLineWords.push(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLineWords.length > 0) {
|
||||||
|
pushLine(currentLineWords);
|
||||||
|
}
|
||||||
|
lines = newLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* precise calculation of character state based on global slider position.
|
||||||
|
*
|
||||||
|
* @param charIndex - Index of the character in the line
|
||||||
|
* @param sliderPos - Current slider position (0-100)
|
||||||
|
* @param lineElement - The line element
|
||||||
|
* @param container - The container element
|
||||||
|
* @returns Object containing proximity (0-1) and isPast (boolean)
|
||||||
|
*/
|
||||||
|
function getCharState(
|
||||||
|
charIndex: number,
|
||||||
|
sliderPos: number,
|
||||||
|
lineElement?: HTMLElement,
|
||||||
|
container?: HTMLElement,
|
||||||
|
) {
|
||||||
|
if (!containerWidth || !container) {
|
||||||
|
return {
|
||||||
|
proximity: 0,
|
||||||
|
isPast: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const charElement = lineElement?.children[charIndex] as HTMLElement;
|
||||||
|
|
||||||
|
if (!charElement) {
|
||||||
|
return { proximity: 0, isPast: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the actual bounding box of the character
|
||||||
|
const charRect = charElement.getBoundingClientRect();
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Calculate character center relative to container
|
||||||
|
const charCenter = charRect.left + (charRect.width / 2) - containerRect.left;
|
||||||
|
const charGlobalPercent = (charCenter / containerWidth) * 100;
|
||||||
|
|
||||||
|
const distance = Math.abs(sliderPos - charGlobalPercent);
|
||||||
|
const range = 5;
|
||||||
|
const proximity = Math.max(0, 1 - distance / range);
|
||||||
|
const isPast = sliderPos > charGlobalPercent;
|
||||||
|
|
||||||
|
return { proximity, isPast };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get lines() {
|
||||||
|
return lines;
|
||||||
|
},
|
||||||
|
get containerWidth() {
|
||||||
|
return containerWidth;
|
||||||
|
},
|
||||||
|
breakIntoLines,
|
||||||
|
getCharState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CharacterComparison = ReturnType<typeof createCharacterComparison>;
|
||||||
@@ -1,5 +1,28 @@
|
|||||||
import { debounce } from '$shared/lib/utils';
|
import { debounce } from '$shared/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates reactive state with immediate and debounced values.
|
||||||
|
*
|
||||||
|
* Useful for UI inputs that need instant feedback but debounced logic
|
||||||
|
* (e.g., search fields with API calls). The immediate value updates on
|
||||||
|
* every change for UI binding, while debounced updates after a delay.
|
||||||
|
*
|
||||||
|
* @param initialValue - Initial value for both immediate and debounced state
|
||||||
|
* @param wait - Delay in milliseconds before updating debounced value (default: 300)
|
||||||
|
* @returns Object with immediate/debounced getters, immediate setter, and reset method
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```svelte
|
||||||
|
* <script lang="ts">
|
||||||
|
* const search = createDebouncedState('', 300);
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* <input bind:value={search.immediate} />
|
||||||
|
*
|
||||||
|
* <p>Typing: {search.immediate}</p>
|
||||||
|
* <p>Searching: {search.debounced}</p>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
|
export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
|
||||||
let immediate = $state(initialValue);
|
let immediate = $state(initialValue);
|
||||||
let debounced = $state(initialValue);
|
let debounced = $state(initialValue);
|
||||||
@@ -9,16 +32,23 @@ export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
|
|||||||
}, wait);
|
}, wait);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
/** Current value with immediate updates (for UI binding) */
|
||||||
get immediate() {
|
get immediate() {
|
||||||
return immediate;
|
return immediate;
|
||||||
},
|
},
|
||||||
set immediate(value: T) {
|
set immediate(value: T) {
|
||||||
immediate = value;
|
immediate = value;
|
||||||
updateDebounced(value); // Manually trigger the debounce on write
|
// Manually trigger the debounce on write
|
||||||
|
updateDebounced(value);
|
||||||
},
|
},
|
||||||
|
/** Current value with debounced updates (for logic/operations) */
|
||||||
get debounced() {
|
get debounced() {
|
||||||
return debounced;
|
return debounced;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Resets both values to initial or specified value.
|
||||||
|
* @param value - Optional value to reset to (defaults to initialValue)
|
||||||
|
*/
|
||||||
reset(value?: T) {
|
reset(value?: T) {
|
||||||
const resetValue = value ?? initialValue;
|
const resetValue = value ?? initialValue;
|
||||||
immediate = resetValue;
|
immediate = resetValue;
|
||||||
@@ -26,33 +56,3 @@ export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// export function createDebouncedState<T>(initialValue: T, wait: number = 300) {
|
|
||||||
// let immediate = $state(initialValue);
|
|
||||||
// let debounced = $state(initialValue);
|
|
||||||
|
|
||||||
// const updateDebounced = debounce((value: T) => {
|
|
||||||
// debounced = value;
|
|
||||||
// }, wait);
|
|
||||||
|
|
||||||
// $effect(() => {
|
|
||||||
// updateDebounced(immediate);
|
|
||||||
// });
|
|
||||||
|
|
||||||
// return {
|
|
||||||
// get immediate() {
|
|
||||||
// return immediate;
|
|
||||||
// },
|
|
||||||
// set immediate(value: T) {
|
|
||||||
// immediate = value;
|
|
||||||
// },
|
|
||||||
// get debounced() {
|
|
||||||
// return debounced;
|
|
||||||
// },
|
|
||||||
// reset(value?: T) {
|
|
||||||
// const resetValue = value ?? initialValue;
|
|
||||||
// immediate = resetValue;
|
|
||||||
// debounced = resetValue;
|
|
||||||
// },
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import { createDebouncedState } from '$shared/lib';
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Suite for createDebouncedState Helper Function
|
||||||
|
*
|
||||||
|
* This suite tests the debounced state management logic,
|
||||||
|
* including immediate vs debounced updates, timing behavior,
|
||||||
|
* and reset functionality.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('createDebouncedState - Basic Logic', () => {
|
||||||
|
it('creates state with initial value', () => {
|
||||||
|
const state = createDebouncedState('initial');
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('initial');
|
||||||
|
expect(state.debounced).toBe('initial');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports custom debounce delay', () => {
|
||||||
|
const state = createDebouncedState('test', 100);
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('test');
|
||||||
|
expect(state.debounced).toBe('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default delay of 300ms when not specified', () => {
|
||||||
|
const state = createDebouncedState('test');
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('test');
|
||||||
|
expect(state.debounced).toBe('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows updating immediate value', () => {
|
||||||
|
const state = createDebouncedState('initial');
|
||||||
|
|
||||||
|
state.immediate = 'updated';
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('updated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createDebouncedState - Debounce Timing', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('immediate value updates instantly', () => {
|
||||||
|
const state = createDebouncedState('initial', 100);
|
||||||
|
|
||||||
|
state.immediate = 'updated';
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('updated');
|
||||||
|
expect(state.debounced).toBe('initial');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debounced value updates after delay', () => {
|
||||||
|
const state = createDebouncedState('initial', 100);
|
||||||
|
|
||||||
|
state.immediate = 'updated';
|
||||||
|
expect(state.debounced).toBe('initial');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(99);
|
||||||
|
expect(state.debounced).toBe('initial');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(state.debounced).toBe('updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rapid changes reset the debounce timer', () => {
|
||||||
|
const state = createDebouncedState('initial', 100);
|
||||||
|
|
||||||
|
state.immediate = 'change1';
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
state.immediate = 'change2';
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
state.immediate = 'change3';
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
expect(state.debounced).toBe('initial');
|
||||||
|
expect(state.immediate).toBe('change3');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
expect(state.debounced).toBe('change3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debounced value remains unchanged during rapid updates', () => {
|
||||||
|
const state = createDebouncedState('initial', 100);
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
state.immediate = `update${i}`;
|
||||||
|
vi.advanceTimersByTime(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('update4');
|
||||||
|
expect(state.debounced).toBe('initial');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createDebouncedState - Reset Functionality', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets to initial value when called without argument', () => {
|
||||||
|
const state = createDebouncedState('initial', 100);
|
||||||
|
|
||||||
|
state.immediate = 'changed';
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('changed');
|
||||||
|
expect(state.debounced).toBe('changed');
|
||||||
|
|
||||||
|
state.reset();
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('initial');
|
||||||
|
expect(state.debounced).toBe('initial');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets to custom value when argument provided', () => {
|
||||||
|
const state = createDebouncedState('initial', 100);
|
||||||
|
|
||||||
|
state.immediate = 'changed';
|
||||||
|
state.reset('custom');
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('custom');
|
||||||
|
expect(state.debounced).toBe('custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets immediately without debounce delay', () => {
|
||||||
|
const state = createDebouncedState('initial', 100);
|
||||||
|
|
||||||
|
state.immediate = 'changed';
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
state.reset();
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('initial');
|
||||||
|
expect(state.debounced).toBe('initial');
|
||||||
|
|
||||||
|
// Pending debounce from 'changed' will still fire after the delay
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
expect(state.debounced).toBe('changed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets sets both values immediately', () => {
|
||||||
|
const state = createDebouncedState('initial', 100);
|
||||||
|
|
||||||
|
state.immediate = 'changed';
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
state.reset('new');
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('new');
|
||||||
|
expect(state.debounced).toBe('new');
|
||||||
|
|
||||||
|
// Pending debounce from 'changed' will fire after remaining delay
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
expect(state.debounced).toBe('changed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createDebouncedState - Type Support', () => {
|
||||||
|
it('works with string type', () => {
|
||||||
|
const state = createDebouncedState<string>('hello', 100);
|
||||||
|
|
||||||
|
state.immediate = 'world';
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with number type', () => {
|
||||||
|
const state = createDebouncedState<number>(0, 100);
|
||||||
|
|
||||||
|
state.immediate = 42;
|
||||||
|
|
||||||
|
expect(state.immediate).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with boolean type', () => {
|
||||||
|
const state = createDebouncedState<boolean>(false, 100);
|
||||||
|
|
||||||
|
state.immediate = true;
|
||||||
|
|
||||||
|
expect(state.immediate).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with object type', () => {
|
||||||
|
interface TestObject {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
const initial: TestObject = { value: 0, label: 'initial' };
|
||||||
|
const state = createDebouncedState<TestObject>(initial, 100);
|
||||||
|
|
||||||
|
const updated: TestObject = { value: 1, label: 'updated' };
|
||||||
|
state.immediate = updated;
|
||||||
|
|
||||||
|
expect(state.immediate).toBe(updated);
|
||||||
|
expect(state.immediate.value).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with array type', () => {
|
||||||
|
const initial = [1, 2, 3];
|
||||||
|
const state = createDebouncedState<number[]>(initial, 100);
|
||||||
|
|
||||||
|
const updated = [4, 5, 6];
|
||||||
|
state.immediate = updated;
|
||||||
|
|
||||||
|
expect(state.immediate).toEqual(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with null type', () => {
|
||||||
|
const state = createDebouncedState<string | null>(null, 100);
|
||||||
|
|
||||||
|
state.immediate = 'not null';
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('not null');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with undefined type', () => {
|
||||||
|
const state = createDebouncedState<number | undefined>(undefined, 100);
|
||||||
|
|
||||||
|
state.immediate = 42;
|
||||||
|
|
||||||
|
expect(state.immediate).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createDebouncedState - Corner Cases', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty string', () => {
|
||||||
|
const state = createDebouncedState('', 100);
|
||||||
|
|
||||||
|
state.immediate = '';
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('');
|
||||||
|
expect(state.debounced).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles zero value', () => {
|
||||||
|
const state = createDebouncedState(0, 100);
|
||||||
|
|
||||||
|
expect(state.immediate).toBe(0);
|
||||||
|
expect(state.debounced).toBe(0);
|
||||||
|
|
||||||
|
state.immediate = 0;
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
expect(state.immediate).toBe(0);
|
||||||
|
expect(state.debounced).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles very short debounce delay (1ms)', () => {
|
||||||
|
const state = createDebouncedState('initial', 1);
|
||||||
|
|
||||||
|
state.immediate = 'changed';
|
||||||
|
expect(state.debounced).toBe('initial');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(state.debounced).toBe('changed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles very long debounce delay (5000ms)', () => {
|
||||||
|
const state = createDebouncedState('initial', 5000);
|
||||||
|
|
||||||
|
state.immediate = 'changed';
|
||||||
|
vi.advanceTimersByTime(4999);
|
||||||
|
|
||||||
|
expect(state.debounced).toBe('initial');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(state.debounced).toBe('changed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles setting to same value multiple times', () => {
|
||||||
|
const state = createDebouncedState('initial', 100);
|
||||||
|
|
||||||
|
state.immediate = 'same';
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
state.immediate = 'same';
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('same');
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
expect(state.debounced).toBe('same');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles alternating between two values rapidly', () => {
|
||||||
|
const state = createDebouncedState('initial', 50);
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
state.immediate = 'value1';
|
||||||
|
vi.advanceTimersByTime(25);
|
||||||
|
state.immediate = 'value2';
|
||||||
|
vi.advanceTimersByTime(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('value2');
|
||||||
|
expect(state.debounced).toBe('initial');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
expect(state.debounced).toBe('value2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles reset during pending debounce', () => {
|
||||||
|
const state = createDebouncedState('initial', 100);
|
||||||
|
|
||||||
|
state.immediate = 'changed';
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
state.reset();
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('initial');
|
||||||
|
expect(state.debounced).toBe('initial');
|
||||||
|
|
||||||
|
// Pending debounce from 'changed' will fire after remaining delay
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
expect(state.debounced).toBe('changed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles immediate value changes after reset', () => {
|
||||||
|
const state = createDebouncedState('initial', 100);
|
||||||
|
|
||||||
|
state.reset('new');
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('new');
|
||||||
|
|
||||||
|
state.immediate = 'newer';
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
expect(state.immediate).toBe('newer');
|
||||||
|
expect(state.debounced).toBe('newer');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createDebouncedState - Multiple Instances', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple independent instances', () => {
|
||||||
|
const state1 = createDebouncedState('one', 100);
|
||||||
|
const state2 = createDebouncedState('two', 100);
|
||||||
|
|
||||||
|
state1.immediate = 'changed1';
|
||||||
|
state2.immediate = 'changed2';
|
||||||
|
|
||||||
|
expect(state1.immediate).toBe('changed1');
|
||||||
|
expect(state2.immediate).toBe('changed2');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
expect(state1.debounced).toBe('changed1');
|
||||||
|
expect(state2.debounced).toBe('changed2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('independent timers for each instance', () => {
|
||||||
|
const state1 = createDebouncedState('one', 100);
|
||||||
|
const state2 = createDebouncedState('two', 200);
|
||||||
|
|
||||||
|
state1.immediate = 'changed1';
|
||||||
|
state2.immediate = 'changed2';
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
expect(state1.debounced).toBe('changed1');
|
||||||
|
expect(state2.debounced).toBe('two');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
expect(state2.debounced).toBe('changed2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createDebouncedState - Interface Compliance', () => {
|
||||||
|
it('exposes immediate getter', () => {
|
||||||
|
const state = createDebouncedState('test');
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
const _ = state.immediate;
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes immediate setter', () => {
|
||||||
|
const state = createDebouncedState('test');
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
state.immediate = 'new';
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes debounced getter', () => {
|
||||||
|
const state = createDebouncedState('test');
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
const _ = state.debounced;
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes reset method', () => {
|
||||||
|
const state = createDebouncedState('test');
|
||||||
|
|
||||||
|
expect(typeof state.reset).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose debounced setter', () => {
|
||||||
|
const state = createDebouncedState('test');
|
||||||
|
|
||||||
|
// TypeScript should prevent this, but we can check the runtime behavior
|
||||||
|
expect(state).not.toHaveProperty('set debounced');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
export interface Entity {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Svelte 5 Entity Store
|
||||||
|
* Uses SvelteMap for O(1) lookups and granular reactivity.
|
||||||
|
*/
|
||||||
|
export class EntityStore<T extends Entity> {
|
||||||
|
// SvelteMap is a reactive version of the native Map
|
||||||
|
#entities = new SvelteMap<string, T>();
|
||||||
|
|
||||||
|
constructor(initialEntities: T[] = []) {
|
||||||
|
this.setAll(initialEntities);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Selectors (Equivalent to Selectors) ---
|
||||||
|
|
||||||
|
/** Get all entities as an array */
|
||||||
|
get all() {
|
||||||
|
return Array.from(this.#entities.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select a single entity by ID */
|
||||||
|
getById(id: string) {
|
||||||
|
return this.#entities.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select multiple entities by IDs */
|
||||||
|
getByIds(ids: string[]) {
|
||||||
|
return ids.map(id => this.#entities.get(id)).filter((e): e is T => !!e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Actions (CRUD) ---
|
||||||
|
|
||||||
|
addOne(entity: T) {
|
||||||
|
this.#entities.set(entity.id, entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
addMany(entities: T[]) {
|
||||||
|
entities.forEach(e => this.addOne(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOne(id: string, changes: Partial<T>) {
|
||||||
|
const entity = this.#entities.get(id);
|
||||||
|
if (entity) {
|
||||||
|
this.#entities.set(id, { ...entity, ...changes });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeOne(id: string) {
|
||||||
|
this.#entities.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeMany(ids: string[]) {
|
||||||
|
ids.forEach(id => this.#entities.delete(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
setAll(entities: T[]) {
|
||||||
|
this.#entities.clear();
|
||||||
|
this.addMany(entities);
|
||||||
|
}
|
||||||
|
|
||||||
|
has(id: string) {
|
||||||
|
return this.#entities.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.#entities.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new EntityStore instance with the given initial entities.
|
||||||
|
* @param initialEntities The initial entities to populate the store with.
|
||||||
|
* @returns - A new EntityStore instance.
|
||||||
|
*/
|
||||||
|
export function createEntityStore<T extends Entity>(initialEntities: T[] = []) {
|
||||||
|
return new EntityStore<T>(initialEntities);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,84 +26,54 @@ export interface FilterModel<TValue extends string> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a filter store.
|
* Create a filter store.
|
||||||
* @param initialState - Initial state of the filter store
|
* @param initialState - Initial state of filter store
|
||||||
*/
|
*/
|
||||||
export function createFilter<TValue extends string>(
|
export function createFilter<TValue extends string>(initialState: FilterModel<TValue>) {
|
||||||
initialState: FilterModel<TValue>,
|
// We map the initial properties into a reactive state array
|
||||||
) {
|
const properties = $state(
|
||||||
let properties = $state(
|
|
||||||
initialState.properties.map(p => ({
|
initialState.properties.map(p => ({
|
||||||
...p,
|
...p,
|
||||||
selected: p.selected ?? false,
|
selected: p.selected ?? false,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedProperties = $derived(properties.filter(p => p.selected));
|
// Helper to find a property by ID
|
||||||
const selectedCount = $derived(selectedProperties.length);
|
const findProp = (id: string) => properties.find(p => p.id === id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/**
|
|
||||||
* Get all properties.
|
|
||||||
*/
|
|
||||||
get properties() {
|
get properties() {
|
||||||
return properties;
|
return properties;
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Get selected properties.
|
|
||||||
*/
|
|
||||||
get selectedProperties() {
|
get selectedProperties() {
|
||||||
return selectedProperties;
|
return properties.filter(p => p.selected);
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Get selected count.
|
|
||||||
*/
|
|
||||||
get selectedCount() {
|
get selectedCount() {
|
||||||
return selectedCount;
|
return properties.filter(p => p.selected)?.length;
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Toggle property selection.
|
toggleProperty(id: string) {
|
||||||
*/
|
const property = findProp(id);
|
||||||
toggleProperty: (id: string) => {
|
if (property) {
|
||||||
properties = properties.map(p => ({
|
property.selected = !property.selected;
|
||||||
...p,
|
}
|
||||||
selected: p.id === id ? !p.selected : p.selected,
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Select property.
|
|
||||||
*/
|
|
||||||
selectProperty(id: string) {
|
selectProperty(id: string) {
|
||||||
properties = properties.map(p => ({
|
const property = findProp(id);
|
||||||
...p,
|
if (property) {
|
||||||
selected: p.id === id ? true : p.selected,
|
property.selected = true;
|
||||||
}));
|
}
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Deselect property.
|
|
||||||
*/
|
|
||||||
deselectProperty(id: string) {
|
deselectProperty(id: string) {
|
||||||
properties = properties.map(p => ({
|
const property = findProp(id);
|
||||||
...p,
|
if (property) {
|
||||||
selected: p.id === id ? false : p.selected,
|
property.selected = false;
|
||||||
}));
|
}
|
||||||
},
|
},
|
||||||
/**
|
selectAll() {
|
||||||
* Select all properties.
|
properties.forEach(property => property.selected = true);
|
||||||
*/
|
|
||||||
selectAll: () => {
|
|
||||||
properties = properties.map(p => ({
|
|
||||||
...p,
|
|
||||||
selected: true,
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
/**
|
deselectAll() {
|
||||||
* Deselect all properties.
|
properties.forEach(property => property.selected = false);
|
||||||
*/
|
|
||||||
deselectAll: () => {
|
|
||||||
properties = properties.map(p => ({
|
|
||||||
...p,
|
|
||||||
selected: false,
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { createFilter } from '$shared/lib';
|
||||||
type Filter,
|
|
||||||
type Property,
|
|
||||||
createFilter,
|
|
||||||
} from '$shared/lib';
|
|
||||||
import {
|
import {
|
||||||
describe,
|
describe,
|
||||||
expect,
|
expect,
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Reusable persistent storage utility using Svelte 5 runes
|
||||||
|
*
|
||||||
|
* Automatically syncs state with localStorage.
|
||||||
|
*/
|
||||||
|
export function createPersistentStore<T>(key: string, defaultValue: T) {
|
||||||
|
// Initialize from storage or default
|
||||||
|
const loadFromStorage = (): T => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const item = localStorage.getItem(key);
|
||||||
|
return item ? JSON.parse(item) : defaultValue;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[createPersistentStore] Error loading ${key}:`, error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = $state<T>(loadFromStorage());
|
||||||
|
|
||||||
|
// Sync to storage whenever value changes
|
||||||
|
$effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[createPersistentStore] Error saving ${key}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
get value() {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
set value(v: T) {
|
||||||
|
value = v;
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
value = defaultValue;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,23 +22,23 @@ 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
|
||||||
*/
|
*/
|
||||||
increaseLabel: string;
|
increaseLabel?: string;
|
||||||
/**
|
/**
|
||||||
* Area label for decrease button
|
* Area label for decrease button
|
||||||
*/
|
*/
|
||||||
decreaseLabel: string;
|
decreaseLabel?: string;
|
||||||
/**
|
/**
|
||||||
* Control area label
|
* Control area label
|
||||||
*/
|
*/
|
||||||
controlLabel: string;
|
controlLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTypographyControl<T extends ControlDataModel>(
|
export function createTypographyControl<T extends ControlDataModel>(
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,116 +1,478 @@
|
|||||||
import {
|
/**
|
||||||
createVirtualizer as coreCreateVirtualizer,
|
* Represents a virtualized list item with layout information.
|
||||||
observeElementRect,
|
*
|
||||||
} from '@tanstack/svelte-virtual';
|
* Used to render visible items with absolute positioning based on computed offsets.
|
||||||
import type { VirtualItem as CoreVirtualItem } from '@tanstack/virtual-core';
|
*/
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
export interface VirtualItem {
|
export interface VirtualItem {
|
||||||
|
/**
|
||||||
|
* Index of the item in the data array
|
||||||
|
*/
|
||||||
index: number;
|
index: number;
|
||||||
|
/**
|
||||||
|
* Offset from the top of the list in pixels
|
||||||
|
*/
|
||||||
start: number;
|
start: number;
|
||||||
|
/**
|
||||||
|
* Height/size of the item in pixels
|
||||||
|
*/
|
||||||
size: number;
|
size: number;
|
||||||
|
/**
|
||||||
|
* End position in pixels (start + size)
|
||||||
|
*/
|
||||||
end: number;
|
end: number;
|
||||||
|
/**
|
||||||
|
* Unique key for the item (for Svelte's {#each} keying)
|
||||||
|
*/
|
||||||
key: string | number;
|
key: string | number;
|
||||||
}
|
/**
|
||||||
|
* Whether the item is currently fully visible in the viewport
|
||||||
export interface VirtualizerOptions {
|
*/
|
||||||
/** Total number of items in the data array */
|
isFullyVisible: boolean;
|
||||||
count: number;
|
/**
|
||||||
/** Function to estimate the size of an item at a given index */
|
* Whether the item is currently partially visible in the viewport
|
||||||
estimateSize: (index: number) => number;
|
*/
|
||||||
/** Number of extra items to render outside viewport (default: 5) */
|
isPartiallyVisible: boolean;
|
||||||
overscan?: number;
|
/**
|
||||||
/** Function to get the key of an item at a given index (defaults to index) */
|
* Proximity of the item to the center of the viewport
|
||||||
getItemKey?: (index: number) => string | number;
|
*/
|
||||||
/** Optional margin in pixels for scroll calculations */
|
proximity: number;
|
||||||
scrollMargin?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a reactive virtualizer using Svelte 5 runes and TanStack's core library.
|
* Configuration options for {@link createVirtualizer}.
|
||||||
|
*
|
||||||
|
* Options are reactive - pass them through a function getter to enable updates.
|
||||||
|
*/
|
||||||
|
export interface VirtualizerOptions {
|
||||||
|
/** Total number of items in the data array */
|
||||||
|
count: number;
|
||||||
|
/**
|
||||||
|
* Function to estimate the size of an item at a given index.
|
||||||
|
* Used for initial layout before actual measurements are available.
|
||||||
|
*/
|
||||||
|
estimateSize: (index: number) => number;
|
||||||
|
/** Number of extra items to render outside viewport for smoother scrolling (default: 5) */
|
||||||
|
overscan?: number;
|
||||||
|
/**
|
||||||
|
* Function to get the key of an item at a given index.
|
||||||
|
* Defaults to using the index directly. Useful for stable keys when items reorder.
|
||||||
|
*/
|
||||||
|
getItemKey?: (index: number) => string | number;
|
||||||
|
/**
|
||||||
|
* Optional margin in pixels for scroll calculations.
|
||||||
|
* Can be useful for handling sticky headers or other UI elements.
|
||||||
|
*/
|
||||||
|
scrollMargin?: number;
|
||||||
|
/**
|
||||||
|
* Whether to use the window as the scroll container.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
useWindowScroll?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reactive virtualizer for efficiently rendering large lists by only rendering visible items.
|
||||||
|
*
|
||||||
|
* Uses Svelte 5 runes ($state, $derived) for reactive state management and optimizes rendering
|
||||||
|
* through scroll position tracking and item height measurement. Supports dynamic item heights
|
||||||
|
* and programmatic scrolling.
|
||||||
|
*
|
||||||
|
* @param optionsGetter - Function that returns reactive virtualizer options
|
||||||
|
* @returns Virtualizer instance with computed properties and action functions
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```svelte
|
||||||
* const virtualizer = createVirtualizer(() => ({
|
* <script lang="ts">
|
||||||
* count: items.length,
|
* const virtualizer = createVirtualizer(() => ({
|
||||||
* estimateSize: () => 80,
|
* count: 1000,
|
||||||
* overscan: 5,
|
* estimateSize: (i) => i % 3 === 0 ? 100 : 50,
|
||||||
* }));
|
* overscan: 5,
|
||||||
|
* getItemKey: (i) => `item-${i}`
|
||||||
|
* }));
|
||||||
|
* </script>
|
||||||
*
|
*
|
||||||
* // In template:
|
* <div use:virtualizer.container style="height: 500px; overflow: auto;">
|
||||||
* // <div bind:this={virtualizer.scrollElement}>
|
* <div style="height: {virtualizer.totalSize}px;">
|
||||||
* // {#each virtualizer.items as item}
|
* {#each virtualizer.items as item (item.key)}
|
||||||
* // <div style="transform: translateY({item.start}px)">
|
* <div
|
||||||
* // {items[item.index]}
|
* use:virtualizer.measureElement
|
||||||
* // </div>
|
* data-index={item.index}
|
||||||
* // {/each}
|
* style="position: absolute; top: {item.start}px; height: {item.size}px;"
|
||||||
* // </div>
|
* >
|
||||||
|
* Item {item.index}
|
||||||
|
* </div>
|
||||||
|
* {/each}
|
||||||
|
* </div>
|
||||||
|
* </div>
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function createVirtualizer(
|
export function createVirtualizer<T>(
|
||||||
optionsGetter: () => VirtualizerOptions,
|
optionsGetter: () => VirtualizerOptions & {
|
||||||
|
data: T[];
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
let element = $state<HTMLElement | null>(null);
|
let scrollOffset = $state(0);
|
||||||
|
let containerHeight = $state(0);
|
||||||
|
let measuredSizes = $state<Record<number, number>>({});
|
||||||
|
let elementRef: HTMLElement | null = null;
|
||||||
|
let elementOffsetTop = 0;
|
||||||
|
|
||||||
const internalStore = coreCreateVirtualizer({
|
// By wrapping the getter in $derived, we track everything inside it
|
||||||
get count() {
|
const options = $derived(optionsGetter());
|
||||||
return optionsGetter().count;
|
|
||||||
},
|
// This derivation now tracks: count, _version (for measuredSizes updates), AND the data array itself
|
||||||
get estimateSize() {
|
const offsets = $derived.by(() => {
|
||||||
return optionsGetter().estimateSize;
|
const count = options.count;
|
||||||
},
|
// Implicit dependency on version signal
|
||||||
get overscan() {
|
const v = _version;
|
||||||
return optionsGetter().overscan ?? 5;
|
const result = new Float64Array(count);
|
||||||
},
|
let accumulated = 0;
|
||||||
get scrollMargin() {
|
for (let i = 0; i < count; i++) {
|
||||||
return optionsGetter().scrollMargin;
|
result[i] = accumulated;
|
||||||
},
|
// Accessing measuredSizes here creates the subscription
|
||||||
get getItemKey() {
|
accumulated += measuredSizes[i] ?? options.estimateSize(i);
|
||||||
return optionsGetter().getItemKey ?? (i => i);
|
}
|
||||||
},
|
|
||||||
getScrollElement: () => element,
|
return result;
|
||||||
observeElementRect: observeElementRect,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = $derived(get(internalStore));
|
const totalSize = $derived(
|
||||||
|
options.count > 0
|
||||||
const virtualItems = $derived(
|
? offsets[options.count - 1]
|
||||||
state.getVirtualItems().map((item: CoreVirtualItem): VirtualItem => ({
|
+ (measuredSizes[options.count - 1] ?? options.estimateSize(options.count - 1))
|
||||||
index: item.index,
|
: 0,
|
||||||
start: item.start,
|
|
||||||
size: item.size,
|
|
||||||
end: item.end,
|
|
||||||
key: typeof item.key === 'bigint' ? Number(item.key) : item.key,
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const items = $derived.by((): VirtualItem[] => {
|
||||||
|
// We MUST read options.data here so Svelte knows to re-run
|
||||||
|
// this derivation when the items array is replaced!
|
||||||
|
const { count, data } = options;
|
||||||
|
// Implicit dependency
|
||||||
|
const v = _version;
|
||||||
|
if (count === 0 || containerHeight === 0 || !data) return [];
|
||||||
|
|
||||||
|
const overscan = options.overscan ?? 5;
|
||||||
|
|
||||||
|
// Binary search for efficiency
|
||||||
|
let low = 0;
|
||||||
|
let high = count - 1;
|
||||||
|
let startIdx = 0;
|
||||||
|
while (low <= high) {
|
||||||
|
const mid = Math.floor((low + high) / 2);
|
||||||
|
if (offsets[mid] <= scrollOffset) {
|
||||||
|
startIdx = mid;
|
||||||
|
low = mid + 1;
|
||||||
|
} else {
|
||||||
|
high = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let endIdx = startIdx;
|
||||||
|
const viewportEnd = scrollOffset + containerHeight;
|
||||||
|
const viewportCenter = scrollOffset + (containerHeight / 2);
|
||||||
|
|
||||||
|
while (endIdx < count && offsets[endIdx] < viewportEnd) {
|
||||||
|
endIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(0, startIdx - overscan);
|
||||||
|
const end = Math.min(count, endIdx + overscan);
|
||||||
|
|
||||||
|
const result: VirtualItem[] = [];
|
||||||
|
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
const itemStart = offsets[i];
|
||||||
|
const itemSize = measuredSizes[i] ?? options.estimateSize(i);
|
||||||
|
const itemEnd = itemStart + itemSize;
|
||||||
|
|
||||||
|
// Visibility check: Does the item overlap the viewport?
|
||||||
|
const isPartiallyVisible = itemStart < viewportEnd && itemEnd > scrollOffset;
|
||||||
|
const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
|
||||||
|
|
||||||
|
// 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 distanceToCenter = Math.abs(viewportCenter - itemCenter);
|
||||||
|
const maxDistance = containerHeight / 2;
|
||||||
|
const proximity = maxDistance > 0
|
||||||
|
? Math.max(0, 1 - (distanceToCenter / maxDistance))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
index: i,
|
||||||
|
start: itemStart,
|
||||||
|
size: itemSize,
|
||||||
|
end: itemEnd,
|
||||||
|
key: options.getItemKey?.(i) ?? i,
|
||||||
|
isPartiallyVisible,
|
||||||
|
isFullyVisible,
|
||||||
|
proximity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
// Svelte Actions (The DOM Interface)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Svelte action to attach to the scrollable container element.
|
||||||
|
*
|
||||||
|
* Sets up scroll tracking, container height monitoring, and cleanup on destroy.
|
||||||
|
*
|
||||||
|
* @param node - The DOM element to attach to (should be the scrollable container)
|
||||||
|
* @returns Object with destroy method for cleanup
|
||||||
|
*/
|
||||||
|
function container(node: HTMLElement) {
|
||||||
|
elementRef = node;
|
||||||
|
const { useWindowScroll } = optionsGetter();
|
||||||
|
|
||||||
|
if (useWindowScroll) {
|
||||||
|
// Calculate initial offset ONCE
|
||||||
|
const getElementOffset = () => {
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
return rect.top + window.scrollY;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedOffsetTop = 0;
|
||||||
|
let rafId: number | null = null;
|
||||||
|
containerHeight = window.innerHeight;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (rafId !== null) return;
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
|
containerHeight = window.innerHeight;
|
||||||
|
elementOffsetTop = getElementOffset();
|
||||||
|
cachedOffsetTop = elementOffsetTop;
|
||||||
|
handleScroll();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial setup
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
elementOffsetTop = getElementOffset();
|
||||||
|
cachedOffsetTop = elementOffsetTop;
|
||||||
|
handleScroll();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
if (frameId !== null) {
|
||||||
|
cancelAnimationFrame(frameId);
|
||||||
|
frameId = null;
|
||||||
|
}
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
// Disconnect shared ResizeObserver
|
||||||
|
if (sharedResizeObserver) {
|
||||||
|
sharedResizeObserver.disconnect();
|
||||||
|
sharedResizeObserver = null;
|
||||||
|
}
|
||||||
|
elementRef = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
containerHeight = node.offsetHeight;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
scrollOffset = node.scrollTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||||
|
if (entry) containerHeight = entry.contentRect.height;
|
||||||
|
});
|
||||||
|
|
||||||
|
node.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
resizeObserver.observe(node);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener('scroll', handleScroll);
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
// Disconnect shared ResizeObserver
|
||||||
|
if (sharedResizeObserver) {
|
||||||
|
sharedResizeObserver.disconnect();
|
||||||
|
sharedResizeObserver = null;
|
||||||
|
}
|
||||||
|
elementRef = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let measurementBuffer: Record<number, number> = {};
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* Uses a single shared ResizeObserver for all items to track actual element heights.
|
||||||
|
* Requires `data-index` attribute on the element.
|
||||||
|
*
|
||||||
|
* @param node - The DOM element to measure (should have `data-index` attribute)
|
||||||
|
* @returns Object with destroy method for cleanup
|
||||||
|
*/
|
||||||
|
function measureElement(node: HTMLElement) {
|
||||||
|
// Initialize shared observer on first use
|
||||||
|
if (!sharedResizeObserver) {
|
||||||
|
sharedResizeObserver = new ResizeObserver(entries => {
|
||||||
|
// 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)) {
|
||||||
|
const oldHeight = measuredSizes[index];
|
||||||
|
|
||||||
|
// Only update if the height difference is significant (> 0.5px)
|
||||||
|
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
|
||||||
|
measurementBuffer[index] = height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule a single update for the next animation frame
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls the container to bring the specified item into view.
|
||||||
|
*
|
||||||
|
* @param index - Index of the item to scroll to
|
||||||
|
* @param align - Scroll alignment: 'start', 'center', 'end', or 'auto' (default)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* virtualizer.scrollToIndex(50, 'center'); // Scroll to item 50 and center it
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
function scrollToIndex(index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') {
|
||||||
|
if (!elementRef || index < 0 || index >= options.count) return;
|
||||||
|
|
||||||
|
const itemStart = offsets[index];
|
||||||
|
const itemSize = measuredSizes[index] ?? options.estimateSize(index);
|
||||||
|
let target = itemStart;
|
||||||
|
const { useWindowScroll } = optionsGetter();
|
||||||
|
|
||||||
|
if (useWindowScroll) {
|
||||||
|
if (align === 'center') target = itemStart - window.innerHeight / 2 + itemSize / 2;
|
||||||
|
if (align === 'end') target = itemStart - window.innerHeight + itemSize;
|
||||||
|
|
||||||
|
// Add container offset to target to get absolute document position
|
||||||
|
const absoluteTarget = target + elementOffsetTop;
|
||||||
|
|
||||||
|
window.scrollTo({ top: absoluteTarget, behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
|
||||||
|
if (align === 'end') target = itemStart - containerHeight + itemSize;
|
||||||
|
|
||||||
|
elementRef.scrollTo({ top: target, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 items() {
|
|
||||||
return virtualItems;
|
|
||||||
},
|
|
||||||
|
|
||||||
get totalSize() {
|
|
||||||
return state.getTotalSize();
|
|
||||||
},
|
|
||||||
|
|
||||||
get scrollOffset() {
|
get scrollOffset() {
|
||||||
return state.scrollOffset ?? 0;
|
return scrollOffset;
|
||||||
},
|
},
|
||||||
|
get containerHeight() {
|
||||||
get scrollElement() {
|
return containerHeight;
|
||||||
return element;
|
|
||||||
},
|
},
|
||||||
set scrollElement(el) {
|
/** Computed array of visible items to render (reactive) */
|
||||||
element = el;
|
get items() {
|
||||||
|
return items;
|
||||||
},
|
},
|
||||||
|
/** Total height of all items in pixels (reactive) */
|
||||||
scrollToIndex: (idx: number, opt?: { align?: 'start' | 'center' | 'end' | 'auto' }) =>
|
get totalSize() {
|
||||||
state.scrollToIndex(idx, opt),
|
return totalSize;
|
||||||
|
},
|
||||||
scrollToOffset: (off: number) => state.scrollToOffset(off),
|
/** Svelte action for the scrollable container element */
|
||||||
|
container,
|
||||||
measureElement: (el: HTMLElement) => state.measureElement(el),
|
/** Svelte action for measuring individual item elements */
|
||||||
|
measureElement,
|
||||||
|
/** Programmatic scroll method to scroll to a specific item */
|
||||||
|
scrollToIndex,
|
||||||
|
/** Programmatic scroll method to scroll to a specific pixel offset */
|
||||||
|
scrollToOffset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Virtualizer instance returned by {@link createVirtualizer}.
|
||||||
|
*
|
||||||
|
* Provides reactive computed properties for visible items and total size,
|
||||||
|
* along with action functions for DOM integration and element measurement.
|
||||||
|
*/
|
||||||
export type Virtualizer = ReturnType<typeof createVirtualizer>;
|
export type Virtualizer = ReturnType<typeof createVirtualizer>;
|
||||||
|
|||||||
@@ -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?.();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,3 +20,31 @@ export {
|
|||||||
} from './createVirtualizer/createVirtualizer.svelte';
|
} from './createVirtualizer/createVirtualizer.svelte';
|
||||||
|
|
||||||
export { createDebouncedState } from './createDebouncedState/createDebouncedState.svelte';
|
export { createDebouncedState } from './createDebouncedState/createDebouncedState.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createEntityStore,
|
||||||
|
type Entity,
|
||||||
|
type EntityStore,
|
||||||
|
} from './createEntityStore/createEntityStore.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CharacterComparison,
|
||||||
|
createCharacterComparison,
|
||||||
|
type LineData,
|
||||||
|
} from './createCharacterComparison/createCharacterComparison.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,14 +1,43 @@
|
|||||||
export {
|
export {
|
||||||
|
type CharacterComparison,
|
||||||
type ControlDataModel,
|
type ControlDataModel,
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
|
createCharacterComparison,
|
||||||
|
createDebouncedState,
|
||||||
|
createEntityStore,
|
||||||
createFilter,
|
createFilter,
|
||||||
|
createPersistentStore,
|
||||||
|
createPerspectiveManager,
|
||||||
|
createResponsiveManager,
|
||||||
createTypographyControl,
|
createTypographyControl,
|
||||||
createVirtualizer,
|
createVirtualizer,
|
||||||
|
type Entity,
|
||||||
|
type EntityStore,
|
||||||
type Filter,
|
type Filter,
|
||||||
type FilterModel,
|
type FilterModel,
|
||||||
|
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 {
|
||||||
|
buildQueryString,
|
||||||
|
clampNumber,
|
||||||
|
debounce,
|
||||||
|
getDecimalPlaces,
|
||||||
|
roundToStepPrecision,
|
||||||
|
smoothScroll,
|
||||||
|
splitArray,
|
||||||
|
throttle,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
export { springySlideFade } from './transitions';
|
||||||
|
|
||||||
|
export { ResponsiveProvider } from './providers';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user