feature/font-fetching-again #16

Merged
ilia merged 7 commits from feature/font-fetching-again into main 2026-01-15 17:12:37 +00:00
9 changed files with 277 additions and 809 deletions

View File

@@ -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
![Lint](https://your-gitea-instance.com/username/glyphdiff/actions/badges/workflow/lint.yml/badge.svg)
![Test](https://your-gitea-instance.com/username/glyphdiff/actions/badges/workflow/test.yml/badge.svg)
![Build](https://your-gitea-instance.com/username/glyphdiff/actions/badges/workflow/build.yml/badge.svg)
```
## 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

View File

@@ -17,38 +17,21 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '25' node-version: '25'
# We handle caching manually below to ensure
# corepack-managed yarn is used correctly.
- name: Enable Corepack - name: Enable Corepack
run: | run: |
corepack enable corepack enable
corepack prepare yarn@stable --activate corepack prepare yarn@stable --activate
# - 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: ${{ github.workspace }}/.yarn/cache
# key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
# restore-keys: ${{ runner.os }}-yarn-
#
- name: Persistent Yarn Cache - name: Persistent Yarn Cache
uses: actions/cache@v4 uses: actions/cache@v4
id: yarn-cache id: yarn-cache
with: with:
# In Yarn Berry, the cache is local to the project
path: .yarn/cache path: .yarn/cache
# Ensure a clean key without hidden newline characters
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn- restore-keys: ${{ runner.os }}-yarn-
- name: Install dependencies - name: Install dependencies
# --immutable ensures the lockfile isn't changed (replaces --frozen-lockfile)
run: yarn install --immutable run: yarn install --immutable
- name: Build Svelte App - name: Build Svelte App

124
README.md
View File

@@ -1,38 +1,120 @@
# sv # GlyphDiff
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). A modern, high-performance font exploration tool for browsing and comparing fonts from Google Fonts and Fontshare.
## Creating a project ## ✨ Features
If you're seeing this, you've probably already done this step. Congrats! - **Multi-Provider Support**: Access fonts from Google Fonts and Fontshare in one place
- **Fast Virtual Scrolling**: Handles thousands of fonts smoothly with custom virtualization
- **Advanced Filtering**: Filter by category, provider, and character subsets
- **Responsive Design**: Beautiful UI 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 - **Frontend**: Svelte 5 with runes (reactive primitives)
npx sv create my-app - **Styling**: Tailwind CSS v4 + shadcn-svelte components
- **Data Fetching**: TanStack Query for caching and state management
- **Architecture**: Feature-Sliced Design (FSD) methodology
- **Testing**: Playwright (E2E), Vitest (unit), Storybook (components)
- **Quality**: Oxlint (linting), Dprint (formatting), Lefthook (git hooks)
## 📁 Architecture
```
src/
├── app/ # App shell, layout, providers
├── widgets/ # Composed UI blocks
├── features/ # Business features (filters, search)
├── entities/ # Domain entities (Font models, stores)
├── 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 # Open in browser
npm run dev -- --open yarn dev -- --open
``` ```
## 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 with dprint |
| `yarn test` | Run all tests (E2E + unit) |
| `yarn test:e2e` | Run Playwright E2E tests |
| `yarn test:unit` | Run Vitest unit tests |
| `yarn storybook` | Start Storybook dev server |
```sh ## 🧪 Development
npm run build
### Type Checking
```bash
yarn check # Single run
yarn check:watch # Watch mode
``` ```
You can preview the production build with `npm run preview`. ### Testing
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. ```bash
yarn test:unit # Unit tests
yarn test:unit:watch # Watch mode
yarn test:unit:ui # Vitest UI
yarn test:e2e # E2E tests with Playwright
yarn test:e2e --ui # Interactive test runner
```
### Code Quality
```bash
yarn lint # Lint code
yarn format # Format code
yarn format:check # Check formatting
```
## 🎯 Key Components
- **VirtualList**: Custom high-performance list virtualization using Svelte 5 runes
- **FontList**: Displays fonts with loading, empty, and error states
- **FilterControls**: Multi-filter system for category, provider, and subsets
- **TypographyControl**: Dynamic typography adjustment controls
## 📝 Code Style
- **Path Aliases**: Use `$app/`, `$shared/`, `$features/`, `$entities/`, `$widgets/`, `$routes/`
- **Components**: PascalCase (e.g., `CheckboxFilter.svelte`)
- **Formatting**: 100 char line width, 4-space indent, single quotes
- **Imports**: Auto-sorted by dprint, separated by blank line
- **Type Safety**: Strict TypeScript, JSDoc comments for public APIs
## 🏗️ Building for Production
```bash
yarn build
yarn preview
```
## 📚 Learn More
- [Svelte 5 Documentation](https://svelte-5-preview.vercel.app/docs)
- [Feature-Sliced Design](https://feature-sliced.design)
- [Tailwind CSS v4](https://tailwindcss.com/blog/tailwindcss-v4-alpha)
- [shadcn-svelte](https://www.shadcn-svelte.com)
## 📄 License
MIT

View File

@@ -66,7 +66,6 @@
"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"
} }
} }

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { fontshareStore } from '$entities/Font/model/store/fontshareStore.svelte'; import { fontshareStore } from '$entities/Font/model/store/fontshareStore.svelte';
import type { UnifiedFont } from '$entities/Font/model/types/normalize';
import { import {
Content as ItemContent, Content as ItemContent,
Root as ItemRoot, Root as ItemRoot,
@@ -13,28 +12,10 @@ import { VirtualList } from '$shared/ui';
* Displays a virtualized list of fonts with loading, empty, and error states. * 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. * 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> </script>
{#each fontshareStore.fonts as font (font.id)} <VirtualList items={fontshareStore.fonts} itemHeight={30}>
{#snippet children({ item: font })}
<ItemRoot> <ItemRoot>
<ItemContent> <ItemContent>
<ItemTitle>{font.name}</ItemTitle> <ItemTitle>{font.name}</ItemTitle>
@@ -43,4 +24,5 @@ let {
</span> </span>
</ItemContent> </ItemContent>
</ItemRoot> </ItemRoot>
{/each} {/snippet}
</VirtualList>

View File

@@ -24,4 +24,4 @@ import {
</script> </script>
<!-- Font List --> <!-- Font List -->
<FontList showEmpty={true} /> <FontList />

View File

@@ -1,15 +1,157 @@
import { export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
createVirtualizer as coreCreateVirtualizer, // Reactive State
observeElementRect, let scrollOffset = $state(0);
} from '@tanstack/svelte-virtual'; let containerHeight = $state(0);
import type { VirtualItem as CoreVirtualItem } from '@tanstack/virtual-core'; let measuredSizes = $state<Record<number, number>>({});
import { get } from 'svelte/store';
// Non-reactive ref for DOM manipulation (avoiding unnecessary state tracking)
let elementRef: HTMLElement | null = null;
// Reactive Options
const options = $derived(optionsGetter());
// Optimized Memoization (The Cache Layer)
// Only recalculates when item count or measured sizes change.
const offsets = $derived.by(() => {
const count = options.count;
const result = Array.from<number>({ length: count });
let accumulated = 0;
for (let i = 0; i < count; i++) {
result[i] = accumulated;
accumulated += measuredSizes[i] ?? options.estimateSize(i);
}
return result;
});
const totalSize = $derived(
options.count > 0
? offsets[options.count - 1]
+ (measuredSizes[options.count - 1] ?? options.estimateSize(options.count - 1))
: 0,
);
// Visible Range Calculation
// Svelte tracks dependencies automatically here.
const items = $derived.by((): VirtualItem[] => {
const count = options.count;
if (count === 0 || containerHeight === 0) return [];
const overscan = options.overscan ?? 5;
const viewportStart = scrollOffset;
const viewportEnd = scrollOffset + containerHeight;
// Find Start (Linear Scan)
let startIdx = 0;
while (startIdx < count && offsets[startIdx + 1] < viewportStart) {
startIdx++;
}
// Find End
let endIdx = startIdx;
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 size = measuredSizes[i] ?? options.estimateSize(i);
result.push({
index: i,
start: offsets[i],
size,
end: offsets[i] + size,
key: options.getItemKey?.(i) ?? i,
});
}
return result;
});
// Svelte Actions (The DOM Interface)
function container(node: HTMLElement) {
elementRef = node;
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();
elementRef = null;
},
};
}
function measureElement(node: HTMLElement) {
// Use a ResizeObserver on individual items for dynamic height support
const resizeObserver = new ResizeObserver(([entry]) => {
if (entry) {
const index = parseInt(node.dataset.index || '', 10);
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
// Only update if height actually changed to prevent loops
if (!isNaN(index) && measuredSizes[index] !== height) {
measuredSizes[index] = height;
}
}
});
resizeObserver.observe(node);
return {
destroy: () => resizeObserver.disconnect(),
};
}
// Programmatic Scroll
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;
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
if (align === 'end') target = itemStart - containerHeight + itemSize;
elementRef.scrollTo({ top: target, behavior: 'smooth' });
}
return {
get items() {
return items;
},
get totalSize() {
return totalSize;
},
container,
measureElement,
scrollToIndex,
};
}
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 */
start: number; start: number;
/** Height of the item */
size: number; size: number;
/** End position (start + size) */
end: number; end: number;
/** Unique key for the item (for Svelte's {#each} keying) */
key: string | number; key: string | number;
} }
@@ -26,91 +168,4 @@ export interface VirtualizerOptions {
scrollMargin?: number; scrollMargin?: number;
} }
/**
* Creates a reactive virtualizer using Svelte 5 runes and TanStack's core library.
*
* @example
* ```ts
* const virtualizer = createVirtualizer(() => ({
* count: items.length,
* estimateSize: () => 80,
* overscan: 5,
* }));
*
* // In template:
* // <div bind:this={virtualizer.scrollElement}>
* // {#each virtualizer.items as item}
* // <div style="transform: translateY({item.start}px)">
* // {items[item.index]}
* // </div>
* // {/each}
* // </div>
* ```
*/
export function createVirtualizer(
optionsGetter: () => VirtualizerOptions,
) {
let element = $state<HTMLElement | null>(null);
const internalStore = coreCreateVirtualizer({
get count() {
return optionsGetter().count;
},
get estimateSize() {
return optionsGetter().estimateSize;
},
get overscan() {
return optionsGetter().overscan ?? 5;
},
get scrollMargin() {
return optionsGetter().scrollMargin;
},
get getItemKey() {
return optionsGetter().getItemKey ?? (i => i);
},
getScrollElement: () => element,
observeElementRect: observeElementRect,
});
const state = $derived(get(internalStore));
const virtualItems = $derived(
state.getVirtualItems().map((item: CoreVirtualItem): VirtualItem => ({
index: item.index,
start: item.start,
size: item.size,
end: item.end,
key: typeof item.key === 'bigint' ? Number(item.key) : item.key,
})),
);
return {
get items() {
return virtualItems;
},
get totalSize() {
return state.getTotalSize();
},
get scrollOffset() {
return state.scrollOffset ?? 0;
},
get scrollElement() {
return element;
},
set scrollElement(el) {
element = el;
},
scrollToIndex: (idx: number, opt?: { align?: 'start' | 'center' | 'end' | 'auto' }) =>
state.scrollToIndex(idx, opt),
scrollToOffset: (off: number) => state.scrollToOffset(off),
measureElement: (el: HTMLElement) => state.measureElement(el),
};
}
export type Virtualizer = ReturnType<typeof createVirtualizer>; export type Virtualizer = ReturnType<typeof createVirtualizer>;

View File

@@ -55,53 +55,15 @@ interface Props {
let { items, itemHeight = 80, overscan = 5, class: className, children }: Props = $props(); let { items, itemHeight = 80, overscan = 5, class: className, children }: Props = $props();
let activeIndex = $state(0); const virtualizer = createVirtualizer(() => ({
const itemRefs = new Map<number, HTMLElement>();
const virtual = createVirtualizer(() => ({
count: items.length, count: items.length,
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight, estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
overscan, overscan,
})); }));
function registerItem(node: HTMLElement, index: number) {
itemRefs.set(index, node);
return {
destroy() {
itemRefs.delete(index);
},
};
}
async function focusItem(index: number) {
activeIndex = index;
virtual.scrollToIndex(index, { align: 'auto' });
await tick();
itemRefs.get(index)?.focus();
}
async function handleKeydown(event: KeyboardEvent) {
let nextIndex = activeIndex;
if (event.key === 'ArrowDown') nextIndex++;
else if (event.key === 'ArrowUp') nextIndex--;
else if (event.key === 'Home') nextIndex = 0;
else if (event.key === 'End') nextIndex = items.length - 1;
else return;
if (nextIndex >= 0 && nextIndex < items.length) {
event.preventDefault();
await focusItem(nextIndex);
}
}
</script> </script>
<!--
Scroll container with single tab stop pattern:
- tabindex="0" on container, tabindex="-1" on items
- Arrow keys navigate within, Tab moves out
-->
<div <div
bind:this={virtual.scrollElement} use:virtualizer.container
class={cn( class={cn(
'relative overflow-auto border rounded-md bg-background', 'relative overflow-auto border rounded-md bg-background',
'outline-none focus-visible:ring-2 ring-ring ring-offset-2', 'outline-none focus-visible:ring-2 ring-ring ring-offset-2',
@@ -110,29 +72,15 @@ async function handleKeydown(event: KeyboardEvent) {
)} )}
role="listbox" role="listbox"
tabindex="0" tabindex="0"
onkeydown={handleKeydown}
onfocusin={(e => e.target === virtual.scrollElement && focusItem(activeIndex))}
> >
<!-- Total scrollable height placeholder --> {#each virtualizer.items as item (item.key)}
<div <div
class="relative w-full" use:virtualizer.measureElement
style:height="{virtual.totalSize}px" data-index={item.index}
class="absolute top-0 left-0 w-full translate-y-[var(--offset)] will-change-transform"
style:--offset="{item.start}px"
> >
{#each virtual.items as row (row.key)} {@render children({ item: items[item.index], index: item.index })}
<!-- Individual item positioned absolutely via GPU-accelerated transform -->
<div
use:registerItem={row.index}
data-index={row.index}
role="option"
aria-selected={activeIndex === row.index}
tabindex="-1"
onmousedown={() => (activeIndex = row.index)}
class="absolute top-0 left-0 w-full outline-none focus:bg-accent focus:text-accent-foreground"
style:height="{row.size}px"
style:transform="translateY({row.start}px)"
>
{@render children({ item: items[row.index], index: row.index })}
</div> </div>
{/each} {/each}
</div>
</div> </div>

View File

@@ -1310,24 +1310,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@tanstack/svelte-virtual@npm:^3.13.17":
version: 3.13.17
resolution: "@tanstack/svelte-virtual@npm:3.13.17"
dependencies:
"@tanstack/virtual-core": "npm:3.13.17"
peerDependencies:
svelte: ^3.48.0 || ^4.0.0 || ^5.0.0
checksum: 10c0/8139a94d8b913c1a3aef0e7cda4cfd8451c3e46455a5bd5bae1df26ab7583bfde785ab93cacefba4f0f45f2e2cd13f43fa8cf672c45cb31d52b3232ffb37e69e
languageName: node
linkType: hard
"@tanstack/virtual-core@npm:3.13.17":
version: 3.13.17
resolution: "@tanstack/virtual-core@npm:3.13.17"
checksum: 10c0/a021795b88856eff8518137ecb85b72f875399bc234ad10bea440ecb6ab48e5e72a74c9a712649a7765f0c37bc41b88263f5104d18df8256b3d50f6a97b32c48
languageName: node
linkType: hard
"@testing-library/dom@npm:9.x.x || 10.x.x": "@testing-library/dom@npm:9.x.x || 10.x.x":
version: 10.4.1 version: 10.4.1
resolution: "@testing-library/dom@npm:10.4.1" resolution: "@testing-library/dom@npm:10.4.1"
@@ -2466,7 +2448,6 @@ __metadata:
"@sveltejs/vite-plugin-svelte": "npm:^6.2.1" "@sveltejs/vite-plugin-svelte": "npm:^6.2.1"
"@tailwindcss/vite": "npm:^4.1.18" "@tailwindcss/vite": "npm:^4.1.18"
"@tanstack/svelte-query": "npm:^6.0.14" "@tanstack/svelte-query": "npm:^6.0.14"
"@tanstack/svelte-virtual": "npm:^3.13.17"
"@testing-library/jest-dom": "npm:^6.9.1" "@testing-library/jest-dom": "npm:^6.9.1"
"@testing-library/svelte": "npm:^5.3.1" "@testing-library/svelte": "npm:^5.3.1"
"@tsconfig/svelte": "npm:^5.0.6" "@tsconfig/svelte": "npm:^5.0.6"