26 Commits

Author SHA1 Message Date
Renovate Bot 3bc142af14 chore(deps): update peerdependency eslint to v10
CI / @robonen/crdt (pull_request) Successful in 1m15s
CI / @robonen/docs (pull_request) Successful in 5m4s
CI / @robonen/encoding (pull_request) Successful in 1m14s
CI / @robonen/eslint (pull_request) Successful in 1m27s
CI / @robonen/fetch (pull_request) Successful in 1m13s
CI / @robonen/platform (pull_request) Successful in 1m47s
CI / @robonen/primitives (pull_request) Successful in 4m34s
CI / @robonen/primitives-playground (pull_request) Successful in 1m53s
CI / @robonen/renovate (pull_request) Successful in 58s
CI / @robonen/stdlib (pull_request) Successful in 1m54s
CI / @robonen/stories (pull_request) Successful in 2m35s
CI / @robonen/tsconfig (pull_request) Successful in 52s
CI / @robonen/tsdown (pull_request) Successful in 49s
CI / @robonen/vue (pull_request) Successful in 3m20s
CI / @robonen/writekit (pull_request) Successful in 3m45s
CI / @robonen/writekit-playground (pull_request) Successful in 2m4s
CI / CI (pull_request) Successful in 5s
2026-08-08 00:03:44 +00:00
robonen e59e05ac05 chore: bump version to 0.0.2 in jsr.json and package.json
Publish to NPM / Check version changes and publish (push) Successful in 9m40s
2026-08-03 21:53:43 +07:00
robonen 85313c6046 feat: update useSnapPoints to improve drawer snapping behavior and add new features
Publish to NPM / Check version changes and publish (push) Successful in 11m14s
2026-08-03 21:12:35 +07:00
Renovate Bot f444feb7b3 chore(deps): update actions/setup-node action to v7
Publish to NPM / Check version changes and publish (push) Successful in 9m25s
2026-07-30 22:26:13 +00:00
robonen 38c2cd5504 feat: enhance npm publish workflow with improved authentication and concurrency handling
Publish to NPM / Check version changes and publish (push) Successful in 11m13s
2026-07-31 04:54:09 +07:00
robonen c6b6b93e81 chore: update dependencies and package manager versions across projects
Publish to NPM / Check version changes and publish (push) Failing after 10m54s
2026-07-31 04:21:32 +07:00
robonen 53e831894a feat: enhance useVirtualList for dynamic item sizing and improved scrolling behavior
Publish to NPM / Check version changes and publish (push) Failing after 11m20s
2026-07-29 17:19:47 +07:00
robonen a8e5f63415 build: update version to 0.0.2
Publish to NPM / Check version changes and publish (push) Failing after 9m34s
2026-07-17 22:13:04 +07:00
robonen 1cab48ca48 fix(fetch): accept a named interface as request body without a cast
FetchOptions['body'] listed `Record<string, unknown>`, which a named interface
is not assignable to (interfaces carry no implicit index signature), so every
caller passing a typed body had to cast it. Widen the object member to `object`:
named interfaces and arrays now assign directly, `any` is not introduced, and
bare primitives (number/boolean) are still rejected. Runtime is unchanged — the
serializer already narrows the body with its own casts.

Guard it with a type test (src/types.test-d.ts, run under vitest --typecheck):
a named interface must assign to the body type and flow through the method
shortcuts with no cast, while bare primitives stay rejected. Enable typecheck
for the package's vitest project so the assertions are enforced by tsc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 22:13:04 +07:00
robonen 8ea85c4aab Merge pull request 'chore(deps): update actions/checkout action to v7' (#7) from renovate/actions-checkout-7.x into master
Publish to NPM / Check version changes and publish (push) Successful in 10m28s
Reviewed-on: #7
2026-06-18 19:44:24 +00:00
Renovate Bot 151e106aa7 chore(deps): update actions/checkout action to v7
CI / @robonen/crdt (pull_request) Successful in 1m40s
CI / @robonen/docs (pull_request) Successful in 5m30s
CI / @robonen/encoding (pull_request) Successful in 1m42s
CI / @robonen/eslint (pull_request) Successful in 1m6s
CI / @robonen/fetch (pull_request) Successful in 1m14s
CI / @robonen/platform (pull_request) Successful in 1m21s
CI / @robonen/primitives (pull_request) Successful in 5m26s
CI / @robonen/primitives-playground (pull_request) Successful in 1m54s
CI / @robonen/renovate (pull_request) Successful in 54s
CI / @robonen/stdlib (pull_request) Successful in 1m36s
CI / @robonen/stories (pull_request) Successful in 2m10s
CI / @robonen/tsconfig (pull_request) Successful in 52s
CI / @robonen/tsdown (pull_request) Successful in 59s
CI / @robonen/vue (pull_request) Successful in 3m31s
CI / @robonen/writekit (pull_request) Successful in 4m35s
CI / @robonen/writekit-playground (pull_request) Successful in 1m56s
CI / CI (pull_request) Successful in 5s
2026-06-18 16:04:37 +00:00
robonen 655f30a658 test(scroll-area): improve glimpse type test for pointer interactions
Publish to NPM / Check version changes and publish (push) Successful in 11m30s
2026-06-18 03:17:25 +07:00
robonen ab6d8f6ce0 build: bump new versions
Publish to NPM / Check version changes and publish (push) Failing after 10m34s
2026-06-18 02:57:03 +07:00
robonen e73e8d2cdd chore: update dependencies and configurations across multiple packages
Publish to NPM / Check version changes and publish (push) Failing after 10m44s
2026-06-18 02:02:58 +07:00
robonen 6e70d4edd1 refactor(ci): replace dynamic package discovery with static package list
Publish to NPM / Check version changes and publish (push) Failing after 10m51s
2026-06-16 06:01:38 +07:00
robonen c8c9676d1e Merge pull request #148 from robonen/docs
Publish to NPM / Check version changes and publish (push) Failing after 2m26s
refactor(docs): remove unused broadcastedRef composable
2026-06-15 17:50:26 +07:00
robonen 91fa464d95 refactor(docs): remove unused broadcastedRef composable 2026-06-15 17:49:59 +07:00
robonen b24291ac3a Merge pull request #147 from robonen/docs
fix(docs): build workspace libs before the Nuxt build
2026-06-15 17:48:32 +07:00
robonen 98b76f46cf fix(docs): build workspace libs before the Nuxt build
The SFC type resolver resolves `@robonen/*` package imports (e.g. PrimitiveProps,
used in `defineProps<X extends PrimitiveProps>()`) via the package `exports` →
`dist/*.d.ts`, independently of the Vite source-alias. On a fresh checkout (CI /
Vercel) those dist files don't exist yet, so the compiler throws 'Failed to
resolve extends base type'. `build`/`generate` now run `build:deps` first.
2026-06-15 17:41:34 +07:00
robonen 0679160cb0 Merge pull request #146 from robonen/docs
Docs
2026-06-15 17:16:22 +07:00
robonen 4c8c3a396e chore(ci): migrate workflows from GitHub to Gitea; update lockfile 2026-06-15 16:55:23 +07:00
robonen 8adc2522c6 docs: site WIP, extractor type cleanup, tests preset; add broadcastedRef
Type the docs extractor's package.json parsing as unknown; comment the Vite
plugin version-skew cast; wire the tests preset; site/architecture WIP.
2026-06-15 16:55:22 +07:00
robonen be667df3d8 chore(stories): wire tests preset into lint config 2026-06-15 16:55:07 +07:00
robonen a147ec0730 docs(core): update crdt/encoding/fetch docs and lint config 2026-06-15 16:55:07 +07:00
robonen aa2938cb34 refactor(toolkit): type source any with proper types
Genuinely type composable any usages (useStepper/useStorage/useForm/
createEventHook/useSorted/etc.) as proper generics/unknown; keep idiomatic
any-function and overload-impl signatures with comments; skipped test -> .todo.
2026-06-15 16:55:07 +07:00
robonen 858cd8f8e0 Merge pull request #143 from robonen/docs
feat(navigation-menu): enhance context handling and lifecycle management
2026-06-10 16:36:24 +07:00
544 changed files with 15221 additions and 10407 deletions
@@ -13,54 +13,47 @@ env:
NODE_VERSION: 24.x NODE_VERSION: 24.x
jobs: jobs:
# Enumerate the workspace packages so the matrix below fans out one job per
# package (kept dynamic so new packages are picked up automatically).
discover:
name: Discover packages
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.list.outputs.packages }}
steps:
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: List workspace packages
id: list
run: echo "packages=$(pnpm -r ls --depth -1 --json | jq -c '[.[] | select(.name != "tools") | .name]')" >> "$GITHUB_OUTPUT"
# One job per package — build (with its workspace deps), lint and test run in # One job per package — build (with its workspace deps), lint and test run in
# parallel across packages. fail-fast: false so every package is reported. # parallel across packages. fail-fast: false so every package is reported.
#
# The list is static: Gitea's act_runner does not expand a dynamic matrix
# built from a previous job's outputs (the strategy is evaluated before the
# producing job runs), so `matrix.package` came out empty. When you add a
# workspace package, add a line here.
check: check:
name: ${{ matrix.package }} name: ${{ matrix.package }}
needs: discover
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
package: ${{ fromJSON(needs.discover.outputs.packages) }} package:
- "@robonen/eslint"
- "@robonen/tsconfig"
- "@robonen/tsdown"
- "@robonen/crdt"
- "@robonen/encoding"
- "@robonen/fetch"
- "@robonen/platform"
- "@robonen/stdlib"
- "@robonen/docs"
- "@robonen/renovate"
- "@robonen/primitives"
- "@robonen/primitives-playground"
- "@robonen/stories"
- "@robonen/vue"
- "@robonen/writekit"
- "@robonen/writekit-playground"
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
with: with:
run_install: false run_install: false
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: pnpm cache: pnpm
@@ -77,7 +70,7 @@ jobs:
# browser. playwright is a direct devDep of these packages, so run its CLI # browser. playwright is a direct devDep of these packages, so run its CLI
# in the package context (--filter) — it isn't resolvable from the root. # in the package context (--filter) — it isn't resolvable from the root.
- name: Install Playwright Chromium - name: Install Playwright Chromium
if: matrix.package == '@robonen/primitives' || matrix.package == '@robonen/editor' if: matrix.package == '@robonen/primitives' || matrix.package == '@robonen/writekit'
run: pnpm --filter "${{ matrix.package }}" exec playwright install --with-deps chromium run: pnpm --filter "${{ matrix.package }}" exec playwright install --with-deps chromium
- name: Lint - name: Lint
@@ -87,7 +80,7 @@ jobs:
run: pnpm --filter "${{ matrix.package }}" --if-present run test run: pnpm --filter "${{ matrix.package }}" --if-present run test
# Sentinel job — aggregates all matrix results into a single status check. # Sentinel job — aggregates all matrix results into a single status check.
# Add "CI" as the required check in branch protection rules. # Add "CI" as the required status check in the branch protection rules.
ci: ci:
name: CI name: CI
needs: check needs: check
@@ -5,6 +5,12 @@ on:
branches: branches:
- master - master
# The registry is append-only — a half-finished release cannot be rolled back.
# One publish at a time, and never cancel one that is already writing.
concurrency:
group: publish-npm
cancel-in-progress: false
env: env:
NODE_VERSION: 24.x NODE_VERSION: 24.x
@@ -13,7 +19,7 @@ jobs:
name: Check version changes and publish name: Check version changes and publish
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -22,11 +28,41 @@ jobs:
with: with:
run_install: false run_install: false
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: pnpm cache: pnpm
registry-url: 'https://registry.npmjs.org' # No `registry-url:` on purpose. It writes an npmrc holding the literal
# string `${NODE_AUTH_TOKEN}` and points NPM_CONFIG_USERCONFIG at it,
# which would shadow the file the next step writes. We supply the token
# verbatim instead, so no variable-expansion rules can apply.
# npm masks an unauthorized write as `404 Not Found` rather than 401, so a
# dead token surfaces as "package does not exist" three steps later, after
# a full build+test. Verify the credential up front and say so plainly.
- name: Authenticate to npm
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
if [ -z "${NPM_TOKEN:-}" ]; then
echo "::error::secrets.NPM_TOKEN is empty or not set for this repo."
echo "::error::Add it under Gitea > Settings > Actions > Secrets."
exit 1
fi
printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" > "$HOME/.npmrc"
chmod 600 "$HOME/.npmrc"
if ! WHO=$(npm whoami --registry=https://registry.npmjs.org 2>&1); then
echo "::error::npm rejected the token — expired, revoked, or wrong account."
echo "::error::npm said: ${WHO}"
echo "::error::Mint a granular token with read+write on the @robonen scope"
echo "::error::at npmjs.com and update the Gitea secret."
exit 1
fi
echo "Authenticated to npm as: ${WHO}"
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -38,8 +74,6 @@ jobs:
run: pnpm build && pnpm test run: pnpm build && pnpm test
- name: Check for version changes and publish - name: Check for version changes and publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: | run: |
# Find all package.json files (excluding node_modules) # Find all package.json files (excluding node_modules)
PACKAGE_FILES=$(find . -path "*/package.json" -not -path "*/node_modules/*") PACKAGE_FILES=$(find . -path "*/package.json" -not -path "*/node_modules/*")
@@ -79,3 +113,7 @@ jobs:
echo "No version change detected for $PACKAGE_NAME" echo "No version change detected for $PACKAGE_NAME"
fi fi
done done
- name: Scrub credentials
if: always()
run: rm -f "$HOME/.npmrc"
+11 -10
View File
@@ -17,11 +17,12 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "configs/eslint" "directory": "configs/eslint"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.18.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
@@ -47,15 +48,15 @@
"dependencies": { "dependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "catalog:", "@stylistic/eslint-plugin": "catalog:",
"@vitest/eslint-plugin": "^1.6.19", "@vitest/eslint-plugin": "^1.6.24",
"eslint-plugin-import-x": "^4.16.2", "eslint-plugin-import-x": "^4.17.1",
"eslint-plugin-n": "^18.1.0", "eslint-plugin-n": "^18.2.2",
"eslint-plugin-regexp": "^3.1.0", "eslint-plugin-regexp": "^3.1.1",
"eslint-plugin-unicorn": "^65.0.1", "eslint-plugin-unicorn": "^72.0.0",
"eslint-plugin-vue": "^10.9.2", "eslint-plugin-vue": "^10.10.0",
"globals": "^17.6.0", "globals": "^17.8.0",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"typescript-eslint": "^8.61.0", "typescript-eslint": "^8.65.0",
"vue-eslint-parser": "^10.4.1" "vue-eslint-parser": "^10.4.1"
}, },
"devDependencies": { "devDependencies": {
@@ -66,7 +67,7 @@
"tsdown": "catalog:" "tsdown": "catalog:"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": ">=9.39.4" "eslint": ">=10.8.1"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
+1 -1
View File
@@ -15,7 +15,7 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "packages/tsconfig" "directory": "packages/tsconfig"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.18.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
+1 -1
View File
@@ -15,7 +15,7 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "configs/tsdown" "directory": "configs/tsdown"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.18.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
+2 -2
View File
@@ -1,6 +1,6 @@
# @robonen/crdt # @robonen/crdt
Framework-agnostic CRDT primitives — the convergence engine behind `@robonen/editor`, usable on their own. Zero runtime dependencies; pure TypeScript; runs in Node and the browser. Framework-agnostic CRDT primitives — the convergence engine behind `@robonen/writekit`, usable on their own. Zero runtime dependencies; pure TypeScript; runs in Node and the browser.
Every primitive is built so that **applying the same set of operations in any order, with duplicates, yields the same state** (commutative, idempotent, convergent), verified by property tests. Every primitive is built so that **applying the same set of operations in any order, with duplicates, yields the same state** (commutative, idempotent, convergent), verified by property tests.
@@ -50,7 +50,7 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged
- `compareOpId` is the single deterministic tie-break (higher clock wins; site id breaks ties) every primitive agrees on — that's what makes LWW and RGA converge. - `compareOpId` is the single deterministic tie-break (higher clock wins; site id breaks ties) every primitive agrees on — that's what makes LWW and RGA converge.
- `VersionVector` assumes **dense** per-site clocks (1, 2, 3, …). - `VersionVector` assumes **dense** per-site clocks (1, 2, 3, …).
- The v1 wire format is JSON encoded to bytes — simple and debuggable; a compact varint format is a later optimization with no API change. - The v1 wire format is JSON encoded to bytes — simple and debuggable; a compact varint format is a later optimization with no API change.
- An editor-specific composition of these primitives (blocks + text + marks ↔ editor steps) lives in `@robonen/editor` under `crdt/native/`, not here — this package stays domain-agnostic. - A writekit-specific composition of these primitives (blocks + text + marks ↔ writekit steps) lives in `@robonen/writekit` under `crdt/native/`, not here — this package stays domain-agnostic.
## Development ## Development
+20 -20
View File
@@ -179,13 +179,13 @@ const propsSrc = `// Commutative — order of application doesn't matter:
same survivor. That single shared decision is what lets a last-writer-wins register and a sequence same survivor. That single shared decision is what lets a last-writer-wins register and a sequence
CRDT, built by different code, nonetheless agree on the final document. CRDT, built by different code, nonetheless agree on the final document.
</p> </p>
<div class="my-4 rounded-lg border border-(--border) bg-(--bg-subtle) p-4"> <div class="my-4 rounded-lg border border-border bg-bg-subtle p-4">
<p class="m-0 text-sm leading-relaxed text-(--fg-muted)"> <p class="m-0 text-sm leading-relaxed text-fg-muted">
<strong class="text-(--fg)">Why one rule for everything?</strong> <strong class="text-fg">Why one rule for everything?</strong>
<code class="text-(--accent-text)">LwwRegister</code> uses <code class="text-accent-text">LwwRegister</code> uses
<code class="text-(--accent-text)">compareOpId</code> to pick the surviving value; <code class="text-accent-text">compareOpId</code> to pick the surviving value;
<code class="text-(--accent-text)">Rga</code> uses it to break ties between concurrent inserts at <code class="text-accent-text">Rga</code> uses it to break ties between concurrent inserts at
the same position; <code class="text-(--accent-text)">MarkStore</code> uses it to decide which the same position; <code class="text-accent-text">MarkStore</code> uses it to decide which
formatting wins per character. One total order, applied consistently, is what turns a pile of formatting wins per character. One total order, applied consistently, is what turns a pile of
independent primitives into a coherent, converging system. independent primitives into a coherent, converging system.
</p> </p>
@@ -223,11 +223,11 @@ const propsSrc = `// Commutative — order of application doesn't matter:
<DocsCode :code="vvWireSrc" lang="ts" /> <DocsCode :code="vvWireSrc" lang="ts" />
<div class="prose-docs"> <div class="prose-docs">
<div class="my-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-4"> <div class="my-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
<p class="m-0 text-sm leading-relaxed text-(--fg-muted)"> <p class="m-0 text-sm leading-relaxed text-fg-muted">
<strong class="text-amber-700 dark:text-amber-400">Density matters.</strong> <strong class="text-amber-700 dark:text-amber-400">Density matters.</strong>
<code class="text-(--accent-text)">VersionVector</code> only works because clocks arrive without <code class="text-accent-text">VersionVector</code> only works because clocks arrive without
gaps. If you generate ids with a raw <code class="text-(--accent-text)">LamportClock</code>, deliver gaps. If you generate ids with a raw <code class="text-accent-text">LamportClock</code>, deliver
them in order per site (the <code class="text-(--accent-text)">Replica</code>'s causal buffer does them in order per site (the <code class="text-accent-text">Replica</code>'s causal buffer does
this for you) so a single high-water mark per site can stand in for the full set of seen ops. this for you) so a single high-water mark per site can stand in for the full set of seen ops.
</p> </p>
</div> </div>
@@ -242,23 +242,23 @@ const propsSrc = `// Commutative — order of application doesn't matter:
</div> </div>
<DocsCode :code="propsSrc" lang="ts" /> <DocsCode :code="propsSrc" lang="ts" />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Commutative</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Commutative</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
Order of application doesn't change the result. A replica can integrate operations as they arrive, Order of application doesn't change the result. A replica can integrate operations as they arrive,
in whatever sequence the network delivers them. in whatever sequence the network delivers them.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Idempotent</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Idempotent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
Applying the same operation twice is the same as applying it once. Redelivery and retries are safe; Applying the same operation twice is the same as applying it once. Redelivery and retries are safe;
version vectors make them free. version vectors make them free.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Convergent</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Convergent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
Same set of operations, same final state — full stop. Two replicas that have seen the same ops are Same set of operations, same final state — full stop. Two replicas that have seen the same ops are
byte-for-byte identical. byte-for-byte identical.
</p> </p>
+30 -30
View File
@@ -198,33 +198,33 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
<!-- Map of the package --> <!-- Map of the package -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Registers</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Registers</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
<code class="text-(--accent-text)">LwwRegister</code> and <code class="text-accent-text">LwwRegister</code> and
<code class="text-(--accent-text)">LwwMap</code> single values and keyed maps where the <code class="text-accent-text">LwwMap</code> single values and keyed maps where the
write with the highest op id wins. write with the highest op id wins.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Ordering</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Ordering</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
<code class="text-(--accent-text)">keyBetween</code> / <code class="text-accent-text">keyBetween</code> /
<code class="text-(--accent-text)">keysBetween</code> fractional indexing to place or move <code class="text-accent-text">keysBetween</code> fractional indexing to place or move
an item with a single string key. an item with a single string key.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Sequence</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Sequence</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
<code class="text-(--accent-text)">Rga</code> a replicated growable array: an ordered <code class="text-accent-text">Rga</code> a replicated growable array: an ordered
sequence CRDT with tombstones and a deterministic insert tie-break. sequence CRDT with tombstones and a deterministic insert tie-break.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Marks</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Marks</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
<code class="text-(--accent-text)">MarkStore</code> lightweight Peritext formatting spans <code class="text-accent-text">MarkStore</code> lightweight Peritext formatting spans
anchored to character op ids, resolved per character by highest op id. anchored to character op ids, resolved per character by highest op id.
</p> </p>
</div> </div>
@@ -262,12 +262,12 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
</div> </div>
<DocsCode :code="lwwMap" lang="ts" /> <DocsCode :code="lwwMap" lang="ts" />
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-4"> <div class="rounded-lg border border-border bg-bg-subtle p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
<strong class="text-(--fg)">Why keep tombstones?</strong> If a delete simply dropped the entry, <strong class="text-fg">Why keep tombstones?</strong> If a delete simply dropped the entry,
a concurrent <code class="text-(--accent-text)">set</code> arriving afterward would resurrect a concurrent <code class="text-accent-text">set</code> arriving afterward would resurrect
the key the two replicas would disagree on whether it exists. Retaining the delete as a the key the two replicas would disagree on whether it exists. Retaining the delete as a
timestamped tombstone lets <code class="text-(--accent-text)">compareOpId</code> decide the timestamped tombstone lets <code class="text-accent-text">compareOpId</code> decide the
winner deterministically, the same way it does for live values. winner deterministically, the same way it does for live values.
</p> </p>
</div> </div>
@@ -308,9 +308,9 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
<DocsCode :code="fractionalBatch" lang="ts" /> <DocsCode :code="fractionalBatch" lang="ts" />
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4"> <div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
<strong class="text-amber-700 dark:text-amber-400">Heads up:</strong> <strong class="text-amber-700 dark:text-amber-400">Heads up:</strong>
<code class="text-(--accent-text)">keyBetween</code> requires <code>lower &lt; upper</code> <code class="text-accent-text">keyBetween</code> requires <code>lower &lt; upper</code>
and throws otherwise. Two replicas independently generating a key between the and throws otherwise. Two replicas independently generating a key between the
<em>same</em> neighbors can produce identical keys; pair the key with the item's op id as a <em>same</em> neighbors can produce identical keys; pair the key with the item's op id as a
secondary sort to keep ordering deterministic, or let secondary sort to keep ordering deterministic, or let
@@ -366,14 +366,14 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
</div> </div>
<DocsCode :code="rgaBuffer" lang="ts" /> <DocsCode :code="rgaBuffer" lang="ts" />
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-4"> <div class="rounded-lg border border-border bg-bg-subtle p-4">
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
<strong class="text-(--fg)">Garbage collection.</strong> Tombstones accumulate. When every <strong class="text-fg">Garbage collection.</strong> Tombstones accumulate. When every
replica has fully synced and nothing is in flight, <code class="text-(--accent-text)">gc(stable, keep?)</code> replica has fully synced and nothing is in flight, <code class="text-accent-text">gc(stable, keep?)</code>
drops deleted nodes whose insert is covered by a stable drops deleted nodes whose insert is covered by a stable
<NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink>, returning how many it removed. <NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink>, returning how many it removed.
Run it only at quiescence a late op that uses a dropped node as its origin could no longer Run it only at quiescence a late op that uses a dropped node as its origin could no longer
integrate and pass <code class="text-(--accent-text)">keep</code> to protect ids still integrate and pass <code class="text-accent-text">keep</code> to protect ids still
referenced elsewhere, such as mark span endpoints. referenced elsewhere, such as mark span endpoints.
</p> </p>
</div> </div>
+8 -8
View File
@@ -249,12 +249,12 @@ a.replica.receive(ops);`;
</div> </div>
<!-- Why order does not matter --> <!-- Why order does not matter -->
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Why the order of the two deltas is irrelevant</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Why the order of the two deltas is irrelevant</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
You could swap the two <code class="text-(--accent-text)">receive</code> lines, run them You could swap the two <code class="text-accent-text">receive</code> lines, run them
repeatedly, or interleave them with more edits — the result is the same. Each side only ever repeatedly, or interleave them with more edits — the result is the same. Each side only ever
adds ops it hasn't seen, and <code class="text-(--accent-text)">compareOpId</code> places adds ops it hasn't seen, and <code class="text-accent-text">compareOpId</code> places
each op in its deterministic position regardless of arrival order. That is convergence, each op in its deterministic position regardless of arrival order. That is convergence,
and the property tests assert it across randomized schedules. and the property tests assert it across randomized schedules.
</p> </p>
@@ -346,11 +346,11 @@ a.replica.receive(ops);`;
<!-- Caveat callout --> <!-- Caveat callout -->
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-5"> <div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-5">
<h3 class="mb-1.5 text-sm font-semibold text-amber-700 dark:text-amber-400">Dense clocks are a precondition</h3> <h3 class="mb-1.5 text-sm font-semibold text-amber-700 dark:text-amber-400">Dense clocks are a precondition</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
Version vectors assume each site's clocks are dense (1, 2, 3, ). That holds automatically Version vectors assume each site's clocks are dense (1, 2, 3, ). That holds automatically
when ids come from <code class="text-(--accent-text)">Replica.nextId()</code>. If you mint when ids come from <code class="text-accent-text">Replica.nextId()</code>. If you mint
ids yourself, never skip a value for a site a gap would make ids yourself, never skip a value for a site a gap would make
<code class="text-(--accent-text)">delta</code> believe a missing op was already delivered. <code class="text-accent-text">delta</code> believe a missing op was already delivered.
</p> </p>
</div> </div>
+36 -36
View File
@@ -260,17 +260,17 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`;
<ClientOnly> <ClientOnly>
<template #fallback> <template #fallback>
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-8 text-center text-sm text-(--fg-subtle)"> <div class="rounded-xl border border-border bg-bg-subtle p-8 text-center text-sm text-fg-subtle">
Loading interactive demo Loading interactive demo
</div> </div>
</template> </template>
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4 sm:p-5"> <div class="rounded-xl border border-border bg-bg-subtle p-4 sm:p-5">
<div v-if="!ready" class="flex flex-col items-center gap-3 py-8 text-center"> <div v-if="!ready" class="flex flex-col items-center gap-3 py-8 text-center">
<p class="text-sm text-(--fg-muted)">Spin up two fresh replicas to start editing.</p> <p class="text-sm text-fg-muted">Spin up two fresh replicas to start editing.</p>
<button <button
type="button" type="button"
class="rounded-md bg-(--accent) px-4 py-2 text-sm font-medium text-(--accent-fg) hover:bg-(--accent-hover) focus:outline-none focus:ring-2 focus:ring-(--ring)" class="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:bg-accent-hover focus:outline-none focus:ring-2 focus:ring-ring"
@click="start()" @click="start()"
> >
Start demo Start demo
@@ -281,82 +281,82 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`;
<!-- Two replica panes --> <!-- Two replica panes -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Replica A --> <!-- Replica A -->
<div class="flex flex-col gap-2 rounded-lg border border-(--border) bg-(--bg-elevated) p-3"> <div class="flex flex-col gap-2 rounded-lg border border-border bg-bg-elevated p-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider text-(--fg-muted)">Replica A</span> <span class="text-xs font-semibold uppercase tracking-wider text-fg-muted">Replica A</span>
<span class="rounded bg-(--bg-inset) px-1.5 py-0.5 font-mono text-[11px] text-(--fg-subtle)">site: A</span> <span class="rounded bg-bg-inset px-1.5 py-0.5 font-mono text-[11px] text-fg-subtle">site: A</span>
</div> </div>
<textarea <textarea
v-model="drafts.a" v-model="drafts.a"
rows="3" rows="3"
spellcheck="false" spellcheck="false"
class="resize-none rounded-md border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) focus:border-(--border-strong) focus:outline-none focus:ring-2 focus:ring-(--ring)" class="resize-none rounded-md border border-border bg-bg px-3 py-2 font-mono text-sm text-fg focus:border-border-strong focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Type on A…" placeholder="Type on A…"
/> />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
type="button" type="button"
class="rounded-md border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-xs font-medium text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring)" class="rounded-md border border-border bg-bg-elevated px-3 py-1.5 text-xs font-medium text-fg hover:bg-bg-inset focus:outline-none focus:ring-2 focus:ring-ring"
@click="apply('a')" @click="apply('a')"
> >
Apply edits Apply edits
</button> </button>
<div class="ml-auto flex items-center gap-3 font-mono text-[11px] text-(--fg-subtle)"> <div class="ml-auto flex items-center gap-3 font-mono text-[11px] text-fg-subtle">
<span>ops {{ snapshot.a.ops }}</span> <span>ops {{ snapshot.a.ops }}</span>
<span>clock {{ snapshot.a.clock }}</span> <span>clock {{ snapshot.a.clock }}</span>
</div> </div>
</div> </div>
<div class="rounded-md bg-(--bg-inset) px-3 py-2 font-mono text-sm text-(--fg) break-all min-h-9"> <div class="rounded-md bg-bg-inset px-3 py-2 font-mono text-sm text-fg break-all min-h-9">
<span v-if="snapshot.a.text">{{ snapshot.a.text }}</span> <span v-if="snapshot.a.text">{{ snapshot.a.text }}</span>
<span v-else class="text-(--fg-subtle)">(empty)</span> <span v-else class="text-fg-subtle">(empty)</span>
</div> </div>
</div> </div>
<!-- Replica B --> <!-- Replica B -->
<div class="flex flex-col gap-2 rounded-lg border border-(--border) bg-(--bg-elevated) p-3"> <div class="flex flex-col gap-2 rounded-lg border border-border bg-bg-elevated p-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider text-(--fg-muted)">Replica B</span> <span class="text-xs font-semibold uppercase tracking-wider text-fg-muted">Replica B</span>
<span class="rounded bg-(--bg-inset) px-1.5 py-0.5 font-mono text-[11px] text-(--fg-subtle)">site: B</span> <span class="rounded bg-bg-inset px-1.5 py-0.5 font-mono text-[11px] text-fg-subtle">site: B</span>
</div> </div>
<textarea <textarea
v-model="drafts.b" v-model="drafts.b"
rows="3" rows="3"
spellcheck="false" spellcheck="false"
class="resize-none rounded-md border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) focus:border-(--border-strong) focus:outline-none focus:ring-2 focus:ring-(--ring)" class="resize-none rounded-md border border-border bg-bg px-3 py-2 font-mono text-sm text-fg focus:border-border-strong focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Type on B…" placeholder="Type on B…"
/> />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
type="button" type="button"
class="rounded-md border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-xs font-medium text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring)" class="rounded-md border border-border bg-bg-elevated px-3 py-1.5 text-xs font-medium text-fg hover:bg-bg-inset focus:outline-none focus:ring-2 focus:ring-ring"
@click="apply('b')" @click="apply('b')"
> >
Apply edits Apply edits
</button> </button>
<div class="ml-auto flex items-center gap-3 font-mono text-[11px] text-(--fg-subtle)"> <div class="ml-auto flex items-center gap-3 font-mono text-[11px] text-fg-subtle">
<span>ops {{ snapshot.b.ops }}</span> <span>ops {{ snapshot.b.ops }}</span>
<span>clock {{ snapshot.b.clock }}</span> <span>clock {{ snapshot.b.clock }}</span>
</div> </div>
</div> </div>
<div class="rounded-md bg-(--bg-inset) px-3 py-2 font-mono text-sm text-(--fg) break-all min-h-9"> <div class="rounded-md bg-bg-inset px-3 py-2 font-mono text-sm text-fg break-all min-h-9">
<span v-if="snapshot.b.text">{{ snapshot.b.text }}</span> <span v-if="snapshot.b.text">{{ snapshot.b.text }}</span>
<span v-else class="text-(--fg-subtle)">(empty)</span> <span v-else class="text-fg-subtle">(empty)</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Sync bar --> <!-- Sync bar -->
<div class="flex flex-wrap items-center gap-3 border-t border-(--border) pt-3"> <div class="flex flex-wrap items-center gap-3 border-t border-border pt-3">
<button <button
type="button" type="button"
class="rounded-md bg-(--accent) px-4 py-2 text-sm font-medium text-(--accent-fg) hover:bg-(--accent-hover) focus:outline-none focus:ring-2 focus:ring-(--ring)" class="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:bg-accent-hover focus:outline-none focus:ring-2 focus:ring-ring"
@click="sync()" @click="sync()"
> >
Sync Sync
</button> </button>
<button <button
type="button" type="button"
class="rounded-md px-3 py-2 text-sm text-(--fg-muted) hover:bg-(--bg-inset) hover:text-(--fg) focus:outline-none focus:ring-2 focus:ring-(--ring)" class="rounded-md px-3 py-2 text-sm text-fg-muted hover:bg-bg-inset hover:text-fg focus:outline-none focus:ring-2 focus:ring-ring"
@click="init()" @click="init()"
> >
Reset Reset
@@ -436,27 +436,27 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`;
</div> </div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Commutative</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Commutative</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
A-then-B and B-then-A produce the same sequence. Concurrent inserts at the same origin are A-then-B and B-then-A produce the same sequence. Concurrent inserts at the same origin are
ordered by <code class="text-(--accent-text)">compareOpId</code>, so order of arrival ordered by <code class="text-accent-text">compareOpId</code>, so order of arrival
doesn't matter. doesn't matter.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Idempotent</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Idempotent</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
Receiving the same op twice is a no-op. The op log's version vector dedups on Receiving the same op twice is a no-op. The op log's version vector dedups on
<code class="text-(--accent-text)">id</code>, and <code class="text-(--accent-text)">integrateInsert</code> <code class="text-accent-text">id</code>, and <code class="text-accent-text">integrateInsert</code>
short-circuits if the id is already present. short-circuits if the id is already present.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Causal</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Causal</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
An insert can't integrate before its <code class="text-(--accent-text)">originLeft</code>, An insert can't integrate before its <code class="text-accent-text">originLeft</code>,
nor a delete before its target. <code class="text-(--accent-text)">receive</code> buffers nor a delete before its target. <code class="text-accent-text">receive</code> buffers
such ops and retries them, so out-of-order delivery still converges. such ops and retries them, so out-of-order delivery still converges.
</p> </p>
</div> </div>
+17 -17
View File
@@ -55,40 +55,40 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged`;
offline, with messages that arrive out of order or twice. A CRDT solves this by construction: offline, with messages that arrive out of order or twice. A CRDT solves this by construction:
every primitive here is <strong>commutative, idempotent, and convergent</strong>, so applying every primitive here is <strong>commutative, idempotent, and convergent</strong>, so applying
the same set of operations in any order yields the same state a property verified by the same set of operations in any order yields the same state a property verified by
property tests. It's the convergence engine behind <code>@robonen/editor</code>, but stays property tests. It's the convergence engine behind <code>@robonen/writekit</code>, but stays
fully domain-agnostic, ships zero runtime dependencies, and runs in both Node and the browser. fully domain-agnostic, ships zero runtime dependencies, and runs in both Node and the browser.
</p> </p>
</div> </div>
<!-- Feature cards --> <!-- Feature cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Convergent by construction</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Convergent by construction</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
One deterministic tie-break — <code class="text-(--accent-text)">compareOpId</code> (higher One deterministic tie-break — <code class="text-accent-text">compareOpId</code> (higher
Lamport clock wins; site id breaks ties) — is shared by every primitive, so LWW and RGA agree Lamport clock wins; site id breaks ties) — is shared by every primitive, so LWW and RGA agree
on the same final state. on the same final state.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Causal buffering built in</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Causal buffering built in</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
<code class="text-(--accent-text)">Replica.receive</code> dedups, holds ops whose dependencies <code class="text-accent-text">Replica.receive</code> dedups, holds ops whose dependencies
haven't arrived yet (an insert before its origin), and retries them automatically as they land. haven't arrived yet (an insert before its origin), and retries them automatically as they land.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Delta sync, not full state</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Delta sync, not full state</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
Version vectors let each side request exactly the ops it's missing via Version vectors let each side request exactly the ops it's missing via
<code class="text-(--accent-text)">delta(version)</code>, with a transport-agnostic wire format. <code class="text-accent-text">delta(version)</code>, with a transport-agnostic wire format.
</p> </p>
</div> </div>
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5"> <div class="rounded-lg border border-border bg-bg-subtle p-5">
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Zero dependencies, pure TS</h3> <h3 class="mb-1.5 text-sm font-semibold text-fg">Zero dependencies, pure TS</h3>
<p class="text-sm leading-relaxed text-(--fg-muted)"> <p class="text-sm leading-relaxed text-fg-muted">
No runtime deps, no framework lock-in. Compose the primitives yourself, or lean on No runtime deps, no framework lock-in. Compose the primitives yourself, or lean on
<code class="text-(--accent-text)">Replica</code> to tie a clock, op log, and buffer together. <code class="text-accent-text">Replica</code> to tie a clock, op log, and buffer together.
</p> </p>
</div> </div>
</div> </div>
+2 -2
View File
@@ -1,3 +1,3 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint'; import { base, compose, imports, stylistic, tests, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic); export default compose(base, typescript, imports, stylistic, tests);
+2 -1
View File
@@ -17,11 +17,12 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "core/crdt" "directory": "core/crdt"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.18.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
+12 -12
View File
@@ -27,36 +27,36 @@
<!-- Feature cards --> <!-- Feature cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-(--fg)">High-level QR in one call</h3> <h3 class="text-sm font-semibold text-fg">High-level QR in one call</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)"> <p class="mt-1.5 text-sm text-fg-muted">
<code>encodeText</code> and <code>encodeBinary</code> pick the smallest <code>encodeText</code> and <code>encodeBinary</code> pick the smallest
version and optimal segment modes for you, then hand back an immutable version and optimal segment modes for you, then hand back an immutable
<code>QrCode</code> grid. <code>QrCode</code> grid.
</p> </p>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-(--fg)">Render-agnostic output</h3> <h3 class="text-sm font-semibold text-fg">Render-agnostic output</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)"> <p class="mt-1.5 text-sm text-fg-muted">
A <code>QrCode</code> is just a square of modules. Read each one with A <code>QrCode</code> is just a square of modules. Read each one with
<code>getModule(x, y)</code> and draw to SVG, canvas, or anything else <code>getModule(x, y)</code> and draw to SVG, canvas, or anything else
no rendering opinions baked in. no rendering opinions baked in.
</p> </p>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-(--fg)">Standalone Reed-Solomon</h3> <h3 class="text-sm font-semibold text-fg">Standalone Reed-Solomon</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)"> <p class="mt-1.5 text-sm text-fg-muted">
The GF(2^8) error-correction core <code>multiply</code>, The GF(2^8) error-correction core <code>multiply</code>,
<code>computeDivisor</code>, <code>computeRemainder</code> is exported <code>computeDivisor</code>, <code>computeRemainder</code> is exported
on its own, reusable beyond QR. on its own, reusable beyond QR.
</p> </p>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-(--fg)">Zero dependencies, fully typed</h3> <h3 class="text-sm font-semibold text-fg">Zero dependencies, fully typed</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)"> <p class="mt-1.5 text-sm text-fg-muted">
Tree-shakeable ESM and CJS builds with no third-party runtime deps, hot Tree-shakeable ESM and CJS builds with no third-party runtime deps, hot
loops backed by typed arrays, and end-to-end TypeScript types. loops backed by typed arrays, and end-to-end TypeScript types.
</p> </p>
+2 -2
View File
@@ -1,4 +1,4 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint'; import { base, compose, imports, stylistic, tests, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic, { export default compose(base, typescript, imports, stylistic, {
name: 'encoding/overrides', name: 'encoding/overrides',
@@ -10,4 +10,4 @@ export default compose(base, typescript, imports, stylistic, {
oldest register's seed/last write is intentionally dead — keep symmetry. */ oldest register's seed/last write is intentionally dead — keep symmetry. */
'no-useless-assignment': 'off', 'no-useless-assignment': 'off',
}, },
}); }, tests);
+2 -1
View File
@@ -13,11 +13,12 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "core/encoding" "directory": "core/encoding"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.18.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
+1 -1
View File
@@ -15,7 +15,7 @@ const ASCII_ZERO = 0x30;
* luhn('4111 1111 1111 1111'); // true * luhn('4111 1111 1111 1111'); // true
* luhn('4111 1111 1111 1112'); // false * luhn('4111 1111 1111 1112'); // false
* *
* @since 0.0.2 * @since 0.0.1
*/ */
export function luhn(value: string): boolean { export function luhn(value: string): boolean {
const digits = value.replaceAll(NON_DIGIT, ''); const digits = value.replaceAll(NON_DIGIT, '');
+12 -12
View File
@@ -59,35 +59,35 @@ const billing = api.extend({ baseURL: 'https://billing.example.com' });`;
</div> </div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-(--fg)">Type-safe end to end</h3> <h3 class="text-sm font-semibold text-fg">Type-safe end to end</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)"> <p class="mt-1.5 text-sm text-fg-muted">
Response data, request options, and plugin-contributed fields are all inferred Response data, request options, and plugin-contributed fields are all inferred
the parsed body comes back typed, no casting required. the parsed body comes back typed, no casting required.
</p> </p>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-(--fg)">Smart bodies &amp; parsing</h3> <h3 class="text-sm font-semibold text-fg">Smart bodies &amp; parsing</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)"> <p class="mt-1.5 text-sm text-fg-muted">
Plain objects are JSON-serialized; <code>FormData</code>/<code>Blob</code>/streams Plain objects are JSON-serialized; <code>FormData</code>/<code>Blob</code>/streams
pass through untouched. Responses are decoded from <code>Content-Type</code> or pass through untouched. Responses are decoded from <code>Content-Type</code> or
forced via <code>responseType</code>. forced via <code>responseType</code>.
</p> </p>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-(--fg)">Retry, timeout &amp; errors</h3> <h3 class="text-sm font-semibold text-fg">Retry, timeout &amp; errors</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)"> <p class="mt-1.5 text-sm text-fg-muted">
Built-in retry and per-attempt timeout with sensible defaults, and non-2xx Built-in retry and per-attempt timeout with sensible defaults, and non-2xx
responses reject with a rich <code>FetchError</code> carrying status, request, responses reject with a rich <code>FetchError</code> carrying status, request,
and parsed body. and parsed body.
</p> </p>
</div> </div>
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5"> <div class="rounded-xl border border-border bg-bg-elevated p-5">
<h3 class="text-sm font-semibold text-(--fg)">Hooks &amp; plugins</h3> <h3 class="text-sm font-semibold text-fg">Hooks &amp; plugins</h3>
<p class="mt-1.5 text-sm text-(--fg-muted)"> <p class="mt-1.5 text-sm text-fg-muted">
Lifecycle hooks plus a typed, composable plugin system with onion-style Lifecycle hooks plus a typed, composable plugin system with onion-style
<code>execute</code> middleware composed once, with zero per-request overhead <code>execute</code> middleware composed once, with zero per-request overhead
beyond the hooks themselves. beyond the hooks themselves.
+2 -2
View File
@@ -1,3 +1,3 @@
import { base, compose, imports, stylistic, typescript } from '@robonen/eslint'; import { base, compose, imports, stylistic, tests, typescript } from '@robonen/eslint';
export default compose(base, typescript, imports, stylistic); export default compose(base, typescript, imports, stylistic, tests);
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/fetch", "name": "@robonen/fetch",
"version": "0.0.1", "version": "0.0.2",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "A lightweight, type-safe fetch wrapper with interceptors, retry, and V8-optimized internals", "description": "A lightweight, type-safe fetch wrapper with interceptors, retry, and V8-optimized internals",
"keywords": [ "keywords": [
@@ -15,11 +15,12 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "core/fetch" "directory": "core/fetch"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.18.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
+3 -3
View File
@@ -128,7 +128,7 @@ import type { FetchExecuteMiddleware, FetchHook, FetchHooks, FetchOptions, Fetch
* }); * });
* await billing('/invoices', { method: 'POST', body: { amount: 100 } }); * await billing('/invoices', { method: 'POST', body: { amount: 100 } });
* *
* @since 0.1.0 * @since 0.0.1
*/ */
export function definePlugin< export function definePlugin<
const Name extends string, const Name extends string,
@@ -228,7 +228,7 @@ function applyDefaults(
* Ordering: plugin defaults (in declaration order) → user defaults (user wins). * Ordering: plugin defaults (in declaration order) → user defaults (user wins).
* Headers are merged independently through a single Headers instance. * Headers are merged independently through a single Headers instance.
* *
* @since 0.1.0 * @since 0.0.1
*/ */
export function composePlugins( export function composePlugins(
plugins: readonly FetchPlugin[] | undefined, plugins: readonly FetchPlugin[] | undefined,
@@ -331,7 +331,7 @@ function composeExecute(middlewares: readonly FetchExecuteMiddleware[]): FetchEx
* @description Runs all instance-level (plugin) hooks for a single phase, then the * @description Runs all instance-level (plugin) hooks for a single phase, then the
* optional user per-request hook(s). Avoids allocating an intermediate array per call. * optional user per-request hook(s). Avoids allocating an intermediate array per call.
* *
* @since 0.1.0 * @since 0.0.1
*/ */
export async function runHookPhase<C>( export async function runHookPhase<C>(
instance: ReadonlyArray<FetchHook<C>> | undefined, instance: ReadonlyArray<FetchHook<C>> | undefined,
+1 -1
View File
@@ -44,7 +44,7 @@ function shouldRetryStatus(options: ResolvedFetchOptions, status: number): boole
* *
* Auto-registered by `createFetch`; disable per-request via `retry: false`. * Auto-registered by `createFetch`; disable per-request via `retry: false`.
* *
* @since 0.1.0 * @since 0.0.1
*/ */
export function retryPlugin() { export function retryPlugin() {
return definePlugin({ return definePlugin({
+1 -1
View File
@@ -20,7 +20,7 @@ const baseSignals = new WeakMap<object, AbortSignal | undefined>();
* *
* Auto-registered by `createFetch`; no-op when `timeout` is unset. * Auto-registered by `createFetch`; no-op when `timeout` is unset.
* *
* @since 0.1.0 * @since 0.0.1
*/ */
export function timeoutPlugin() { export function timeoutPlugin() {
return definePlugin({ return definePlugin({
+40
View File
@@ -0,0 +1,40 @@
import { assertType, describe, expectTypeOf, it } from 'vitest';
import type { FetchOptions } from './types';
import { createFetch } from './fetch';
type Body = FetchOptions['body'];
describe('FetchOptions body', () => {
it('accepts a named interface without a cast', () => {
// The regression under test: a named `interface` has no implicit index
// signature, so a `Record<string, unknown>` body type would reject it and
// force a cast at every call site. It must assign directly.
interface CreateUser { name: string; age: number }
expectTypeOf<CreateUser>().toExtend<Body>();
assertType<Body>({ name: 'Alice', age: 30 } satisfies CreateUser);
});
it('accepts plain objects, arrays, BodyInit strings and null', () => {
assertType<Body>({ a: 1 });
assertType<Body>([1, 2, 3]);
assertType<Body>('raw string');
assertType<Body>(new FormData());
assertType<Body>(null);
});
it('rejects bare primitives', () => {
// @ts-expect-error a bare number is not a valid request body
assertType<Body>(42);
// @ts-expect-error a bare boolean is not a valid request body
assertType<Body>(true);
});
it('lets a typed interface flow through the method shortcuts', () => {
interface CreateDeal { title: string; amount: number }
const $fetch = createFetch();
const deal: CreateDeal = { title: 'x', amount: 1 };
// Must type-check with no cast on the body.
expectTypeOf($fetch.post).toBeCallableWith('/deals', { body: deal });
});
});
+8 -2
View File
@@ -79,8 +79,14 @@ export interface FetchOptions<R extends ResponseType = 'json', T = unknown>
FetchHooks<T, R> { FetchHooks<T, R> {
/** Base URL prepended to all relative request URLs */ /** Base URL prepended to all relative request URLs */
baseURL?: string; baseURL?: string;
/** Request body — plain objects are automatically JSON-serialized */ /**
body?: RequestInit['body'] | Record<string, unknown> | unknown[] | null; * Request body. `BodyInit` values (string, Blob, FormData, streams, …) are
* sent as-is; any other object or array is JSON-serialized. Typed as `object`
* rather than `Record<string, unknown>` so a named `interface` assigns
* directly — interfaces carry no implicit index signature and would otherwise
* force every caller to cast the body.
*/
body?: RequestInit['body'] | object | null;
/** Suppress throwing on 4xx/5xx responses */ /** Suppress throwing on 4xx/5xx responses */
ignoreResponseError?: boolean; ignoreResponseError?: boolean;
/** URL query parameters serialized and appended to the request URL */ /** URL query parameters serialized and appended to the request URL */
+6
View File
@@ -3,5 +3,11 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { test: {
environment: 'node', environment: 'node',
// Type tests (*.test-d.ts) are statically analyzed with `tsc --noEmit`
// alongside the runtime suite.
typecheck: {
enabled: true,
tsconfig: './tsconfig.src.json',
},
}, },
}); });
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/platform", "name": "@robonen/platform",
"version": "0.0.4", "version": "0.0.5",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Platform dependent utilities for javascript development", "description": "Platform dependent utilities for javascript development",
"keywords": [ "keywords": [
@@ -18,11 +18,12 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "packages/platform" "directory": "packages/platform"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.18.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
+2 -2
View File
@@ -5,7 +5,7 @@
* @category Multi * @category Multi
* @description Global object that works in any environment * @description Global object that works in any environment
* *
* @since 0.0.1 * @since 0.0.2
*/ */
export const _global export const _global
= typeof globalThis !== 'undefined' = typeof globalThis !== 'undefined'
@@ -23,6 +23,6 @@ export const _global
* @category Multi * @category Multi
* @description Check if the current environment is the client * @description Check if the current environment is the client
* *
* @since 0.0.1 * @since 0.0.2
*/ */
export const isClient = typeof window !== 'undefined' && typeof document !== 'undefined'; export const isClient = typeof window !== 'undefined' && typeof document !== 'undefined';
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/stdlib", "name": "@robonen/stdlib",
"version": "0.0.9", "version": "0.0.12",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "A collection of tools, utilities, and helpers for TypeScript", "description": "A collection of tools, utilities, and helpers for TypeScript",
"keywords": [ "keywords": [
@@ -18,11 +18,12 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "packages/stdlib" "directory": "packages/stdlib"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.18.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
@@ -51,6 +52,6 @@
"@robonen/tsdown": "workspace:*", "@robonen/tsdown": "workspace:*",
"eslint": "catalog:", "eslint": "catalog:",
"tsdown": "catalog:", "tsdown": "catalog:",
"typescript": "^6.0.3" "typescript": "catalog:"
} }
} }
@@ -21,4 +21,28 @@ describe('createMachine', () => {
it('send returns the (typed) resulting state', () => { it('send returns the (typed) resulting state', () => {
expectTypeOf(machine.send('START')).toEqualTypeOf<'idle' | 'running'>(); expectTypeOf(machine.send('START')).toEqualTypeOf<'idle' | 'running'>();
}); });
it('empty terminal nodes do not widen the event union to string', () => {
const terminal = createMachine({
initial: 'idle',
states: {
idle: { on: { START: 'done' } },
done: {},
},
});
expectTypeOf(terminal.send).parameter(0).toEqualTypeOf<'START'>();
});
it('entry/exit-only nodes do not widen the event union either', () => {
const hooked = createMachine({
initial: 'idle',
states: {
idle: { on: { START: 'done' } },
done: { entry: () => {} },
},
});
expectTypeOf(hooked.send).parameter(0).toEqualTypeOf<'START'>();
});
}); });
@@ -418,6 +418,7 @@ describe('asyncStateMachine', () => {
}, },
}); });
// @ts-expect-error -- deliberately undeclared event: runtime must ignore it
const result = await machine.send('STOP'); const result = await machine.send('STOP');
expect(result).toBe('idle'); expect(result).toBe('idle');
@@ -597,6 +598,7 @@ describe('asyncStateMachine', () => {
}, },
}); });
// @ts-expect-error -- deliberately undeclared event: can() must report false
expect(await machine.can('STOP')).toBe(false); expect(await machine.can('STOP')).toBe(false);
}); });
@@ -57,8 +57,12 @@ export type AsyncStateNodeConfig<Context> = StateNodeConfig<Context, MaybePromis
export type ExtractStates<T> = keyof T & string; export type ExtractStates<T> = keyof T & string;
// `on` is matched as REQUIRED here on purpose: an empty terminal node (`{}`)
// satisfies an optional-`on` pattern with no inference candidate, so `infer E`
// would fall back to its constraint and collapse the whole union to `string`,
// silently accepting any event name in `send`/`can`.
export type ExtractEvents<T> = { export type ExtractEvents<T> = {
[K in keyof T]: T[K] extends { readonly on?: Readonly<Record<infer E extends string, unknown>> } [K in keyof T]: T[K] extends { readonly on: Readonly<Record<infer E extends string, unknown>> }
? E ? E
: never; : never;
}[keyof T]; }[keyof T];
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { FenwickTree } from '.';
/** Deterministic LCG so failures reproduce. */
function lcg(seed: number): () => number {
let state = seed;
return () => {
state = (state * 48271) % 2147483647;
return state / 2147483647;
};
}
describe('FenwickTree', () => {
it('should match naive sums after build', () => {
const rand = lcg(1);
const values = Array.from({ length: 137 }, () => Math.floor(rand() * 100));
const tree = new FenwickTree(values.length);
tree.build(values);
let sum = 0;
for (let i = 0; i <= values.length; i++) {
expect(tree.prefix(i)).toBe(sum);
if (i < values.length)
sum += values[i]!;
}
});
it('should keep prefix sums consistent with a naive array under updates', () => {
const rand = lcg(2);
const length = 64;
const naive = Array.from({ length }, () => Math.floor(rand() * 50));
const tree = new FenwickTree(length);
tree.build(naive);
for (let op = 0; op < 500; op++) {
const index = Math.floor(rand() * length);
const delta = Math.floor(rand() * 40) - 20;
naive[index]! += delta;
tree.update(index, delta);
const probe = Math.floor(rand() * (length + 1));
const expected = naive.slice(0, probe).reduce((a, b) => a + b, 0);
expect(tree.prefix(probe)).toBe(expected);
}
});
it('should match a linear scan in lowerBound, including stride and zero values', () => {
const rand = lcg(3);
for (let round = 0; round < 20; round++) {
const length = 1 + Math.floor(rand() * 40);
const values = Array.from({ length }, () => rand() < 0.2 ? 0 : Math.floor(rand() * 60));
const stride = round % 3 === 0 ? 0 : Math.floor(rand() * 10);
const tree = new FenwickTree(length);
tree.build(values);
const total = values.reduce((a, b) => a + b, 0) + length * stride;
for (const target of [-5, 0, 1, total / 3, total / 2, total - 1, total, total + 100]) {
let expected = 0;
for (let c = 0; c <= length; c++) {
const g = values.slice(0, c).reduce((a, b) => a + b, 0) + c * stride;
if (g <= target)
expected = c;
else
break;
}
if (target < 0)
expected = 0;
expect(tree.lowerBound(target, stride), `length=${length} stride=${stride} target=${target}`).toBe(expected);
}
}
});
it('should handle an empty tree', () => {
const tree = new FenwickTree(0);
expect(tree.prefix(0)).toBe(0);
expect(tree.lowerBound(0)).toBe(0);
expect(tree.lowerBound(100)).toBe(0);
});
it('should rebuild in place via build', () => {
const tree = new FenwickTree(4);
tree.build([1, 2, 3, 4]);
expect(tree.prefix(4)).toBe(10);
tree.build([10, 10, 10, 10]);
expect(tree.prefix(2)).toBe(20);
expect(tree.prefix(4)).toBe(40);
});
});
@@ -0,0 +1,95 @@
/**
* @name FenwickTree
* @category Data Structures
* @description Fenwick (Binary Indexed) tree over an array of non-negative
* numbers: O(log n) prefix sums, point updates, and monotonic lower-bound
* search, plus O(n) bulk rebuild. `lowerBound` assumes all values are
* non-negative (the prefix function must be non-decreasing)
*
* @example
* const tree = new FenwickTree(5);
* tree.build([10, 20, 30, 40, 50]);
* tree.prefix(3); // 60
* tree.update(1, 5); // value at index 1 becomes 25
* tree.lowerBound(65); // 3 — largest c with prefix(c) <= 65
*
* @since 0.0.11
*/
export class FenwickTree {
readonly size: number;
private readonly tree: Float64Array;
private readonly highBit: number;
constructor(size: number) {
this.size = size;
this.tree = new Float64Array(size + 1);
this.highBit = size > 0 ? 1 << (31 - Math.clz32(size)) : 0;
}
/**
* Bulk (re)initialization from raw values, O(n)
*
* @param {ArrayLike<number>} values The values to load, `values.length` must equal `size`
*/
build(values: ArrayLike<number>): void {
const { tree, size } = this;
tree.fill(0);
for (let i = 1; i <= size; i++) {
tree[i]! += values[i - 1]!;
const parent = i + (i & -i);
if (parent <= size)
tree[parent]! += tree[i]!;
}
}
/**
* Add `delta` to the value at `index`, O(log n)
*
* @param {number} index Zero-based index of the value to change
* @param {number} delta Amount to add (may be negative)
*/
update(index: number, delta: number): void {
for (let i = index + 1; i <= this.size; i += i & -i)
this.tree[i]! += delta;
}
/**
* Sum of the first `count` values, O(log n)
*
* @param {number} count How many leading values to sum
* @returns {number} The prefix sum
*/
prefix(count: number): number {
let sum = 0;
for (let i = count; i > 0; i -= i & -i)
sum += this.tree[i]!;
return sum;
}
/**
* Largest `c` in `[0, size]` with `prefix(c) + c * stride <= target`, O(log n).
* `stride` models a constant per-item addition (e.g. a layout gap) without
* storing it in the tree
*
* @param {number} target The offset to search for
* @param {number} stride Constant added per item, defaults to `0`
* @returns {number} The largest count whose strided prefix does not exceed `target`
*/
lowerBound(target: number, stride = 0): number {
if (target < 0)
return 0;
let pos = 0;
let sum = 0;
for (let step = this.highBit; step > 0; step >>= 1) {
const next = pos + step;
if (next <= this.size) {
const candidate = sum + this.tree[next]!;
if (candidate + next * stride <= target) {
pos = next;
sum = candidate;
}
}
}
return pos;
}
}
+1
View File
@@ -1,6 +1,7 @@
export * from './BinaryHeap'; export * from './BinaryHeap';
export * from './CircularBuffer'; export * from './CircularBuffer';
export * from './Deque'; export * from './Deque';
export * from './FenwickTree';
export * from './LinkedList'; export * from './LinkedList';
export * from './PriorityQueue'; export * from './PriorityQueue';
export * from './Queue'; export * from './Queue';
+75
View File
@@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
const { data: home } = await useAsyncData(() => queryCollection('renovate').first());
useSeoMeta({
title: home.value?.title,
description: home.value?.description,
});
</script>
<template>
<ContentRenderer v-if="home" :value="home" />
<div v-else>Home not found</div>
</template>
+99 -34
View File
@@ -16,48 +16,113 @@
--radius-card: 0.5rem; --radius-card: 0.5rem;
} }
/* Semantic colour utilities
Register the runtime theme tokens as Tailwind colours so templates use clean
utilities (`bg-bg`, `text-fg`, `border-border`, `ring-ring`, `bg-accent`)
instead of the `bg-(--bg)` arbitrary-value escape hatch. `inline` makes each
utility emit `var(--token)` directly, so it stays switchable by the `.dark`
override below AND gains opacity modifiers (`bg-bg/80` color-mix). The raw
`--token`s remain the single source of truth (consumed directly via `var()`
in the prose/identity CSS); these are thin aliases over them. */
@theme inline {
--color-bg: var(--bg);
--color-bg-subtle: var(--bg-subtle);
--color-bg-elevated: var(--bg-elevated);
--color-bg-inset: var(--bg-inset);
--color-border: var(--border);
--color-border-strong: var(--border-strong);
--color-fg: var(--fg);
--color-fg-muted: var(--fg-muted);
--color-fg-subtle: var(--fg-subtle);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-fg: var(--accent-fg);
--color-accent-subtle: var(--accent-subtle);
--color-accent-text: var(--accent-text);
--color-header-bg: var(--header-bg);
--color-ring: var(--ring);
}
/* Demo design-system shortcuts
The package demo.vue files share a small visual vocabulary: a width-capped
vertical shell, a code-comment eyebrow label, button/badge chrome, inputs,
and card surfaces. These were copy-pasted as identical Tailwind strings
across ~240 demos. Collapsed here into semantic utilities so the look is
tuned in one place. Each is the common CORE of its pattern per-demo extras
(max-width, padding, disabled states, w-full, sizes) stay on the element, so
the rendered result is unchanged. */
@utility demo-stack {
@apply flex w-full flex-col gap-4;
}
@utility demo-label {
@apply text-xs font-medium uppercase tracking-wide text-fg-subtle;
}
@utility demo-card {
@apply rounded-xl border border-border bg-bg-elevated;
}
@utility demo-btn {
@apply inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-border bg-bg-elevated px-3 py-1.5 text-sm font-medium text-fg transition hover:bg-bg-inset hover:border-border-strong active:scale-[0.98];
}
@utility demo-btn-primary {
@apply inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-transparent bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition hover:bg-accent-hover active:scale-[0.98];
}
@utility demo-badge {
@apply inline-flex items-center gap-1.5 rounded-md border border-border bg-bg-inset px-2 py-0.5 text-xs font-medium text-fg-muted;
}
@utility demo-input {
@apply w-full rounded-lg border border-border bg-bg px-3 py-2 text-sm text-fg transition placeholder:text-fg-subtle focus:border-accent focus:outline-none focus:ring-2 focus:ring-ring;
}
@utility demo-stat {
@apply font-mono font-bold tabular-nums text-fg;
}
/* Semantic design tokens ink on warm paper, signal-orange instruments /* Semantic design tokens ink on warm paper, signal-orange instruments
The site reads like a tool-maker's field manual: warm neutral surfaces, The site reads like a tool-maker's field manual: warm neutral surfaces,
hairline rules, international-orange accents, code-comment labels. */ hairline rules, international-orange accents, code-comment labels. */
:root { :root {
--bg: #faf8f3; /* Colours are OKLCH (perceptually uniform even lightness steps, predictable
--bg-subtle: #f4f1e8; hue) and are exact equivalents of the original hand-tuned sRGB palette.
--bg-elevated: #fffdf8; Translucent tokens derive from their base via color-mix(), so they track
--bg-inset: #eeeadf; theme + accent retuning automatically instead of duplicating a literal. */
--border: #e5dfd0; --bg: oklch(0.9793 0.007 88.64);
--border-strong: #cfc6b1; --bg-subtle: oklch(0.958 0.0124 91.52);
--fg: #211e18; --bg-elevated: oklch(0.9942 0.0069 88.64);
--fg-muted: #5d574b; --bg-inset: oklch(0.9371 0.0153 90.24);
--fg-subtle: #93897a; --border: oklch(0.9043 0.0211 88.73);
--accent: #d9480f; --border-strong: oklch(0.8282 0.0303 87.56);
--accent-hover: #bf3f0d; --fg: oklch(0.2363 0.012 84.56);
--accent-fg: #fffdf8; --fg-muted: oklch(0.4588 0.0204 84.58);
--accent-subtle: #f7e7d8; --fg-subtle: oklch(0.6346 0.0249 78.12);
--accent-text: #c2410c; --accent: oklch(0.5999 0.1905 37.88);
--header-bg: rgba(250, 248, 243, 0.82); --accent-hover: oklch(0.5461 0.1724 37.96);
--ring: rgba(217, 72, 15, 0.35); --accent-fg: oklch(0.9942 0.0069 88.64);
--shadow-card: 0 1px 2px rgba(56, 44, 28, 0.05), 0 1px 3px rgba(56, 44, 28, 0.07); --accent-subtle: oklch(0.9367 0.0266 65.68);
--accent-text: oklch(0.5534 0.1739 38.4);
--header-bg: color-mix(in oklch, var(--bg) 82%, transparent);
--ring: color-mix(in oklch, var(--accent) 35%, transparent);
--shadow-card: 0 1px 2px oklch(0.302 0.0319 74.11 / 0.05), 0 1px 3px oklch(0.302 0.0319 74.11 / 0.07);
color-scheme: light; color-scheme: light;
} }
.dark { .dark {
--bg: #161310; --bg: oklch(0.1892 0.0077 67.33);
--bg-subtle: #1b1813; --bg-subtle: oklch(0.2107 0.0106 80.56);
--bg-elevated: #211d17; --bg-elevated: oklch(0.2332 0.0127 78);
--bg-inset: #2a251c; --bg-inset: oklch(0.267 0.0176 82.2);
--border: #322c22; --border: oklch(0.2964 0.0194 80.44);
--border-strong: #4a4231; --border-strong: oklch(0.3822 0.0294 85.68);
--fg: #ece7db; --fg: oklch(0.9286 0.0169 88);
--fg-muted: #b2a995; --fg-muted: oklch(0.7369 0.0298 86.66);
--fg-subtle: #7d7363; --fg-subtle: oklch(0.56 0.0269 79.61);
--accent: #ff7d33; --accent: oklch(0.7294 0.1789 46.57);
--accent-hover: #ff9a59; --accent-hover: oklch(0.7788 0.1452 51.83);
--accent-fg: #1d0e04; --accent-fg: oklch(0.1825 0.0328 56.53);
--accent-subtle: #3a2415; --accent-subtle: oklch(0.284 0.042 54.49);
--accent-text: #ff9c63; --accent-text: oklch(0.7835 0.139 49.63);
--header-bg: rgba(22, 19, 16, 0.82); /* --header-bg is not re-declared: the :root color-mix tracks --bg, which we
--ring: rgba(255, 125, 51, 0.4); override above. Only --ring needs a tweak (slightly stronger in dark). */
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.5); --ring: color-mix(in oklch, var(--accent) 40%, transparent);
--shadow-card: 0 1px 2px oklch(0 0 0 / 0.4), 0 1px 3px oklch(0 0 0 / 0.5);
color-scheme: dark; color-scheme: dark;
} }
+2 -2
View File
@@ -22,8 +22,8 @@ const kindLabels: Record<string, string> = {
:class="[ :class="[
'inline-flex items-center justify-center rounded font-mono font-medium shrink-0 border', 'inline-flex items-center justify-center rounded font-mono font-medium shrink-0 border',
kind === 'component' kind === 'component'
? 'border-(--accent-subtle) bg-(--accent-subtle) text-(--accent-text)' ? 'border-accent-subtle bg-accent-subtle text-accent-text'
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)', : 'border-border bg-bg-inset text-fg-muted',
size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs', size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs',
]" ]"
:title="kind" :title="kind"
+5 -5
View File
@@ -39,12 +39,12 @@ async function copy() {
</script> </script>
<template> <template>
<div class="group relative rounded-xl border border-(--border) bg-(--bg-subtle) overflow-hidden max-w-full"> <div class="group relative rounded-xl border border-border bg-bg-subtle overflow-hidden max-w-full">
<div v-if="!bare" class="flex items-center justify-between px-3 h-9 border-b border-(--border) bg-(--bg-subtle)"> <div v-if="!bare" class="flex items-center justify-between px-3 h-9 border-b border-border bg-bg-subtle">
<span class="text-[11px] font-mono uppercase tracking-wider text-(--fg-subtle)">{{ langLabel }}</span> <span class="text-[11px] font-mono uppercase tracking-wider text-fg-subtle">{{ langLabel }}</span>
<button <button
type="button" type="button"
class="inline-flex items-center gap-1 text-[11px] font-medium text-(--fg-subtle) hover:text-(--fg) transition-colors cursor-pointer" class="inline-flex items-center gap-1 text-[11px] font-medium text-fg-subtle hover:text-fg transition-colors cursor-pointer"
@click="copy" @click="copy"
> >
<svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -59,7 +59,7 @@ async function copy() {
<button <button
v-else v-else
type="button" type="button"
class="absolute right-2 top-2 z-10 inline-flex items-center justify-center w-7 h-7 rounded-md bg-(--bg-elevated) border border-(--border) text-(--fg-subtle) opacity-0 group-hover:opacity-100 hover:text-(--fg) transition-all cursor-pointer" class="absolute right-2 top-2 z-10 inline-flex items-center justify-center w-7 h-7 rounded-md bg-bg-elevated border border-border text-fg-subtle opacity-0 group-hover:opacity-100 hover:text-fg transition-all cursor-pointer"
title="Copy" title="Copy"
@click="copy" @click="copy"
> >
+8 -8
View File
@@ -43,10 +43,10 @@ const roleColor: Record<string, string> = {
<div class="space-y-10"> <div class="space-y-10">
<!-- Anatomy snippet --> <!-- Anatomy snippet -->
<section> <section>
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-3"> <h2 class="text-xs font-semibold uppercase tracking-wider text-fg-subtle mb-3">
Anatomy Anatomy
</h2> </h2>
<p class="text-sm text-(--fg-muted) mb-3"> <p class="text-sm text-fg-muted mb-3">
Import the parts and compose them. Each part forwards attributes to its underlying element. Import the parts and compose them. Each part forwards attributes to its underlying element.
</p> </p>
<DocsCode :code="anatomyCode" lang="vue" /> <DocsCode :code="anatomyCode" lang="vue" />
@@ -54,7 +54,7 @@ const roleColor: Record<string, string> = {
<!-- Parts --> <!-- Parts -->
<section> <section>
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4"> <h2 class="text-xs font-semibold uppercase tracking-wider text-fg-subtle mb-4">
API Reference API Reference
</h2> </h2>
<div class="space-y-8"> <div class="space-y-8">
@@ -65,18 +65,18 @@ const roleColor: Record<string, string> = {
class="scroll-mt-20" class="scroll-mt-20"
> >
<div class="flex items-center gap-2.5 mb-2"> <div class="flex items-center gap-2.5 mb-2">
<h3 class="font-mono text-base font-semibold text-(--fg)">{{ part.name }}</h3> <h3 class="font-mono text-base font-semibold text-fg">{{ part.name }}</h3>
<span <span
:class="[ :class="[
'text-[11px] px-2 py-0.5 rounded-full font-medium leading-none', 'text-[11px] px-2 py-0.5 rounded-full font-medium leading-none',
roleColor[part.role] ?? 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)', roleColor[part.role] ?? 'bg-bg-inset text-fg-muted border border-border',
]" ]"
> >
{{ part.role }} {{ part.role }}
</span> </span>
</div> </div>
<p v-if="part.description" class="text-sm text-(--fg-muted) mb-3 max-w-2xl"> <p v-if="part.description" class="text-sm text-fg-muted mb-3 max-w-2xl">
{{ part.description }} {{ part.description }}
</p> </p>
@@ -85,11 +85,11 @@ const roleColor: Record<string, string> = {
</div> </div>
<div v-if="part.emits.length > 0" class="mb-3"> <div v-if="part.emits.length > 0" class="mb-3">
<div class="text-[11px] font-semibold uppercase tracking-wider text-(--fg-subtle) mb-2">Emits</div> <div class="text-[11px] font-semibold uppercase tracking-wider text-fg-subtle mb-2">Emits</div>
<DocsEmitsTable :emits="part.emits" /> <DocsEmitsTable :emits="part.emits" />
</div> </div>
<p v-if="part.props.length === 0 && part.emits.length === 0" class="text-sm text-(--fg-subtle) italic"> <p v-if="part.props.length === 0 && part.emits.length === 0" class="text-sm text-fg-subtle italic">
No props or events renders its element and forwards attributes. No props or events renders its element and forwards attributes.
</p> </p>
</div> </div>
+6 -6
View File
@@ -24,14 +24,14 @@ watch(showSource, async (show) => {
</script> </script>
<template> <template>
<div class="rounded-xl border border-(--border) overflow-hidden"> <div class="rounded-xl border border-border overflow-hidden">
<!-- Live demo client-only: demos are interactive and use browser APIs, <!-- Live demo client-only: demos are interactive and use browser APIs,
so they must not be instantiated during SSR/prerender. --> so they must not be instantiated during SSR/prerender. -->
<div class="p-4 sm:p-8 bg-(--bg-subtle) flex items-center justify-center min-h-32"> <div class="p-4 sm:p-8 bg-bg-subtle flex items-center justify-center min-h-32">
<ClientOnly> <ClientOnly>
<component :is="component" /> <component :is="component" />
<template #fallback> <template #fallback>
<div class="flex items-center gap-2 text-sm text-(--fg-subtle)"> <div class="flex items-center gap-2 text-sm text-fg-subtle">
<svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg class="animate-spin" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" /> <path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg> </svg>
@@ -42,10 +42,10 @@ watch(showSource, async (show) => {
</div> </div>
<!-- Source toggle bar --> <!-- Source toggle bar -->
<div class="flex items-center border-t border-(--border) bg-(--bg-elevated)"> <div class="flex items-center border-t border-border bg-bg-elevated">
<button <button
type="button" type="button"
class="flex items-center gap-1.5 px-4 py-2.5 text-xs font-medium text-(--fg-muted) hover:text-(--fg) transition-colors cursor-pointer" class="flex items-center gap-1.5 px-4 py-2.5 text-xs font-medium text-fg-muted hover:text-fg transition-colors cursor-pointer"
@click="showSource = !showSource" @click="showSource = !showSource"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -62,7 +62,7 @@ watch(showSource, async (show) => {
</div> </div>
<!-- Source code --> <!-- Source code -->
<div v-if="showSource" class="border-t border-(--border) bg-(--bg-subtle)"> <div v-if="showSource" class="border-t border-border bg-bg-subtle">
<div class="overflow-x-auto text-[13px] [&_pre]:p-4 [&_pre]:m-0 [&_pre]:bg-transparent!" v-html="highlighted" /> <div class="overflow-x-auto text-[13px] [&_pre]:p-4 [&_pre]:m-0 [&_pre]:bg-transparent!" v-html="highlighted" />
</div> </div>
</div> </div>
+7 -7
View File
@@ -6,21 +6,21 @@ defineProps<{
</script> </script>
<template> <template>
<div v-if="emits.length > 0" class="overflow-x-auto rounded-xl border border-(--border)"> <div v-if="emits.length > 0" class="overflow-x-auto rounded-xl border border-border">
<table class="w-full text-sm border-collapse"> <table class="w-full text-sm border-collapse">
<thead> <thead>
<tr class="bg-(--bg-subtle) text-left"> <tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Event</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Event</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Payload</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Payload</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="e in emits" :key="e.name" class="border-t border-(--border) align-top"> <tr v-for="e in emits" :key="e.name" class="border-t border-border align-top">
<td class="py-2.5 px-4 whitespace-nowrap"> <td class="py-2.5 px-4 whitespace-nowrap">
<code class="text-(--accent-text) font-mono text-[13px] font-medium">{{ e.name }}</code> <code class="text-accent-text font-mono text-[13px] font-medium">{{ e.name }}</code>
</td> </td>
<td class="py-2.5 px-4"> <td class="py-2.5 px-4">
<code class="text-xs font-mono text-(--fg-muted) bg-(--bg-inset) px-1.5 py-0.5 rounded border border-(--border) wrap-break-word">{{ e.payload }}</code> <code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ e.payload }}</code>
</td> </td>
</tr> </tr>
</tbody> </tbody>
+1 -1
View File
@@ -28,7 +28,7 @@ async function highlightCodeBlocks() {
try { try {
const out = await highlight(text, resolved); const out = await highlight(text, resolved);
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.className = 'not-prose rounded-xl border border-(--border) bg-(--bg-subtle) overflow-x-auto text-[13px] my-5 [&_pre]:p-4 [&_pre]:m-0 [&_pre]:bg-transparent!'; wrapper.className = 'not-prose rounded-xl border border-border bg-bg-subtle overflow-x-auto text-[13px] my-5 [&_pre]:p-4 [&_pre]:m-0 [&_pre]:bg-transparent!';
wrapper.innerHTML = out; wrapper.innerHTML = out;
pre.replaceWith(wrapper); pre.replaceWith(wrapper);
} }
+7 -7
View File
@@ -10,19 +10,19 @@ defineProps<{
<div <div
v-for="method in methods" v-for="method in methods"
:key="method.name" :key="method.name"
class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4" class="rounded-xl border border-border bg-bg-subtle p-4"
> >
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<code class="text-sm font-mono font-semibold text-(--fg)">{{ method.name }}</code> <code class="text-sm font-mono font-semibold text-fg">{{ method.name }}</code>
<span <span
v-if="method.visibility !== 'public'" v-if="method.visibility !== 'public'"
class="text-[10px] uppercase px-1.5 py-0.5 rounded bg-(--bg-inset) border border-(--border) text-(--fg-subtle)" class="text-[10px] uppercase px-1.5 py-0.5 rounded bg-bg-inset border border-border text-fg-subtle"
> >
{{ method.visibility }} {{ method.visibility }}
</span> </span>
</div> </div>
<p v-if="method.description" class="text-sm text-(--fg-muted) mb-3"> <p v-if="method.description" class="text-sm text-fg-muted mb-3">
<DocsText :text="method.description" /> <DocsText :text="method.description" />
</p> </p>
@@ -36,9 +36,9 @@ defineProps<{
<DocsParamsTable v-if="method.params.length > 0" :params="method.params" /> <DocsParamsTable v-if="method.params.length > 0" :params="method.params" />
<div v-if="method.returns" class="mt-2 text-sm"> <div v-if="method.returns" class="mt-2 text-sm">
<span class="text-(--fg-subtle)">Returns</span> <span class="text-fg-subtle">Returns</span>
<code class="ml-1.5 text-xs font-mono bg-(--bg-inset) border border-(--border) px-1.5 py-0.5 rounded">{{ method.returns.type }}</code> <code class="ml-1.5 text-xs font-mono bg-bg-inset border border-border px-1.5 py-0.5 rounded">{{ method.returns.type }}</code>
<DocsText v-if="method.returns.description" :text="method.returns.description" class="ml-2 text-(--fg-muted)" /> <DocsText v-if="method.returns.description" :text="method.returns.description" class="ml-2 text-fg-muted" />
</div> </div>
</div> </div>
</div> </div>
+12 -12
View File
@@ -6,33 +6,33 @@ defineProps<{
</script> </script>
<template> <template>
<div v-if="params.length > 0" class="overflow-x-auto rounded-xl border border-(--border)"> <div v-if="params.length > 0" class="overflow-x-auto rounded-xl border border-border">
<table class="w-full text-sm border-collapse"> <table class="w-full text-sm border-collapse">
<thead> <thead>
<tr class="bg-(--bg-subtle) text-left"> <tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Parameter</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Parameter</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Type</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Type</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider hidden sm:table-cell">Default</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider hidden sm:table-cell">Default</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Description</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="param in params" v-for="param in params"
:key="param.name" :key="param.name"
class="border-t border-(--border) align-top" class="border-t border-border align-top"
> >
<td class="py-2.5 px-4 whitespace-nowrap"> <td class="py-2.5 px-4 whitespace-nowrap">
<code class="text-(--accent-text) font-mono text-[13px] font-medium">{{ param.name }}</code><span v-if="param.optional" class="text-(--fg-subtle) text-xs">?</span> <code class="text-accent-text font-mono text-[13px] font-medium">{{ param.name }}</code><span v-if="param.optional" class="text-fg-subtle text-xs">?</span>
</td> </td>
<td class="py-2.5 px-4"> <td class="py-2.5 px-4">
<code class="text-xs font-mono text-(--fg-muted) bg-(--bg-inset) px-1.5 py-0.5 rounded border border-(--border) wrap-break-word">{{ param.type }}</code> <code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ param.type }}</code>
</td> </td>
<td class="py-2.5 px-4 hidden sm:table-cell"> <td class="py-2.5 px-4 hidden sm:table-cell">
<code v-if="param.defaultValue" class="text-xs font-mono text-(--fg-muted)">{{ param.defaultValue }}</code> <code v-if="param.defaultValue" class="text-xs font-mono text-fg-muted">{{ param.defaultValue }}</code>
<span v-else class="text-(--fg-subtle)"></span> <span v-else class="text-fg-subtle"></span>
</td> </td>
<td class="py-2.5 px-4 text-(--fg-muted) min-w-48"> <td class="py-2.5 px-4 text-fg-muted min-w-48">
<DocsText v-if="param.description" :text="param.description" /> <DocsText v-if="param.description" :text="param.description" />
<span v-else></span> <span v-else></span>
</td> </td>
+13 -13
View File
@@ -8,34 +8,34 @@ defineProps<{
</script> </script>
<template> <template>
<div v-if="properties.length > 0" class="overflow-x-auto rounded-xl border border-(--border)"> <div v-if="properties.length > 0" class="overflow-x-auto rounded-xl border border-border">
<table class="w-full text-sm border-collapse"> <table class="w-full text-sm border-collapse">
<thead> <thead>
<tr class="bg-(--bg-subtle) text-left"> <tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">{{ label ?? 'Property' }}</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">{{ label ?? 'Property' }}</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Type</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Type</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider hidden sm:table-cell">Default</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider hidden sm:table-cell">Default</th>
<th class="py-2.5 px-4 font-medium text-(--fg-muted) text-xs uppercase tracking-wider">Description</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="prop in properties" v-for="prop in properties"
:key="prop.name" :key="prop.name"
class="border-t border-(--border) align-top" class="border-t border-border align-top"
> >
<td class="py-2.5 px-4 whitespace-nowrap"> <td class="py-2.5 px-4 whitespace-nowrap">
<code class="text-(--accent-text) font-mono text-[13px] font-medium">{{ prop.name }}</code><span v-if="prop.optional" class="text-(--fg-subtle) text-xs">?</span> <code class="text-accent-text font-mono text-[13px] font-medium">{{ prop.name }}</code><span v-if="prop.optional" class="text-fg-subtle text-xs">?</span>
<span v-if="prop.readonly" class="block text-[10px] text-(--fg-subtle) uppercase tracking-wide mt-0.5">readonly</span> <span v-if="prop.readonly" class="block text-[10px] text-fg-subtle uppercase tracking-wide mt-0.5">readonly</span>
</td> </td>
<td class="py-2.5 px-4"> <td class="py-2.5 px-4">
<code class="text-xs font-mono text-(--fg-muted) bg-(--bg-inset) px-1.5 py-0.5 rounded border border-(--border) wrap-break-word">{{ prop.type }}</code> <code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ prop.type }}</code>
</td> </td>
<td class="py-2.5 px-4 hidden sm:table-cell"> <td class="py-2.5 px-4 hidden sm:table-cell">
<code v-if="prop.defaultValue" class="text-xs font-mono text-(--fg-muted)">{{ prop.defaultValue }}</code> <code v-if="prop.defaultValue" class="text-xs font-mono text-fg-muted">{{ prop.defaultValue }}</code>
<span v-else class="text-(--fg-subtle)"></span> <span v-else class="text-fg-subtle"></span>
</td> </td>
<td class="py-2.5 px-4 text-(--fg-muted) min-w-48"> <td class="py-2.5 px-4 text-fg-muted min-w-48">
<DocsText v-if="prop.description" :text="prop.description" /> <DocsText v-if="prop.description" :text="prop.description" />
<span v-else></span> <span v-else></span>
</td> </td>
+12 -12
View File
@@ -65,14 +65,14 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
<div> <div>
<button <button
type="button" type="button"
class="flex items-center gap-2 px-2.5 h-9 text-sm text-(--fg-subtle) bg-(--bg-subtle) border border-(--border) rounded-lg hover:border-(--border-strong) transition-colors w-9 sm:w-56 justify-center sm:justify-start cursor-pointer" class="flex items-center gap-2 px-2.5 h-9 text-sm text-fg-subtle bg-bg-subtle border border-border rounded-lg hover:border-border-strong transition-colors w-9 sm:w-56 justify-center sm:justify-start cursor-pointer"
@click="open" @click="open"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" /> <circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg> </svg>
<span class="hidden sm:inline flex-1 text-left font-mono text-[13px]">search</span> <span class="hidden sm:inline flex-1 text-left font-mono text-[13px]">search</span>
<kbd class="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono bg-(--bg) border border-(--border) rounded text-(--fg-subtle)">K</kbd> <kbd class="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono bg-bg border border-border rounded text-fg-subtle">K</kbd>
</button> </button>
<Teleport to="body"> <Teleport to="body">
@@ -84,21 +84,21 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm" @click="close" /> <div class="fixed inset-0 bg-black/40 backdrop-blur-sm" @click="close" />
<div class="fixed inset-x-0 top-[12vh] mx-auto max-w-xl px-4"> <div class="fixed inset-x-0 top-[12vh] mx-auto max-w-xl px-4">
<div class="bg-(--bg-elevated) rounded-xl border border-(--border) shadow-2xl overflow-hidden"> <div class="bg-bg-elevated rounded-xl border border-border shadow-2xl overflow-hidden">
<div class="flex items-center px-4 border-b border-(--border)"> <div class="flex items-center px-4 border-b border-border">
<span class="font-mono text-base text-(--accent-text) select-none shrink-0"></span> <span class="font-mono text-base text-accent-text select-none shrink-0"></span>
<input <input
v-model="query" v-model="query"
data-search-input data-search-input
type="text" type="text"
placeholder="search across all packages…" placeholder="search across all packages…"
class="w-full py-3.5 px-3 bg-transparent text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none font-mono text-[14px]" class="w-full py-3.5 px-3 bg-transparent text-fg placeholder:text-fg-subtle focus:outline-none font-mono text-[14px]"
> >
<kbd class="hidden sm:inline-flex items-center px-1.5 py-0.5 text-[10px] font-mono bg-(--bg-inset) border border-(--border) rounded text-(--fg-subtle)">ESC</kbd> <kbd class="hidden sm:inline-flex items-center px-1.5 py-0.5 text-[10px] font-mono bg-bg-inset border border-border rounded text-fg-subtle">ESC</kbd>
</div> </div>
<div class="max-h-[60vh] overflow-y-auto p-2"> <div class="max-h-[60vh] overflow-y-auto p-2">
<div v-if="query && results.length === 0" class="py-12 text-center text-sm text-(--fg-subtle)"> <div v-if="query && results.length === 0" class="py-12 text-center text-sm text-fg-subtle">
No results for "{{ query }}" No results for "{{ query }}"
</div> </div>
<ul v-else-if="results.length > 0" class="space-y-0.5"> <ul v-else-if="results.length > 0" class="space-y-0.5">
@@ -107,20 +107,20 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
:to="`/${r.pkg.slug}/${r.slug}`" :to="`/${r.pkg.slug}/${r.slug}`"
:class="[ :class="[
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors', 'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
i === activeIndex ? 'bg-(--accent-subtle)' : 'hover:bg-(--bg-inset)', i === activeIndex ? 'bg-accent-subtle' : 'hover:bg-bg-inset',
]" ]"
@click="close" @click="close"
@mouseenter="activeIndex = i" @mouseenter="activeIndex = i"
> >
<DocsBadge :kind="r.badge" size="sm" /> <DocsBadge :kind="r.badge" size="sm" />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="text-sm font-medium text-(--fg) truncate">{{ r.name }}</div> <div class="text-sm font-medium text-fg truncate">{{ r.name }}</div>
<div class="text-xs text-(--fg-subtle) truncate">{{ r.pkg.name }} · {{ r.description }}</div> <div class="text-xs text-fg-subtle truncate">{{ r.pkg.name }} · {{ r.description }}</div>
</div> </div>
</NuxtLink> </NuxtLink>
</li> </li>
</ul> </ul>
<div v-else class="py-12 text-center text-sm text-(--fg-subtle)"> <div v-else class="py-12 text-center text-sm text-fg-subtle">
Type to search functions, components &amp; guides Type to search functions, components &amp; guides
</div> </div>
</div> </div>
+3 -3
View File
@@ -4,10 +4,10 @@
}>(); }>();
const variantClasses: Record<string, string> = { const variantClasses: Record<string, string> = {
since: 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)', since: 'bg-bg-inset text-fg-muted border border-border',
neutral: 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)', neutral: 'bg-bg-inset text-fg-muted border border-border',
test: 'bg-emerald-50 text-emerald-800 border border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:border-emerald-500/20', test: 'bg-emerald-50 text-emerald-800 border border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:border-emerald-500/20',
demo: 'bg-(--accent-subtle) text-(--accent-text) border border-(--accent-subtle)', demo: 'bg-accent-subtle text-accent-text border border-accent-subtle',
wip: 'bg-amber-50 text-amber-800 border border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/20', wip: 'bg-amber-50 text-amber-800 border border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/20',
}; };
</script> </script>
+1 -1
View File
@@ -12,7 +12,7 @@ const label = computed(() => ({
type="button" type="button"
:title="`Theme: ${label} (click to change)`" :title="`Theme: ${label} (click to change)`"
:aria-label="`Theme: ${label}`" :aria-label="`Theme: ${label}`"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset) transition-colors cursor-pointer" class="inline-flex items-center justify-center w-9 h-9 rounded-lg text-fg-muted hover:text-fg hover:bg-bg-inset transition-colors cursor-pointer"
@click="cycle" @click="cycle"
> >
<ClientOnly> <ClientOnly>
+3 -3
View File
@@ -49,7 +49,7 @@ function go(id: string) {
<div class="comment-label mb-3"> <div class="comment-label mb-3">
on this page on this page
</div> </div>
<ul class="space-y-1 border-l border-(--border)"> <ul class="space-y-1 border-l border-border">
<li v-for="item in items" :key="item.id"> <li v-for="item in items" :key="item.id">
<a <a
:href="`#${item.id}`" :href="`#${item.id}`"
@@ -57,8 +57,8 @@ function go(id: string) {
'block py-1 -ml-px border-l-2 transition-colors', 'block py-1 -ml-px border-l-2 transition-colors',
item.depth === 3 ? 'pl-6' : 'pl-4', item.depth === 3 ? 'pl-6' : 'pl-4',
activeId === item.id activeId === item.id
? 'border-(--accent) text-(--accent-text) font-medium' ? 'border-accent text-accent-text font-medium'
: 'border-transparent text-(--fg-muted) hover:text-(--fg)', : 'border-transparent text-fg-muted hover:text-fg',
]" ]"
@click.prevent="go(item.id)" @click.prevent="go(item.id)"
> >
+46
View File
@@ -35,6 +35,28 @@ const GROUP_LABELS: Record<PackageGroup, string> = {
const GROUP_ORDER: PackageGroup[] = ['core', 'vue', 'configs', 'infra']; const GROUP_ORDER: PackageGroup[] = ['core', 'vue', 'configs', 'infra'];
/** Display order for component categories (unlisted categories sort last, AZ). */
const COMPONENT_CATEGORY_ORDER: string[] = [
'Forms',
'Selection',
'Color',
'Overlays',
'Menus',
'Disclosure',
'Navigation',
'Display',
'Feedback',
'Canvas & editors',
'Utilities',
'Other',
];
/** A category bucket of components, for grouped rendering. */
export interface ComponentGroup {
name: string;
components: ComponentMeta[];
}
export function useDocs() { export function useDocs() {
const data = metadata as unknown as DocsMetadata; const data = metadata as unknown as DocsMetadata;
@@ -74,6 +96,29 @@ export function useDocs() {
return pkg.docs.filter(s => !s.isIntro); return pkg.docs.filter(s => !s.isIntro);
} }
/**
* A `components`-kind package's components bucketed by `category`, ordered by
* {@link COMPONENT_CATEGORY_ORDER} (unlisted categories last, AZ), with the
* components inside each bucket kept in their incoming (alphabetical) order.
*/
function getComponentGroups(pkg: PackageMeta): ComponentGroup[] {
if (pkg.kind !== 'components') return [];
const buckets = new Map<string, ComponentMeta[]>();
for (const c of pkg.components) {
const cat = c.category || 'Other';
const list = buckets.get(cat);
if (list) list.push(c);
else buckets.set(cat, [c]);
}
const rank = (name: string) => {
const i = COMPONENT_CATEGORY_ORDER.indexOf(name);
return i === -1 ? COMPONENT_CATEGORY_ORDER.length : i;
};
return [...buckets.entries()]
.map(([name, components]) => ({ name, components }))
.sort((a, b) => rank(a.name) - rank(b.name) || a.name.localeCompare(b.name));
}
/** Resolve any `/:package/:slug` route to a normalised entry. */ /** Resolve any `/:package/:slug` route to a normalised entry. */
function resolveEntry(packageSlug: string, slug: string): DocEntry | undefined { function resolveEntry(packageSlug: string, slug: string): DocEntry | undefined {
const pkg = getPackage(packageSlug); const pkg = getPackage(packageSlug);
@@ -157,6 +202,7 @@ export function useDocs() {
firstEntrySlug, firstEntrySlug,
getIntro, getIntro,
getDocSections, getDocSections,
getComponentGroups,
search, search,
getTotalItems, getTotalItems,
}; };
+37 -32
View File
@@ -1,4 +1,4 @@
<script setup lang="ts">const { getGroupedPackages, getPackage, getIntro, getDocSections } = useDocs(); <script setup lang="ts">const { getGroupedPackages, getPackage, getIntro, getDocSections, getComponentGroups } = useDocs();
const groups = getGroupedPackages(); const groups = getGroupedPackages();
const route = useRoute(); const route = useRoute();
@@ -79,11 +79,11 @@ watch(() => route.path, () => {
<template> <template>
<div class="min-h-screen"> <div class="min-h-screen">
<!-- Header --> <!-- Header -->
<header class="sticky top-0 z-50 border-b border-(--border) backdrop-blur-md" style="background-color: var(--header-bg)"> <header class="sticky top-0 z-50 border-b border-border backdrop-blur-md" style="background-color: var(--header-bg)">
<div class="mx-auto max-w-352 flex items-center gap-3 px-4 h-14 sm:px-6"> <div class="mx-auto max-w-352 flex items-center gap-3 px-4 h-14 sm:px-6">
<button <button
type="button" type="button"
class="lg:hidden inline-flex items-center justify-center w-9 h-9 -ml-1.5 rounded-lg text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)" class="lg:hidden inline-flex items-center justify-center w-9 h-9 -ml-1.5 rounded-lg text-fg-muted hover:text-fg hover:bg-bg-inset"
aria-label="Toggle navigation" aria-label="Toggle navigation"
@click="isSidebarOpen = !isSidebarOpen" @click="isSidebarOpen = !isSidebarOpen"
> >
@@ -93,12 +93,12 @@ watch(() => route.path, () => {
</button> </button>
<NuxtLink to="/" class="group flex items-center gap-2.5 mr-auto"> <NuxtLink to="/" class="group flex items-center gap-2.5 mr-auto">
<span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-(--accent) text-(--accent-fg) font-mono text-[13px] font-semibold leading-none select-none"> <span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-accent text-accent-fg font-mono text-[13px] font-semibold leading-none select-none">
</span> </span>
<span class="hidden sm:flex items-baseline font-mono text-[13.5px] tracking-tight"> <span class="hidden sm:flex items-baseline font-mono text-[13.5px] tracking-tight">
<span class="text-(--fg-subtle)">~/</span><span class="text-(--fg) font-medium">robonen</span><span class="text-(--fg-subtle)">/</span><span class="text-(--accent-text) font-medium">tools</span> <span class="text-fg-subtle">~/</span><span class="text-fg font-medium">robonen</span><span class="text-fg-subtle">/</span><span class="text-accent-text font-medium">tools</span>
<span class="ml-1 inline-block w-1.75 h-3.75 translate-y-0.5 bg-(--accent) opacity-0 group-hover:opacity-80 group-hover:animate-pulse" /> <span class="ml-1 inline-block w-1.75 h-3.75 translate-y-0.5 bg-accent opacity-0 group-hover:opacity-80 group-hover:animate-pulse" />
</span> </span>
</NuxtLink> </NuxtLink>
@@ -108,7 +108,7 @@ watch(() => route.path, () => {
href="https://github.com/robonen/tools" href="https://github.com/robonen/tools"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="inline-flex items-center justify-center w-9 h-9 rounded-lg text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset) transition-colors" class="inline-flex items-center justify-center w-9 h-9 rounded-lg text-fg-muted hover:text-fg hover:bg-bg-inset transition-colors"
aria-label="GitHub" aria-label="GitHub"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" width="19" height="19" viewBox="0 0 24 24" fill="currentColor">
@@ -122,7 +122,7 @@ watch(() => route.path, () => {
<!-- Sidebar --> <!-- Sidebar -->
<aside <aside
:class="[ :class="[
'fixed inset-y-0 left-0 z-40 w-72 bg-(--bg) border-r border-(--border) pt-14 transform transition-transform lg:sticky lg:top-14 lg:z-auto lg:h-[calc(100vh-3.5rem)] lg:w-64 lg:shrink-0 lg:translate-x-0 lg:pt-0 lg:border-r-0 lg:bg-transparent', 'fixed inset-y-0 left-0 z-40 w-72 bg-bg border-r border-border pt-14 transform transition-transform lg:sticky lg:top-14 lg:z-auto lg:h-[calc(100vh-3.5rem)] lg:w-64 lg:shrink-0 lg:translate-x-0 lg:pt-0 lg:border-r-0 lg:bg-transparent',
isSidebarOpen ? 'translate-x-0' : '-translate-x-full', isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
]" ]"
> >
@@ -136,24 +136,24 @@ watch(() => route.path, () => {
:class="[ :class="[
'flex items-center justify-between py-1.5 px-2 rounded-md text-sm transition-colors', 'flex items-center justify-between py-1.5 px-2 rounded-md text-sm transition-colors',
currentPackageSlug === pkg.slug currentPackageSlug === pkg.slug
? 'text-(--fg) font-medium bg-(--bg-inset)' ? 'text-fg font-medium bg-bg-inset'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)', : 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]" ]"
> >
<span class="font-mono text-[13px]">{{ pkg.name.replace('@robonen/', '') }}</span> <span class="font-mono text-[13px]">{{ pkg.name.replace('@robonen/', '') }}</span>
<span class="text-[10px] font-mono text-(--fg-subtle)">{{ pkg.kind === 'api' ? 'api' : pkg.kind === 'components' ? 'ui' : 'guide' }}</span> <span class="text-[10px] font-mono text-fg-subtle">{{ pkg.kind === 'api' ? 'api' : pkg.kind === 'components' ? 'ui' : 'guide' }}</span>
</NuxtLink> </NuxtLink>
<!-- Expanded tree for the current package --> <!-- Expanded tree for the current package -->
<div v-if="currentPackageSlug === pkg.slug && currentPackage" class="mt-1.5 mb-3 ml-2.5 pl-2.5 border-l border-(--border)"> <div v-if="currentPackageSlug === pkg.slug && currentPackage" class="mt-1.5 mb-3 ml-2.5 pl-2.5 border-l border-border">
<!-- Quick filter the tree below collapses to matches --> <!-- Quick filter the tree below collapses to matches -->
<div v-if="currentPackage.kind === 'api'" class="relative mb-2 mt-1"> <div v-if="currentPackage.kind === 'api'" class="relative mb-2 mt-1">
<span class="absolute left-2 top-1/2 -translate-y-1/2 font-mono text-[11px] text-(--accent-text) select-none"></span> <span class="absolute left-2 top-1/2 -translate-y-1/2 font-mono text-[11px] text-accent-text select-none"></span>
<input <input
v-model="navQuery" v-model="navQuery"
type="text" type="text"
placeholder="filter…" placeholder="filter…"
class="w-full h-7 pl-6 pr-2 font-mono text-[12px] rounded-md bg-(--bg-subtle) border border-(--border) text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none focus:border-(--border-strong) transition-colors" class="w-full h-7 pl-6 pr-2 font-mono text-[12px] rounded-md bg-bg-subtle border border-border text-fg placeholder:text-fg-subtle focus:outline-none focus:border-border-strong transition-colors"
> >
</div> </div>
@@ -167,8 +167,8 @@ watch(() => route.path, () => {
:class="[ :class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate', 'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
route.path === `/${pkg.slug}` route.path === `/${pkg.slug}`
? 'text-(--accent-text) font-medium' ? 'text-accent-text font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)', : 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]" ]"
> >
Introduction Introduction
@@ -180,8 +180,8 @@ watch(() => route.path, () => {
:class="[ :class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate', 'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
isActive(pkg.slug, s.slug) isActive(pkg.slug, s.slug)
? 'text-(--accent-text) font-medium' ? 'text-accent-text font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)', : 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]" ]"
> >
{{ s.title }} {{ s.title }}
@@ -192,7 +192,7 @@ watch(() => route.path, () => {
<!-- api: collapsible categories --> <!-- api: collapsible categories -->
<template v-if="currentPackage.kind === 'api'"> <template v-if="currentPackage.kind === 'api'">
<div v-if="navQuery && visibleCategories.length === 0" class="py-2 px-1 font-mono text-[11px] text-(--fg-subtle)"> <div v-if="navQuery && visibleCategories.length === 0" class="py-2 px-1 font-mono text-[11px] text-fg-subtle">
no matches no matches
</div> </div>
@@ -206,14 +206,14 @@ watch(() => route.path, () => {
xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
:class="[ :class="[
'shrink-0 text-(--fg-subtle) transition-transform duration-150', 'shrink-0 text-fg-subtle transition-transform duration-150',
isCategoryOpen(cat.slug) ? 'rotate-90' : '', isCategoryOpen(cat.slug) ? 'rotate-90' : '',
]" ]"
> >
<polyline points="9 18 15 12 9 6" /> <polyline points="9 18 15 12 9 6" />
</svg> </svg>
<span class="comment-label group-hover/cat:text-(--fg-muted) transition-colors">{{ cat.name.toLowerCase() }}</span> <span class="comment-label group-hover/cat:text-fg-muted transition-colors">{{ cat.name.toLowerCase() }}</span>
<span class="ml-auto font-mono text-[10px] text-(--fg-subtle) tabular-nums">{{ cat.items.length }}</span> <span class="ml-auto font-mono text-[10px] text-fg-subtle tabular-nums">{{ cat.items.length }}</span>
</button> </button>
<ul v-if="isCategoryOpen(cat.slug)" class="mb-1.5"> <ul v-if="isCategoryOpen(cat.slug)" class="mb-1.5">
@@ -223,14 +223,14 @@ watch(() => route.path, () => {
:class="[ :class="[
'flex items-center gap-1.5 py-0.75 px-2 text-[13px] rounded-md font-mono transition-colors', 'flex items-center gap-1.5 py-0.75 px-2 text-[13px] rounded-md font-mono transition-colors',
isActive(pkg.slug, item.slug) isActive(pkg.slug, item.slug)
? 'text-(--accent-text) font-medium' ? 'text-accent-text font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)', : 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]" ]"
> >
<span <span
:class="[ :class="[
'shrink-0 text-[10px] select-none transition-opacity', 'shrink-0 text-[10px] select-none transition-opacity',
isActive(pkg.slug, item.slug) ? 'opacity-100 text-(--accent-text)' : 'opacity-0', isActive(pkg.slug, item.slug) ? 'opacity-100 text-accent-text' : 'opacity-0',
]" ]"
></span> ></span>
<span class="truncate">{{ item.name }}</span> <span class="truncate">{{ item.name }}</span>
@@ -240,22 +240,27 @@ watch(() => route.path, () => {
</div> </div>
</template> </template>
<!-- components --> <!-- components: grouped by functional category -->
<ul v-else-if="currentPackage.kind === 'components'"> <template v-else-if="currentPackage.kind === 'components'">
<li v-for="c in currentPackage.components" :key="c.slug"> <div v-for="group in getComponentGroups(currentPackage)" :key="group.name" class="mb-2">
<div class="comment-label py-1 px-1">{{ group.name.toLowerCase() }}</div>
<ul>
<li v-for="c in group.components" :key="c.slug">
<NuxtLink <NuxtLink
:to="`/${pkg.slug}/${c.slug}`" :to="`/${pkg.slug}/${c.slug}`"
:class="[ :class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate', 'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
isActive(pkg.slug, c.slug) isActive(pkg.slug, c.slug)
? 'text-(--accent-text) font-medium' ? 'text-accent-text font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)', : 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]" ]"
> >
{{ c.name }} {{ c.name }}
</NuxtLink> </NuxtLink>
</li> </li>
</ul> </ul>
</div>
</template>
<!-- guide --> <!-- guide -->
<ul v-else> <ul v-else>
@@ -265,8 +270,8 @@ watch(() => route.path, () => {
:class="[ :class="[
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate', 'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
isActive(pkg.slug, s.slug) isActive(pkg.slug, s.slug)
? 'text-(--accent-text) font-medium' ? 'text-accent-text font-medium'
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)', : 'text-fg-muted hover:text-fg hover:bg-bg-inset',
]" ]"
> >
{{ s.title }} {{ s.title }}
+18 -18
View File
@@ -105,10 +105,10 @@ const sectionTitle = 'comment-label mb-3';
<div v-if="entry" class="xl:grid xl:grid-cols-[minmax(0,1fr)_14rem] xl:gap-12"> <div v-if="entry" class="xl:grid xl:grid-cols-[minmax(0,1fr)_14rem] xl:gap-12">
<article class="min-w-0 max-w-3xl"> <article class="min-w-0 max-w-3xl">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav class="flex items-center gap-1.5 font-mono text-[13px] text-(--fg-subtle) mb-6"> <nav class="flex items-center gap-1.5 font-mono text-[13px] text-fg-subtle mb-6">
<NuxtLink :to="`/${pkg.slug}`" class="hover:text-(--fg) transition-colors">{{ pkg.name }}</NuxtLink> <NuxtLink :to="`/${pkg.slug}`" class="hover:text-fg transition-colors">{{ pkg.name }}</NuxtLink>
<span>/</span> <span>/</span>
<span class="text-(--fg)">{{ title }}</span> <span class="text-fg">{{ title }}</span>
</nav> </nav>
<!-- API ITEM --> <!-- API ITEM -->
@@ -116,7 +116,7 @@ const sectionTitle = 'comment-label mb-3';
<header class="mb-8"> <header class="mb-8">
<div class="flex items-center gap-2.5 mb-2 flex-wrap"> <div class="flex items-center gap-2.5 mb-2 flex-wrap">
<DocsBadge :kind="entry.item.kind" size="md" /> <DocsBadge :kind="entry.item.kind" size="md" />
<h1 class="min-w-0 break-words text-[1.6rem] font-semibold font-mono tracking-tight text-(--fg)">{{ entry.item.name }}</h1> <h1 class="min-w-0 break-words text-[1.6rem] font-semibold font-mono tracking-tight text-fg">{{ entry.item.name }}</h1>
<DocsTag v-if="entry.item.since" :label="`v${entry.item.since}`" variant="neutral" /> <DocsTag v-if="entry.item.since" :label="`v${entry.item.since}`" variant="neutral" />
<DocsTag <DocsTag
v-if="entry.item.hasTests" v-if="entry.item.hasTests"
@@ -126,15 +126,15 @@ const sectionTitle = 'comment-label mb-3';
/> />
<DocsTag v-if="entry.item.hasDemo" label="demo" variant="demo" /> <DocsTag v-if="entry.item.hasDemo" label="demo" variant="demo" />
</div> </div>
<p v-if="entry.item.description" class="text-(--fg-muted) text-[15px] leading-relaxed"> <p v-if="entry.item.description" class="text-fg-muted text-[15px] leading-relaxed">
<DocsText :text="entry.item.description" /> <DocsText :text="entry.item.description" />
</p> </p>
<div class="flex items-center gap-4 mt-4 text-sm"> <div class="flex items-center gap-4 mt-4 text-sm">
<a :href="ghUrl(entry.item.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-(--fg-subtle) hover:text-(--fg) transition-colors"> <a :href="ghUrl(entry.item.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-fg-subtle hover:text-fg transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" /></svg> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" /></svg>
Source Source
</a> </a>
<a v-if="entry.item.hasTests" :href="ghUrl(entry.item.sourcePath).replace('index.ts', 'index.test.ts')" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-(--fg-subtle) hover:text-(--fg) transition-colors"> <a v-if="entry.item.hasTests" :href="ghUrl(entry.item.sourcePath).replace('index.ts', 'index.test.ts')" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-fg-subtle hover:text-fg transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" /><polyline points="14 2 14 8 20 8" /><path d="m9 15 2 2 4-4" /></svg> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" /><polyline points="14 2 14 8 20 8" /><path d="m9 15 2 2 4-4" /></svg>
Tests Tests
</a> </a>
@@ -164,9 +164,9 @@ const sectionTitle = 'comment-label mb-3';
<h2 :class="sectionTitle">Type Parameters</h2> <h2 :class="sectionTitle">Type Parameters</h2>
<div class="space-y-1.5"> <div class="space-y-1.5">
<div v-for="tp in entry.item.typeParams" :key="tp.name" class="flex items-baseline gap-2 text-sm flex-wrap"> <div v-for="tp in entry.item.typeParams" :key="tp.name" class="flex items-baseline gap-2 text-sm flex-wrap">
<code class="font-mono font-medium text-(--accent-text)">{{ tp.name }}</code> <code class="font-mono font-medium text-accent-text">{{ tp.name }}</code>
<span v-if="tp.constraint" class="text-(--fg-subtle)">extends <code class="font-mono text-xs">{{ tp.constraint }}</code></span> <span v-if="tp.constraint" class="text-fg-subtle">extends <code class="font-mono text-xs">{{ tp.constraint }}</code></span>
<span v-if="tp.default" class="text-(--fg-subtle)">= <code class="font-mono text-xs">{{ tp.default }}</code></span> <span v-if="tp.default" class="text-fg-subtle">= <code class="font-mono text-xs">{{ tp.default }}</code></span>
</div> </div>
</div> </div>
</section> </section>
@@ -179,8 +179,8 @@ const sectionTitle = 'comment-label mb-3';
<section v-if="entry.item.returns" id="returns" class="mb-8 scroll-mt-20"> <section v-if="entry.item.returns" id="returns" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Returns</h2> <h2 :class="sectionTitle">Returns</h2>
<div class="flex items-baseline gap-2 text-sm flex-wrap" :class="entry.item.returns.properties?.length ? 'mb-3' : ''"> <div class="flex items-baseline gap-2 text-sm flex-wrap" :class="entry.item.returns.properties?.length ? 'mb-3' : ''">
<code class="font-mono bg-(--bg-inset) border border-(--border) px-2 py-1 rounded text-xs wrap-break-word">{{ entry.item.returns.type }}</code> <code class="font-mono bg-bg-inset border border-border px-2 py-1 rounded text-xs wrap-break-word">{{ entry.item.returns.type }}</code>
<DocsText v-if="entry.item.returns.description" :text="entry.item.returns.description" class="text-(--fg-muted)" /> <DocsText v-if="entry.item.returns.description" :text="entry.item.returns.description" class="text-fg-muted" />
</div> </div>
<DocsPropsTable v-if="entry.item.returns.properties?.length" :properties="entry.item.returns.properties" /> <DocsPropsTable v-if="entry.item.returns.properties?.length" :properties="entry.item.returns.properties" />
</section> </section>
@@ -198,12 +198,12 @@ const sectionTitle = 'comment-label mb-3';
<section v-if="entry.item.relatedTypes?.length" id="related-types" class="mb-8 scroll-mt-20"> <section v-if="entry.item.relatedTypes?.length" id="related-types" class="mb-8 scroll-mt-20">
<h2 :class="sectionTitle">Related Types</h2> <h2 :class="sectionTitle">Related Types</h2>
<div class="space-y-4"> <div class="space-y-4">
<div v-for="rt in entry.item.relatedTypes" :key="rt.name" class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4"> <div v-for="rt in entry.item.relatedTypes" :key="rt.name" class="rounded-xl border border-border bg-bg-subtle p-4">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<DocsBadge :kind="rt.kind" size="sm" /> <DocsBadge :kind="rt.kind" size="sm" />
<h3 class="font-mono font-semibold text-sm text-(--fg)">{{ rt.name }}</h3> <h3 class="font-mono font-semibold text-sm text-fg">{{ rt.name }}</h3>
</div> </div>
<p v-if="rt.description" class="text-sm text-(--fg-muted) mb-3"> <p v-if="rt.description" class="text-sm text-fg-muted mb-3">
<DocsText :text="rt.description" /> <DocsText :text="rt.description" />
</p> </p>
<DocsCode v-if="rt.signatures.length" :code="rt.signatures[0]!" /> <DocsCode v-if="rt.signatures.length" :code="rt.signatures[0]!" />
@@ -218,14 +218,14 @@ const sectionTitle = 'comment-label mb-3';
<header class="mb-8"> <header class="mb-8">
<div class="flex items-center gap-2.5 mb-2 flex-wrap"> <div class="flex items-center gap-2.5 mb-2 flex-wrap">
<DocsBadge kind="component" size="md" /> <DocsBadge kind="component" size="md" />
<h1 class="font-display text-[1.7rem] font-bold tracking-tight text-(--fg)">{{ entry.component.name }}</h1> <h1 class="font-display text-[1.7rem] font-bold tracking-tight text-fg">{{ entry.component.name }}</h1>
<DocsTag :label="`${entry.component.parts.length} parts`" variant="neutral" /> <DocsTag :label="`${entry.component.parts.length} parts`" variant="neutral" />
</div> </div>
<p v-if="entry.component.description" class="text-(--fg-muted) text-[15px] leading-relaxed"> <p v-if="entry.component.description" class="text-fg-muted text-[15px] leading-relaxed">
<DocsText :text="entry.component.description" /> <DocsText :text="entry.component.description" />
</p> </p>
<div class="flex items-center gap-4 mt-4 text-sm"> <div class="flex items-center gap-4 mt-4 text-sm">
<a :href="ghUrl(entry.component.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-(--fg-subtle) hover:text-(--fg) transition-colors"> <a :href="ghUrl(entry.component.sourcePath)" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 text-fg-subtle hover:text-fg transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" /></svg> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" /><path d="M9 18c-4.51 2-5-2-7-2" /></svg>
Source Source
</a> </a>
+55 -27
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">import { sections } from '#docs/sections'; <script setup lang="ts">import { sections } from '#docs/sections';
const route = useRoute(); const route = useRoute();
const { getPackage, countEntries, getIntro } = useDocs(); const { getPackage, countEntries, getIntro, getComponentGroups } = useDocs();
const slug = computed(() => route.params.package as string); const slug = computed(() => route.params.package as string);
const pkg = computed(() => getPackage(slug.value)); const pkg = computed(() => getPackage(slug.value));
@@ -51,6 +51,15 @@ function scrollToCategory(catSlug: string) {
document.getElementById(`cat-${catSlug}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); document.getElementById(`cat-${catSlug}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
} }
// Components: bucketed by functional category
const componentGroups = computed(() =>
pkg.value?.kind === 'components' ? getComponentGroups(pkg.value) : [],
);
function scrollToComponentGroup(name: string) {
document.getElementById(`cgrp-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// For guide packages, surface the overview section inline. // For guide packages, surface the overview section inline.
const overview = computed(() => const overview = computed(() =>
pkg.value?.kind === 'guide' ? pkg.value.sections.find(s => s.slug === 'overview') : undefined, pkg.value?.kind === 'guide' ? pkg.value.sections.find(s => s.slug === 'overview') : undefined,
@@ -68,13 +77,13 @@ const otherSections = computed(() =>
</section> </section>
<!-- Auto header (shown only when there's no hand-authored intro) --> <!-- Auto header (shown only when there's no hand-authored intro) -->
<header v-else class="mb-8 pb-8 border-b border-(--border)"> <header v-else class="mb-8 pb-8 border-b border-border">
<div class="comment-label mb-3">{{ kindLabel.toLowerCase() }} · {{ countEntries(pkg) }} entries</div> <div class="comment-label mb-3">{{ kindLabel.toLowerCase() }} · {{ countEntries(pkg) }} entries</div>
<div class="flex items-center gap-2.5 mb-2 flex-wrap"> <div class="flex items-center gap-2.5 mb-2 flex-wrap">
<h1 class="font-display text-3xl font-bold tracking-tight text-(--fg)">{{ pkg.name }}</h1> <h1 class="font-display text-3xl font-bold tracking-tight text-fg">{{ pkg.name }}</h1>
<DocsTag :label="`v${pkg.version}`" variant="neutral" /> <DocsTag :label="`v${pkg.version}`" variant="neutral" />
</div> </div>
<p class="text-(--fg-muted) text-[15px] leading-relaxed">{{ pkg.description }}</p> <p class="text-fg-muted text-[15px] leading-relaxed">{{ pkg.description }}</p>
<div class="mt-5"> <div class="mt-5">
<DocsCode :code="`pnpm add ${pkg.name}`" lang="bash" /> <DocsCode :code="`pnpm add ${pkg.name}`" lang="bash" />
</div> </div>
@@ -84,14 +93,14 @@ const otherSections = computed(() =>
<template v-if="pkg.kind === 'api'"> <template v-if="pkg.kind === 'api'">
<div class="sticky top-14 z-20 -mx-2 px-2 py-3 backdrop-blur-md" style="background-color: var(--header-bg)"> <div class="sticky top-14 z-20 -mx-2 px-2 py-3 backdrop-blur-md" style="background-color: var(--header-bg)">
<div class="relative mb-2.5"> <div class="relative mb-2.5">
<span class="absolute left-3 top-1/2 -translate-y-1/2 font-mono text-sm text-(--accent-text) select-none"></span> <span class="absolute left-3 top-1/2 -translate-y-1/2 font-mono text-sm text-accent-text select-none"></span>
<input <input
v-model="query" v-model="query"
type="text" type="text"
:placeholder="`filter ${countEntries(pkg)} entries…`" :placeholder="`filter ${countEntries(pkg)} entries…`"
class="w-full h-10 pl-8 pr-16 font-mono text-sm rounded-md bg-(--bg-elevated) border border-(--border) text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none focus:border-(--accent) transition-colors" class="w-full h-10 pl-8 pr-16 font-mono text-sm rounded-md bg-bg-elevated border border-border text-fg placeholder:text-fg-subtle focus:outline-none focus:border-accent transition-colors"
> >
<span v-if="query" class="absolute right-3 top-1/2 -translate-y-1/2 font-mono text-[11px] text-(--fg-subtle) tabular-nums"> <span v-if="query" class="absolute right-3 top-1/2 -translate-y-1/2 font-mono text-[11px] text-fg-subtle tabular-nums">
{{ filteredCount }} hits {{ filteredCount }} hits
</span> </span>
</div> </div>
@@ -101,17 +110,17 @@ const otherSections = computed(() =>
v-for="category in filteredCategories" v-for="category in filteredCategories"
:key="category.slug" :key="category.slug"
type="button" type="button"
class="shrink-0 inline-flex items-center gap-1.5 h-6.5 px-2.5 font-mono text-[11px] rounded-full border border-(--border) bg-(--bg-elevated) text-(--fg-muted) hover:border-(--accent) hover:text-(--accent-text) transition-colors cursor-pointer" class="shrink-0 inline-flex items-center gap-1.5 h-6.5 px-2.5 font-mono text-[11px] rounded-full border border-border bg-bg-elevated text-fg-muted hover:border-accent hover:text-accent-text transition-colors cursor-pointer"
@click="scrollToCategory(category.slug)" @click="scrollToCategory(category.slug)"
> >
{{ category.name.toLowerCase() }} {{ category.name.toLowerCase() }}
<span class="text-(--fg-subtle) tabular-nums">{{ category.items.length }}</span> <span class="text-fg-subtle tabular-nums">{{ category.items.length }}</span>
</button> </button>
</div> </div>
</div> </div>
<div v-if="query && filteredCategories.length === 0" class="py-16 text-center"> <div v-if="query && filteredCategories.length === 0" class="py-16 text-center">
<div class="font-mono text-sm text-(--fg-subtle)">// no matches for "{{ query }}"</div> <div class="font-mono text-sm text-fg-subtle">// no matches for "{{ query }}"</div>
</div> </div>
<section <section
@@ -128,48 +137,67 @@ const otherSections = computed(() =>
v-for="item in category.items" v-for="item in category.items"
:key="item.slug" :key="item.slug"
:to="`/${pkg.slug}/${item.slug}`" :to="`/${pkg.slug}/${item.slug}`"
class="group flex items-start gap-2.5 p-3 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all" class="group flex items-start gap-2.5 p-3 rounded-card border border-border bg-bg-elevated hover:border-border-strong hover:shadow-(--shadow-card) transition-all"
> >
<DocsBadge :kind="item.kind" size="sm" /> <DocsBadge :kind="item.kind" size="sm" />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5 flex-wrap"> <div class="flex items-center gap-1.5 flex-wrap">
<span class="font-mono text-[13px] font-medium text-(--fg) group-hover:text-(--accent-text) transition-colors truncate">{{ item.name }}</span> <span class="font-mono text-[13px] font-medium text-fg group-hover:text-accent-text transition-colors truncate">{{ item.name }}</span>
<DocsTag v-if="item.hasDemo" label="demo" variant="demo" /> <DocsTag v-if="item.hasDemo" label="demo" variant="demo" />
</div> </div>
<p v-if="item.description" class="text-[12.5px] text-(--fg-subtle) mt-0.5 line-clamp-1">{{ item.description }}</p> <p v-if="item.description" class="text-[12.5px] text-fg-subtle mt-0.5 line-clamp-1">{{ item.description }}</p>
</div> </div>
</NuxtLink> </NuxtLink>
</div> </div>
</section> </section>
</template> </template>
<!-- Components: gallery --> <!-- Components: gallery grouped by functional category -->
<template v-else-if="pkg.kind === 'components'"> <template v-else-if="pkg.kind === 'components'">
<section> <!-- Category chips -->
<div class="mb-7 flex flex-wrap gap-1.5">
<button
v-for="group in componentGroups"
:key="group.name"
type="button"
class="font-mono text-[11px] px-2 py-1 rounded-md bg-bg-inset border border-border text-fg-muted hover:text-fg hover:border-border-strong transition-colors"
@click="scrollToComponentGroup(group.name)"
>
{{ group.name.toLowerCase() }}
<span class="text-fg-subtle tabular-nums">{{ group.components.length }}</span>
</button>
</div>
<section
v-for="group in componentGroups"
:id="`cgrp-${group.name}`"
:key="group.name"
class="mb-10 scroll-mt-24"
>
<h2 class="comment-label mb-4"> <h2 class="comment-label mb-4">
all components · {{ pkg.components.length }} {{ group.name.toLowerCase() }} · {{ group.components.length }}
</h2> </h2>
<div class="stagger grid grid-cols-1 gap-3 sm:grid-cols-2"> <div class="stagger grid grid-cols-1 gap-3 sm:grid-cols-2">
<NuxtLink <NuxtLink
v-for="c in pkg.components" v-for="c in group.components"
:key="c.slug" :key="c.slug"
:to="`/${pkg.slug}/${c.slug}`" :to="`/${pkg.slug}/${c.slug}`"
class="group block p-4 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all" class="group block p-4 rounded-card border border-border bg-bg-elevated hover:border-border-strong hover:shadow-(--shadow-card) transition-all"
> >
<div class="flex items-center justify-between gap-2 mb-1.5"> <div class="flex items-center justify-between gap-2 mb-1.5">
<span class="font-semibold text-(--fg) group-hover:text-(--accent-text) transition-colors">{{ c.name }}</span> <span class="font-semibold text-fg group-hover:text-accent-text transition-colors">{{ c.name }}</span>
<span class="font-mono text-[11px] text-(--fg-subtle) tabular-nums">{{ c.parts.length }} parts</span> <span class="font-mono text-[11px] text-fg-subtle tabular-nums">{{ c.parts.length }} parts</span>
</div> </div>
<p v-if="c.description" class="text-sm text-(--fg-subtle) line-clamp-2">{{ c.description }}</p> <p v-if="c.description" class="text-sm text-fg-subtle line-clamp-2">{{ c.description }}</p>
<div class="mt-3 flex flex-wrap gap-1"> <div class="mt-3 flex flex-wrap gap-1">
<span <span
v-for="part in c.parts.slice(0, 4)" v-for="part in c.parts.slice(0, 4)"
:key="part.name" :key="part.name"
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-(--bg-inset) border border-(--border) text-(--fg-subtle)" class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-bg-inset border border-border text-fg-subtle"
> >
{{ part.role }} {{ part.role }}
</span> </span>
<span v-if="c.parts.length > 4" class="text-[10px] font-mono text-(--fg-subtle) px-1">+{{ c.parts.length - 4 }}</span> <span v-if="c.parts.length > 4" class="text-[10px] font-mono text-fg-subtle px-1">+{{ c.parts.length - 4 }}</span>
</div> </div>
</NuxtLink> </NuxtLink>
</div> </div>
@@ -179,17 +207,17 @@ const otherSections = computed(() =>
<!-- Guide: overview markdown + section links --> <!-- Guide: overview markdown + section links -->
<template v-else> <template v-else>
<DocsMarkdown v-if="overview" :source="overview.markdown" /> <DocsMarkdown v-if="overview" :source="overview.markdown" />
<section v-if="otherSections.length > 0" class="mt-10 pt-8 border-t border-(--border)"> <section v-if="otherSections.length > 0" class="mt-10 pt-8 border-t border-border">
<h2 class="comment-label mb-4">sections</h2> <h2 class="comment-label mb-4">sections</h2>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<NuxtLink <NuxtLink
v-for="s in otherSections" v-for="s in otherSections"
:key="s.slug" :key="s.slug"
:to="`/${pkg.slug}/${s.slug}`" :to="`/${pkg.slug}/${s.slug}`"
class="group flex items-center justify-between gap-3 p-3.5 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:bg-(--bg-subtle) transition-all" class="group flex items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-bg-elevated hover:border-border-strong hover:bg-bg-subtle transition-all"
> >
<span class="text-sm font-medium text-(--fg) group-hover:text-(--accent-text) transition-colors">{{ s.title }}</span> <span class="text-sm font-medium text-fg group-hover:text-accent-text transition-colors">{{ s.title }}</span>
<span class="font-mono text-[11px] text-(--fg-subtle) group-hover:text-(--accent-text) transition-colors"></span> <span class="font-mono text-[11px] text-fg-subtle group-hover:text-accent-text transition-colors"></span>
</NuxtLink> </NuxtLink>
</div> </div>
</section> </section>
+17 -17
View File
@@ -20,21 +20,21 @@ useHead({ title: '@robonen/tools — Documentation' });
<div class="comment-label mb-5">field manual · generated from source &amp; jsdoc</div> <div class="comment-label mb-5">field manual · generated from source &amp; jsdoc</div>
<h1 class="font-display text-5xl sm:text-6xl font-bold tracking-tight text-(--fg) mb-5 text-balance"> <h1 class="font-display text-5xl sm:text-6xl font-bold tracking-tight text-fg mb-5 text-balance">
Tools, documented<span class="text-(--accent)">.</span> Tools, documented<span class="text-accent">.</span>
</h1> </h1>
<p class="text-lg text-(--fg-muted) leading-relaxed max-w-2xl"> <p class="text-lg text-fg-muted leading-relaxed max-w-2xl">
A monorepo of TypeScript utilities, Vue composables, headless UI primitives A monorepo of TypeScript utilities, Vue composables, headless UI primitives
and shared tooling typed, tested and demoed in place. and shared tooling typed, tested and demoed in place.
</p> </p>
<div class="mt-7 inline-flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[13px] text-(--fg-subtle) border border-(--border) rounded-md bg-(--bg-elevated) px-3 py-2"> <div class="mt-7 inline-flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[13px] text-fg-subtle border border-border rounded-md bg-bg-elevated px-3 py-2">
<span class="text-(--accent-text)"></span> <span class="text-accent-text"></span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ packages.length }}</span> packages</span> <span><span class="text-fg font-medium tabular-nums">{{ packages.length }}</span> packages</span>
<span class="text-(--border-strong)">·</span> <span class="text-border-strong">·</span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ totalItems }}</span> documented items</span> <span><span class="text-fg font-medium tabular-nums">{{ totalItems }}</span> documented items</span>
<span class="text-(--border-strong)">·</span> <span class="text-border-strong">·</span>
<span><span class="text-(--fg) font-medium tabular-nums">{{ groups.length }}</span> groups</span> <span><span class="text-fg font-medium tabular-nums">{{ groups.length }}</span> groups</span>
</div> </div>
</section> </section>
@@ -46,29 +46,29 @@ useHead({ title: '@robonen/tools — Documentation' });
v-for="pkg in grp.packages" v-for="pkg in grp.packages"
:key="pkg.slug" :key="pkg.slug"
:to="`/${pkg.slug}`" :to="`/${pkg.slug}`"
class="group relative block p-5 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all overflow-hidden" class="group relative block p-5 rounded-card border border-border bg-bg-elevated hover:border-border-strong hover:shadow-(--shadow-card) transition-all overflow-hidden"
> >
<!-- Corner notch fills in on hover like an indicator lamp --> <!-- Corner notch fills in on hover like an indicator lamp -->
<span <span
class="absolute right-0 top-0 w-2 h-2 bg-(--accent) opacity-0 group-hover:opacity-100 transition-opacity" class="absolute right-0 top-0 w-2 h-2 bg-accent opacity-0 group-hover:opacity-100 transition-opacity"
style="clip-path: polygon(100% 0, 0 0, 100% 100%)" style="clip-path: polygon(100% 0, 0 0, 100% 100%)"
aria-hidden="true" aria-hidden="true"
/> />
<div class="flex items-start justify-between gap-3 mb-2"> <div class="flex items-start justify-between gap-3 mb-2">
<h3 class="font-mono text-sm font-semibold text-(--fg) group-hover:text-(--accent-text) transition-colors"> <h3 class="font-mono text-sm font-semibold text-fg group-hover:text-accent-text transition-colors">
{{ pkg.name }} {{ pkg.name }}
</h3> </h3>
<span class="font-mono text-[10px] px-1.5 py-0.5 rounded border border-(--border) bg-(--bg-subtle) text-(--fg-subtle) leading-none shrink-0"> <span class="font-mono text-[10px] px-1.5 py-0.5 rounded border border-border bg-bg-subtle text-fg-subtle leading-none shrink-0">
{{ kindLabels[pkg.kind] }} {{ kindLabels[pkg.kind] }}
</span> </span>
</div> </div>
<p class="text-sm text-(--fg-muted) leading-relaxed line-clamp-2"> <p class="text-sm text-fg-muted leading-relaxed line-clamp-2">
{{ pkg.description }} {{ pkg.description }}
</p> </p>
<div class="mt-4 flex items-center gap-2 font-mono text-[11px] text-(--fg-subtle)"> <div class="mt-4 flex items-center gap-2 font-mono text-[11px] text-fg-subtle">
<span>v{{ pkg.version }}</span> <span>v{{ pkg.version }}</span>
<span class="text-(--border-strong)">·</span> <span class="text-border-strong">·</span>
<span class="tabular-nums">{{ countEntries(pkg) }} {{ pkg.kind === 'components' ? 'components' : pkg.kind === 'guide' ? 'sections' : 'items' }}</span> <span class="tabular-nums">{{ countEntries(pkg) }} {{ pkg.kind === 'components' ? 'components' : pkg.kind === 'guide' ? 'sections' : 'items' }}</span>
</div> </div>
</NuxtLink> </NuxtLink>
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
import { defineCollection, defineContentConfig } from '@nuxt/content';
const repositories = [
'../configs/tsconfig',
'../core/stdlib',
'../core/platform',
'../infra/renovate',
'../web/vue',
];
export default defineContentConfig({
collections: repositories.reduce((acc, repo) => {
const name = repo.split('/').pop();
acc[name] = defineCollection({
source: {
include: `**/*.md`,
exclude: ['**/node_modules/**', '**/dist/**'],
cwd: repo,
},
type: 'page',
});
return acc;
}, {}),
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { base, compose, imports, stylistic, typescript, vue } from '@robonen/eslint'; import { base, compose, imports, stylistic, tests, typescript, vue } from '@robonen/eslint';
export default compose(base, typescript, vue, imports, stylistic, { export default compose(base, typescript, vue, imports, stylistic, {
name: 'docs/build-scripts', name: 'docs/build-scripts',
@@ -7,4 +7,4 @@ export default compose(base, typescript, vue, imports, stylistic, {
/* Build-time tooling (doc extractor) logs progress to the console. */ /* Build-time tooling (doc extractor) logs progress to the console. */
'no-console': 'off', 'no-console': 'off',
}, },
}); }, tests);
+69 -23
View File
@@ -88,7 +88,7 @@ const PACKAGES: PackageConfig[] = [
{ path: 'core/crdt', slug: 'crdt', kind: 'api', group: 'core' }, { path: 'core/crdt', slug: 'crdt', kind: 'api', group: 'core' },
// ── vue ── // ── vue ──
{ path: 'vue/toolkit', slug: 'vue', kind: 'api', group: 'vue' }, { path: 'vue/toolkit', slug: 'vue', kind: 'api', group: 'vue' },
{ path: 'vue/editor', slug: 'editor', kind: 'api', group: 'vue' }, { path: 'vue/writekit', slug: 'writekit', kind: 'api', group: 'vue' },
{ path: 'vue/primitives', slug: 'primitives', kind: 'components', group: 'vue' }, { path: 'vue/primitives', slug: 'primitives', kind: 'components', group: 'vue' },
// ── configs ── // ── configs ──
{ path: 'configs/eslint', slug: 'eslint', kind: 'guide', group: 'configs', guideSources: ['README.md', 'rules/*.md'] }, { path: 'configs/eslint', slug: 'eslint', kind: 'guide', group: 'configs', guideSources: ['README.md', 'rules/*.md'] },
@@ -98,6 +98,27 @@ const PACKAGES: PackageConfig[] = [
{ path: 'infra/renovate', slug: 'renovate', kind: 'guide', group: 'infra', guideSources: ['README.md'] }, { path: 'infra/renovate', slug: 'renovate', kind: 'guide', group: 'infra', guideSources: ['README.md'] },
]; ];
/**
* Display label for each category FOLDER under `src/`. Components now live at
* `src/<category>/<component>/`, so the folder is the source of truth for a
* component's category. Unlisted folders fall back to `toPascalCase(folder)`.
* The display order of categories lives in `useDocs` (`COMPONENT_CATEGORY_ORDER`).
*/
const CATEGORY_LABELS: Record<string, string> = {
forms: 'Forms',
selection: 'Selection',
color: 'Color',
overlays: 'Overlays',
menus: 'Menus',
disclosure: 'Disclosure',
navigation: 'Navigation',
display: 'Display',
feedback: 'Feedback',
canvas: 'Canvas & editors',
utilities: 'Utilities',
internal: 'Internal',
};
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
function toKebabCase(str: string): string { function toKebabCase(str: string): string {
@@ -716,14 +737,14 @@ function inferCategoryFromItem(item: ItemMeta): string {
} }
/** Resolve a package's export subpaths to source entry files. */ /** Resolve a package's export subpaths to source entry files. */
function resolveEntryPoints(pkgDir: string, exportsField: Record<string, any>): Array<{ subpath: string; filePath: string }> { function resolveEntryPoints(pkgDir: string, exportsField: Record<string, unknown>): Array<{ subpath: string; filePath: string }> {
const entryPoints: Array<{ subpath: string; filePath: string }> = []; const entryPoints: Array<{ subpath: string; filePath: string }> = [];
for (const [subpath, value] of Object.entries(exportsField)) { for (const [subpath, value] of Object.entries(exportsField)) {
if (typeof value !== 'object' || value === null) continue; if (typeof value !== 'object' || value === null) continue;
let entry: any = (value as Record<string, any>).import ?? (value as Record<string, any>).types; let entry: unknown = (value as Record<string, unknown>).import ?? (value as Record<string, unknown>).types;
if (typeof entry === 'object' && entry !== null) entry = entry.types || entry.default; if (typeof entry === 'object' && entry !== null) entry = (entry as Record<string, unknown>).types || (entry as Record<string, unknown>).default;
if (!entry || typeof entry !== 'string') continue; if (!entry || typeof entry !== 'string') continue;
// Wildcard exports (e.g. "./*") can't be resolved to a single file here. // Wildcard exports (e.g. "./*") can't be resolved to a single file here.
if (entry.includes('*')) continue; if (entry.includes('*')) continue;
@@ -942,21 +963,16 @@ function roleFromName(componentName: string, base: string): string {
return role || 'Root'; return role || 'Root';
} }
function buildComponents(pkgDir: string): ComponentMeta[] { /**
const srcDir = resolve(pkgDir, 'src'); * Build a single component group from its directory, or `null` when the dir is
if (!existsSync(srcDir)) return []; * not a component group (no `.vue`). `category` is the display label; `entryPoint`
* is the package subpath (e.g. `./forms/checkbox`).
const components: ComponentMeta[] = []; */
function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string): ComponentMeta | null {
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dir = resolve(srcDir, entry.name);
// A component group is any dir that ships at least one .vue file. // A component group is any dir that ships at least one .vue file.
const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue')); const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue'));
if (vueFiles.length === 0) continue; if (vueFiles.length === 0) return null;
const slug = entry.name;
const base = toPascalCase(slug); const base = toPascalCase(slug);
// Anatomy = the PUBLIC parts exported from index.ts, in declared order. This // Anatomy = the PUBLIC parts exported from index.ts, in declared order. This
@@ -997,20 +1013,50 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
parts.push({ name, role, description, props, emits }); parts.push({ name, role, description, props, emits });
} }
const entryPoint = `./${slug}`; return {
const demoPath = resolve(dir, 'demo.vue');
const hasDemo = existsSync(demoPath);
components.push({
name: base, name: base,
slug, slug,
category,
description: groupDescription, description: groupDescription,
entryPoint, entryPoint,
parts, parts,
hasDemo, hasDemo: existsSync(resolve(dir, 'demo.vue')),
demoSource: '', // loaded lazily client-side via #docs/demo-sources demoSource: '', // loaded lazily client-side via #docs/demo-sources
sourcePath: relative(ROOT, dir), sourcePath: relative(ROOT, dir),
}); };
}
function buildComponents(pkgDir: string): ComponentMeta[] {
const srcDir = resolve(pkgDir, 'src');
if (!existsSync(srcDir)) return [];
const components: ComponentMeta[] = [];
// Components live one level deep, in category folders: src/<category>/<component>/.
// The category folder IS the source of truth for the component's category.
for (const catEntry of readdirSync(srcDir, { withFileTypes: true })) {
if (!catEntry.isDirectory()) continue;
const catDir = resolve(srcDir, catEntry.name);
const label = CATEGORY_LABELS[catEntry.name];
if (label) {
// A known category folder — each child dir is a component group.
for (const compEntry of readdirSync(catDir, { withFileTypes: true })) {
if (!compEntry.isDirectory()) continue;
const c = buildComponentAt(
resolve(catDir, compEntry.name),
compEntry.name,
label,
`./${catEntry.name}/${compEntry.name}`,
);
if (c) components.push(c);
}
}
else {
// Backward-compat: a flat component dir directly under src.
const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`);
if (c) components.push(c);
}
} }
return components.sort((a, b) => a.name.localeCompare(b.name)); return components.sort((a, b) => a.name.localeCompare(b.name));
+8 -2
View File
@@ -44,7 +44,7 @@ export default defineNuxtModule({
'@robonen/fetch': 'core/fetch/src', '@robonen/fetch': 'core/fetch/src',
'@robonen/encoding': 'core/encoding/src', '@robonen/encoding': 'core/encoding/src',
'@robonen/crdt': 'core/crdt/src', '@robonen/crdt': 'core/crdt/src',
'@robonen/editor': 'vue/editor/src', '@robonen/writekit': 'vue/writekit/src',
'@robonen/primitives': 'vue/primitives/src', '@robonen/primitives': 'vue/primitives/src',
'@robonen/vue': vueSrc, '@robonen/vue': vueSrc,
}; };
@@ -58,7 +58,13 @@ export default defineNuxtModule({
// Primitive `as="template"` / Slot path), silently blanking every demo // Primitive `as="template"` / Slot path), silently blanking every demo
// that hits it. `import.meta.env.DEV` resolves correctly in dev & prod. // that hits it. `import.meta.env.DEV` resolves correctly in dev & prod.
config.define ??= {}; config.define ??= {};
(config.define as Record<string, unknown>).__DEV__ ??= 'import.meta.env.DEV'; // Inline a STATIC boolean, not `import.meta.env.DEV`: a define value is
// inserted verbatim and is NOT re-scanned for Vite's `import.meta.env`
// replacement, so in a prod build it shipped a literal `import.meta.env.DEV`
// into chunks where `import.meta.env` is undefined at runtime →
// "Cannot read properties of undefined (reading 'DEV')". A literal
// true/false has no runtime dependency and tree-shakes the dev branches.
(config.define as Record<string, unknown>).__DEV__ ??= JSON.stringify(nuxt.options.dev);
const existing = config.resolve?.alias; const existing = config.resolve?.alias;
const sourceAliases = [ const sourceAliases = [
+2
View File
@@ -115,6 +115,8 @@ export interface ComponentMeta {
name: string; name: string;
/** URL-friendly slug, e.g. "accordion" */ /** URL-friendly slug, e.g. "accordion" */
slug: string; slug: string;
/** Functional category for grouping in the docs, e.g. "Forms", "Overlays". */
category: string;
/** Short description (from README heading or first JSDoc) */ /** Short description (from README heading or first JSDoc) */
description: string; description: string;
/** Subpath export, e.g. "./accordion" */ /** Subpath export, e.g. "./accordion" */
+7 -7
View File
@@ -159,15 +159,15 @@ describe('getPackage / resolveEntry', () => {
describe('slug uniqueness & collisions', () => { describe('slug uniqueness & collisions', () => {
// A function and a co-located type/interface whose names differ only in case // A function and a co-located type/interface whose names differ only in case
// both slugify to the same value — the real extractor produces these in // both slugify to the same value — the real extractor produces these in
// @robonen/editor and @robonen/vue. // @robonen/writekit and @robonen/vue.
const colliding: DocsMetadata = { const colliding: DocsMetadata = {
generatedAt: '2026-06-08T00:00:00.000Z', generatedAt: '2026-06-08T00:00:00.000Z',
packages: [ packages: [
{ {
name: '@robonen/editor', name: '@robonen/writekit',
version: '1.0.0', version: '1.0.0',
description: 'Editor', description: 'Writekit',
slug: 'editor', slug: 'writekit',
kind: 'api', kind: 'api',
group: 'vue', group: 'vue',
entryPoints: ['.'], entryPoints: ['.'],
@@ -197,12 +197,12 @@ describe('slug uniqueness & collisions', () => {
it('reaches both colliding symbols — function and interface — independently', () => { it('reaches both colliding symbols — function and interface — independently', () => {
const leaves = buildLeaves(colliding); const leaves = buildLeaves(colliding);
// Exact case-sensitive name disambiguates the function from the interface. // Exact case-sensitive name disambiguates the function from the interface.
const fn = resolveEntry(leaves, 'editor', 'position'); const fn = resolveEntry(leaves, 'writekit', 'position');
const iface = resolveEntry(leaves, 'editor', 'Position'); const iface = resolveEntry(leaves, 'writekit', 'Position');
expect(fn?.kind === 'api' && fn.item.kind).toBe('function'); expect(fn?.kind === 'api' && fn.item.kind).toBe('function');
expect(iface?.kind === 'api' && iface.item.kind).toBe('interface'); expect(iface?.kind === 'api' && iface.item.kind).toBe('interface');
// The disambiguated slug also resolves the interface directly. // The disambiguated slug also resolves the interface directly.
const bySlug = resolveEntry(leaves, 'editor', 'position-interface'); const bySlug = resolveEntry(leaves, 'writekit', 'position-interface');
expect(bySlug?.kind === 'api' && bySlug.item.kind).toBe('interface'); expect(bySlug?.kind === 'api' && bySlug.item.kind).toBe('interface');
}); });
+2
View File
@@ -20,6 +20,8 @@ export default defineNuxtConfig({
vite: { vite: {
plugins: [ plugins: [
// `as any`: @tailwindcss/vite and Nuxt resolve different `vite` versions, so
// their `Plugin` types are structurally identical but nominally incompatible.
tailwindcss() as any, tailwindcss() as any,
], ],
}, },
+10 -9
View File
@@ -10,28 +10,29 @@
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "vitest run", "test": "vitest run",
"dev": "nuxt dev", "dev": "nuxt dev",
"build": "nuxt build", "build:deps": "pnpm --filter @robonen/stdlib --filter @robonen/platform --filter @robonen/fetch --filter @robonen/encoding --filter @robonen/crdt --filter @robonen/vue --filter @robonen/primitives --filter @robonen/writekit build",
"generate": "nuxt generate", "build": "pnpm run build:deps && nuxt build",
"generate": "pnpm run build:deps && nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"extract": "jiti ./modules/extractor/extract.ts" "extract": "jiti ./modules/extractor/extract.ts"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.30.0",
"marked": "^18.0.5", "marked": "^18.0.7",
"shiki": "^4.2.0", "shiki": "^4.3.1",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/fonts": "^0.14.0", "@nuxt/fonts": "^0.14.0",
"@nuxt/kit": "^4.4.8", "@nuxt/kit": "^4.5.1",
"@robonen/eslint": "workspace:*", "@robonen/eslint": "workspace:*",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.3",
"eslint": "catalog:", "eslint": "catalog:",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"nuxt": "catalog:", "nuxt": "catalog:",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.3",
"ts-morph": "^28.0.0", "ts-morph": "^28.0.0",
"vue": "catalog:", "vue": "catalog:",
"vue-router": "^5.1.0" "vue-router": "^5.2.0"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+2
View File
@@ -0,0 +1,2 @@
User-Agent: *
Disallow:
+2 -2
View File
@@ -16,7 +16,7 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "packages/renovate" "directory": "packages/renovate"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.18.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
@@ -27,6 +27,6 @@
"test": "renovate-config-validator ./default.json" "test": "renovate-config-validator ./default.json"
}, },
"devDependencies": { "devDependencies": {
"renovate": "^43.216.1" "renovate": "^44.2.1"
} }
} }
+3 -3
View File
@@ -15,20 +15,20 @@
"type": "git", "type": "git",
"url": "git+https://github.com/robonen/tools.git" "url": "git+https://github.com/robonen/tools.git"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.18.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@types/node": "^25.9.2", "@types/node": "^26.1.2",
"@vitest/coverage-v8": "catalog:", "@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:", "@vitest/ui": "catalog:",
"citty": "^0.2.2", "citty": "^0.2.2",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"jsdom": "catalog:", "jsdom": "catalog:",
"scule": "^1.3.0", "scule": "^1.3.0",
"typescript": "^6.0.3", "typescript": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
}, },
"scripts": { "scripts": {
+4746 -5260
View File
File diff suppressed because it is too large Load Diff
+37 -10
View File
@@ -6,20 +6,47 @@ packages:
- vue/*/playground - vue/*/playground
- docs - docs
allowBuilds:
'@parcel/watcher': true
core-js-pure: true
dtrace-provider: true
esbuild: true
re2: true
unrs-resolver: true
catalog: catalog:
'@stylistic/eslint-plugin': ^5.10.0 '@stylistic/eslint-plugin': ^5.10.0
'@vitest/browser': ^4.1.8 '@vitest/browser': ^4.1.10
'@vitest/coverage-v8': ^4.1.8 '@vitest/coverage-v8': ^4.1.10
'@vitest/ui': ^4.1.8 '@vitest/ui': ^4.1.10
'@vue/shared': ^3.5.35 '@vue/shared': ^3.5.40
'@vue/test-utils': ^2.4.11 '@vue/test-utils': ^2.4.11
eslint: ^10.4.1 eslint: ^10.8.0
jsdom: ^29.1.1 jsdom: ^30.0.1
nuxt: ^4.4.8 nuxt: ^4.5.1
tsdown: ^0.22.2 tsdown: ^0.22.14
vitest: ^4.1.8 typescript: npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2
vue: ^3.5.35 vitest: ^4.1.10
vue: ^3.5.40
# TypeScript 7.0 dropped the classic JS compiler API (`require('typescript')`
# exports only `version`/`versionMajorMinor`), which breaks every Volar/tsc-API
# consumer we depend on: `@vue/compiler-sfc` type resolution (needs `ts.sys`),
# `rolldown-plugin-dts`'s Vue language (needs `ts.ScriptKind`) and `vue-tsc`.
# `typescript-native-bridge` keeps that API surface while type-checking on the
# native tsgo 7.0.2 engine, so we get the new compiler without the breakage.
# Overridden (not just cataloged) because the consumers above resolve
# `typescript` as a transitive peer, not as one of our direct dependencies.
# The version must be pinned exactly — caret ranges never match prereleases.
overrides:
typescript: npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2
ignoredBuiltDependencies: ignoredBuiltDependencies:
- '@parcel/watcher' - '@parcel/watcher'
- esbuild - esbuild
minimumReleaseAgeExclude:
- ast-kit@3.0.0
- renovate@43.228.0
- rolldown-plugin-dts@0.26.0
- tsdown@0.22.3
+1 -1
View File
@@ -2,6 +2,6 @@
"$schema": "https://jsr.io/schema/config-file.v1.json", "$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@robonen/primitives", "name": "@robonen/primitives",
"license": "Apache-2.0", "license": "Apache-2.0",
"version": "0.0.1", "version": "0.0.2",
"exports": "./src/index.ts" "exports": "./src/index.ts"
} }
+13 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/primitives", "name": "@robonen/primitives",
"version": "0.0.1", "version": "0.0.2",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Collection of UI primitives", "description": "Collection of UI primitives",
"keywords": [ "keywords": [
@@ -15,11 +15,12 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "vue/primitives" "directory": "vue/primitives"
}, },
"packageManager": "pnpm@10.34.1", "packageManager": "pnpm@11.18.0",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
@@ -58,23 +59,26 @@
"@robonen/tsconfig": "workspace:*", "@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*", "@robonen/tsdown": "workspace:*",
"@vitest/browser": "catalog:", "@vitest/browser": "catalog:",
"@vitest/browser-playwright": "^4.1.8", "@vitest/browser-playwright": "^4.1.10",
"@vue/test-utils": "catalog:", "@vue/test-utils": "catalog:",
"axe-core": "^4.12.0", "axe-core": "^4.12.1",
"eslint": "catalog:", "eslint": "catalog:",
"playwright": "^1.60.0", "playwright": "^1.62.0",
"tsdown": "catalog:", "tsdown": "catalog:",
"unplugin-vue": "^7.2.0", "unplugin-vue": "^7.2.0",
"vitest-browser-vue": "^2.1.0", "vitest-browser-vue": "^2.1.0",
"vue-tsc": "^3.3.4" "vue": "catalog:",
"vue-tsc": "^3.3.8"
}, },
"dependencies": { "dependencies": {
"@floating-ui/vue": "^1.1.11", "@floating-ui/vue": "^2.0.1",
"@robonen/encoding": "workspace:*", "@robonen/encoding": "workspace:*",
"@robonen/platform": "workspace:*", "@robonen/platform": "workspace:*",
"@robonen/stdlib": "workspace:*", "@robonen/stdlib": "workspace:*",
"@robonen/vue": "workspace:*", "@robonen/vue": "workspace:*",
"@vue/shared": "catalog:", "@vue/shared": "catalog:"
"vue": "catalog:" },
"peerDependencies": {
"vue": "^3.5"
} }
} }
+6 -6
View File
@@ -13,14 +13,14 @@
"dependencies": { "dependencies": {
"@robonen/primitives": "workspace:*", "@robonen/primitives": "workspace:*",
"vue": "catalog:", "vue": "catalog:",
"vue-router": "^5.1.0" "vue-router": "^5.2.0"
}, },
"devDependencies": { "devDependencies": {
"@robonen/tsconfig": "workspace:*", "@robonen/tsconfig": "workspace:*",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.3",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.8",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.3",
"vite": "^8.0.16", "vite": "^8.1.5",
"vue-tsc": "^3.3.4" "vue-tsc": "^3.3.8"
} }
} }
@@ -12,8 +12,14 @@ import {
CalendarHeadCell, CalendarHeadCell,
CalendarRoot, CalendarRoot,
} from '../index'; } from '../index';
import { nativeDateAdapter } from '../../../utilities/config-provider';
import { findFirstFocusableDate, getLocaleWeekStartsOn, toIsoDate } from '../utils'; import { findFirstFocusableDate, getLocaleWeekStartsOn, toIsoDate } from '../utils';
// A date adapter whose "today" sits far outside any month exercised below, so
// the roving-tabindex fallback never anchors on the real system date (which
// would otherwise make date-sensitive expectations flaky).
const fixedTodayAdapter = { ...nativeDateAdapter, now: () => new Date(2020, 0, 1) };
function mountCalendar( function mountCalendar(
props: Record<string, unknown> = {}, props: Record<string, unknown> = {},
options: Record<string, unknown> = {}, options: Record<string, unknown> = {},
@@ -203,6 +209,7 @@ describe('Calendar — roving fallback tabindex', () => {
const w = mountCalendar({ const w = mountCalendar({
defaultPlaceholder: new Date(2026, 5, 1), defaultPlaceholder: new Date(2026, 5, 1),
isDateDisabled: (d: Date) => d.getMonth() === 5 && d.getDate() < 16, isDateDisabled: (d: Date) => d.getMonth() === 5 && d.getDate() < 16,
dateAdapter: fixedTodayAdapter,
}); });
const focusable = w.findAll('[data-primitives-calendar-cell-trigger][tabindex="0"]'); const focusable = w.findAll('[data-primitives-calendar-cell-trigger][tabindex="0"]');
expect(focusable).toHaveLength(1); expect(focusable).toHaveLength(1);
@@ -149,19 +149,27 @@ describe('scroll-area — ref forwarding', () => {
describe('scroll-area — glimpse type', () => { describe('scroll-area — glimpse type', () => {
it('accepts type="glimpse" and reveals scrollbars on pointer enter', async () => { it('accepts type="glimpse" and reveals scrollbars on pointer enter', async () => {
track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body })); const w = track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body }));
await waitFrames(); await waitFrames();
const root = document.querySelector('[dir]') as HTMLElement; const root = w.element as HTMLElement;
root.dispatchEvent(new PointerEvent('pointerenter')); root.dispatchEvent(new PointerEvent('pointerenter'));
await waitFrames(); await waitFrames();
expect(document.querySelectorAll('[data-state="visible"]').length).toBeGreaterThan(0); // Scope to this component's root: browser-mode suites share one document,
// so a global query can also count scrollbars mounted by other suites.
expect(root.querySelectorAll('[data-state="visible"]').length).toBeGreaterThan(0);
}); });
it('glimpse stays hidden before any interaction', async () => { it('glimpse stays hidden when the pointer is away', async () => {
track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body })); const w = track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body }));
const root = w.element as HTMLElement;
// Browser mode uses a real cursor: the area mounts at the top-left, so a
// leftover pointer from a previous suite can land on it and fire a stray
// `pointerenter` (revealing the glimpse). Let that settle, then assert the
// deterministic "pointer not over the area" state via `pointerleave`.
await waitFrames(); await waitFrames();
// No pointer enter / scroll => no visible scrollbar yet. root.dispatchEvent(new PointerEvent('pointerleave'));
expect(document.querySelectorAll('[data-state="visible"]').length).toBe(0); await waitFrames();
expect(root.querySelectorAll('[data-state="visible"]').length).toBe(0);
}); });
}); });
@@ -31,7 +31,7 @@ function isInClosedPopover(el: Element): boolean {
* *
* @param {MaybeComputedElementRef} target Element whose siblings should be aria-hidden * @param {MaybeComputedElementRef} target Element whose siblings should be aria-hidden
* *
* @since 0.0.14 * @since 0.0.1
*/ */
export function useHideOthers(target: MaybeComputedElementRef): void { export function useHideOthers(target: MaybeComputedElementRef): void {
if (!defaultWindow) return; if (!defaultWindow) return;
@@ -0,0 +1,25 @@
<script lang="ts">
import type { DialogCloseProps } from '../dialog';
/**
* A button that closes the drawer when activated. A thin wrapper over Dialog's
* Close that tags the resulting `update:open` with the `close-press` reason.
*/
export interface DrawerCloseProps extends DialogCloseProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { DialogClose } from '../dialog';
import { injectDrawerRootContext } from './context';
const props = defineProps<DrawerCloseProps>();
const { armReason } = injectDrawerRootContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<DialogClose v-bind="props" :ref="forwardRef" @click="armReason('close-press')">
<slot />
</DialogClose>
</template>
@@ -14,6 +14,7 @@ export type DrawerContentEmits = DialogContentEmits;
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, watchEffect } from 'vue'; import { computed, ref, watch, watchEffect } from 'vue';
import { isClient } from '@robonen/platform/multi';
import { useForwardExpose } from '@robonen/vue'; import { useForwardExpose } from '@robonen/vue';
import { DialogContent } from '../dialog'; import { DialogContent } from '../dialog';
import { injectDrawerRootContext } from './context'; import { injectDrawerRootContext } from './context';
@@ -30,6 +31,9 @@ const {
onPress, onPress,
onDrag, onDrag,
onRelease, onRelease,
onCancel,
armReason,
isAllowedToDrag,
modal, modal,
dismissible, dismissible,
keyboardIsOpen, keyboardIsOpen,
@@ -49,10 +53,12 @@ useScaleBackground();
const delayedSnapPoints = ref(false); const delayedSnapPoints = ref(false);
const snapPointHeight = computed(() => { const snapPointHeight = computed(() => {
if (snapPointsOffset.value && snapPointsOffset.value.length > 0) const offset = snapPointsOffset.value?.[0];
return `${snapPointsOffset.value[0]}px`;
return '0'; if (typeof offset === 'number' && Number.isFinite(offset))
return `${offset}px`;
return '0px';
}); });
function handlePointerDownOutside(event: Event) { function handlePointerDownOutside(event: Event) {
@@ -66,13 +72,21 @@ function handlePointerDownOutside(event: Event) {
// Let the underlying DismissableLayer close a dismissible modal drawer; // Let the underlying DismissableLayer close a dismissible modal drawer;
// otherwise hold it open. // otherwise hold it open.
if (!dismissible.value) if (!dismissible.value) {
event.preventDefault(); event.preventDefault();
return;
}
armReason('outside-press');
} }
function handleEscapeKeyDown(event: KeyboardEvent) { function handleEscapeKeyDown(event: KeyboardEvent) {
if (!dismissible.value) if (!dismissible.value) {
event.preventDefault(); event.preventDefault();
return;
}
armReason('escape-key');
} }
function handlePointerDown(event: PointerEvent) { function handlePointerDown(event: PointerEvent) {
@@ -88,8 +102,9 @@ function handlePointerMove(event: PointerEvent) {
} }
watchEffect(() => { watchEffect(() => {
if (hasSnapPoints.value) { // `flush: 'pre'` effects run during SSR, where rAF doesn't exist.
globalThis.requestAnimationFrame(() => { if (hasSnapPoints.value && isClient) {
requestAnimationFrame(() => {
delayedSnapPoints.value = true; delayedSnapPoints.value = true;
}); });
} }
@@ -103,10 +118,13 @@ watchEffect(() => {
:data-drawer-direction="direction" :data-drawer-direction="direction"
:data-drawer-delayed-snap-points="delayedSnapPoints ? 'true' : 'false'" :data-drawer-delayed-snap-points="delayedSnapPoints ? 'true' : 'false'"
:data-drawer-snap-points="isOpen && hasSnapPoints ? 'true' : 'false'" :data-drawer-snap-points="isOpen && hasSnapPoints ? 'true' : 'false'"
:data-swiping="isAllowedToDrag ? 'true' : undefined"
:style="{ '--snap-point-height': snapPointHeight }" :style="{ '--snap-point-height': snapPointHeight }"
@pointerdown="handlePointerDown" @pointerdown="handlePointerDown"
@pointermove="handlePointerMove" @pointermove="handlePointerMove"
@pointerup="onRelease" @pointerup="onRelease"
@pointercancel="onCancel"
@lostpointercapture="onCancel"
@open-auto-focus.prevent @open-auto-focus.prevent
@pointer-down-outside="handlePointerDownOutside" @pointer-down-outside="handlePointerDownOutside"
@escape-key-down="handleEscapeKeyDown" @escape-key-down="handleEscapeKeyDown"
@@ -11,7 +11,8 @@ export type { DrawerHandleProps } from './controls';
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import { ref, useTemplateRef, watchPostEffect } from 'vue'; import { onScopeDispose, useTemplateRef, watch, watchPostEffect } from 'vue';
import { onLongPress, useStateMachine } from '@robonen/vue';
import { injectDrawerRootContext } from './context'; import { injectDrawerRootContext } from './context';
const { preventCycle = false } = defineProps<DrawerHandleProps>(); const { preventCycle = false } = defineProps<DrawerHandleProps>();
@@ -19,7 +20,7 @@ const { preventCycle = false } = defineProps<DrawerHandleProps>();
const LONG_HANDLE_PRESS_TIMEOUT = 250; const LONG_HANDLE_PRESS_TIMEOUT = 250;
const DOUBLE_TAP_TIMEOUT = 120; const DOUBLE_TAP_TIMEOUT = 120;
const { onPress, onDrag, handleRef, handleOnly, isOpen, snapPoints, activeSnapPoint, isDragging, dismissible, closeDrawer } const { onPress, onDrag, onCancel, handleRef, handleOnly, isOpen, snapPoints, activeSnapPoint, isDragging, isAllowedToDrag, dismissible, closeDrawer }
= injectDrawerRootContext(); = injectDrawerRootContext();
// Mirror the element into the shared context ref. A local template ref + watch // Mirror the element into the shared context ref. A local template ref + watch
@@ -31,33 +32,67 @@ watchPostEffect(() => {
handleRef.value = handleElement.value; handleRef.value = handleElement.value;
}); });
const closeTimeoutId = ref<number | null>(null); let cycleTimer: ReturnType<typeof setTimeout> | undefined;
const shouldCancelInteraction = ref(false);
function handleStartCycle() { // Tap-to-cycle as an explicit machine: a tap schedules the cycle after the
// Ignore the second tap of a double-tap. // double-tap window, a long hold suppresses it, and a second press inside the
if (shouldCancelInteraction.value) { // window cancels the pending cycle so a double-tap cycles once, never twice.
handleCancelInteraction(); const tap = useStateMachine({
return; initial: 'idle',
states: {
idle: { on: { PRESS: 'pressed', TAP: 'tapPending' } },
pressed: { on: { LONG_PRESS: 'suppressed', DRAG: 'suppressed', TAP: 'tapPending', CANCEL: 'idle' } },
suppressed: { on: { TAP: 'idle', PRESS: 'pressed', CANCEL: 'idle' } },
tapPending: {
entry: () => {
cycleTimer = setTimeout(fireCycleElapsed, DOUBLE_TAP_TIMEOUT);
},
exit: () => clearTimeout(cycleTimer),
on: {
ELAPSED: { target: 'idle', action: cycleSnapPoints },
PRESS: 'pressed',
// A long-press timer armed before the release can still outrace the
// pending cycle treat it as suppression, like the release-time flag
// check of the pre-machine code did.
LONG_PRESS: 'suppressed',
DRAG: 'suppressed',
CANCEL: 'idle',
},
},
},
});
// The exit hook covers every transition; this covers unmount mid-window.
onScopeDispose(() => clearTimeout(cycleTimer));
// A gesture that actually engaged the drawer must never read as a tap: pointer
// capture keeps the release's click on the handle, and 120ms later the drag is
// long over (isDragging is false again), so only a latch armed DURING the
// press can tell a short drag apart from a tap.
watch(isAllowedToDrag, (dragging) => {
if (dragging)
tap.send('DRAG');
});
// Annotated `: void` so the machine config can reference it without a type cycle.
function fireCycleElapsed(): void {
tap.send('ELAPSED');
} }
globalThis.setTimeout(() => { // A long hold suppresses the tap-to-cycle. `distanceThreshold: false` keeps the
handleCycleSnapPoints(); // original semantics: the hold counts even while the pointer drags the drawer.
}, DOUBLE_TAP_TIMEOUT); onLongPress(handleElement, () => {
} tap.send('LONG_PRESS');
}, { delay: LONG_HANDLE_PRESS_TIMEOUT, distanceThreshold: false });
function handleCycleSnapPoints() { function cycleSnapPoints() {
// Don't treat an accidental tap during a resize as a cycle. // Don't treat an accidental tap during a resize as a cycle.
if (isDragging.value || preventCycle || shouldCancelInteraction.value) { if (isDragging.value || preventCycle)
handleCancelInteraction();
return; return;
}
handleCancelInteraction();
if (!snapPoints.value || snapPoints.value.length === 0) { if (!snapPoints.value || snapPoints.value.length === 0) {
if (!dismissible.value) if (dismissible.value)
closeDrawer(); closeDrawer('handle-press');
return; return;
} }
@@ -65,7 +100,7 @@ function handleCycleSnapPoints() {
const isLastSnapPoint = activeSnapPoint.value === snapPoints.value[snapPoints.value.length - 1]; const isLastSnapPoint = activeSnapPoint.value === snapPoints.value[snapPoints.value.length - 1];
if (isLastSnapPoint && dismissible.value) { if (isLastSnapPoint && dismissible.value) {
closeDrawer(); closeDrawer('handle-press');
return; return;
} }
@@ -78,30 +113,38 @@ function handleCycleSnapPoints() {
activeSnapPoint.value = snapPoints.value[nextSnapPointIndex]; activeSnapPoint.value = snapPoints.value[nextSnapPointIndex];
} }
function handleStartInteraction() { function handleClick() {
closeTimeoutId.value = globalThis.setTimeout(() => { tap.send('TAP');
// A long press cancels the tap-to-cycle.
shouldCancelInteraction.value = true;
}, LONG_HANDLE_PRESS_TIMEOUT);
}
function handleCancelInteraction() {
if (closeTimeoutId.value)
globalThis.clearTimeout(closeTimeoutId.value);
shouldCancelInteraction.value = false;
} }
function handlePointerDown(event: PointerEvent) { function handlePointerDown(event: PointerEvent) {
tap.send('PRESS');
// In handleOnly mode the handle is the capture target so moves keep
// arriving here even when the pointer leaves it.
if (handleOnly.value) if (handleOnly.value)
onPress(event); onPress(event, handleElement.value ?? undefined);
handleStartInteraction();
} }
function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
if (handleOnly.value) if (handleOnly.value)
onDrag(event); onDrag(event);
} }
function handlePointerCancel(event: PointerEvent) {
tap.send('CANCEL');
if (handleOnly.value)
onCancel(event);
}
// Fires after every normal release too (pointer capture sits on the pressed
// element), so it must NOT cancel the tap intent that would defeat the
// long-press suppression. Only the drag engine cares, and it ignores stale calls.
function handleLostPointerCapture(event: PointerEvent) {
if (handleOnly.value)
onCancel(event);
}
</script> </script>
<template> <template>
@@ -110,8 +153,9 @@ function handlePointerMove(event: PointerEvent) {
:data-drawer-visible="isOpen ? 'true' : 'false'" :data-drawer-visible="isOpen ? 'true' : 'false'"
data-drawer-handle data-drawer-handle
aria-hidden="true" aria-hidden="true"
@click="handleStartCycle" @click="handleClick"
@pointercancel="handleCancelInteraction" @pointercancel="handlePointerCancel"
@lostpointercapture="handleLostPointerCapture"
@pointerdown="handlePointerDown" @pointerdown="handlePointerDown"
@pointermove="handlePointerMove" @pointermove="handlePointerMove"
> >
@@ -17,7 +17,7 @@ import { injectDrawerRootContext } from './context';
defineProps<DrawerOverlayProps>(); defineProps<DrawerOverlayProps>();
const { overlayRef, hasSnapPoints, isOpen, shouldFade } = injectDrawerRootContext(); const { overlayRef, hasSnapPoints, isOpen, shouldFade, isAllowedToDrag } = injectDrawerRootContext();
const { forwardRef, currentElement } = useForwardExpose(); const { forwardRef, currentElement } = useForwardExpose();
watch(currentElement, (el) => { watch(currentElement, (el) => {
@@ -31,6 +31,7 @@ watch(currentElement, (el) => {
data-drawer-overlay data-drawer-overlay
:data-drawer-snap-points="isOpen && hasSnapPoints ? 'true' : 'false'" :data-drawer-snap-points="isOpen && hasSnapPoints ? 'true' : 'false'"
:data-drawer-snap-points-overlay="isOpen && shouldFade ? 'true' : 'false'" :data-drawer-snap-points-overlay="isOpen && shouldFade ? 'true' : 'false'"
:data-swiping="isAllowedToDrag ? 'true' : undefined"
> >
<slot /> <slot />
</DialogOverlay> </DialogOverlay>
@@ -18,12 +18,13 @@ export type { DrawerRootEmits, DrawerRootProps } from './controls';
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, toRefs, watch } from 'vue'; import { computed, ref, toRefs, watch } from 'vue';
import { useStyleTag } from '@robonen/vue'; import { useEventListener, useStyleTag } from '@robonen/vue';
import { isClient } from '@robonen/platform/multi';
import { DialogRoot } from '../dialog'; import { DialogRoot } from '../dialog';
import { provideDrawerRootContext } from './context'; import { provideDrawerRootContext } from './context';
import { useDrawer } from './controls'; import { useDrawer } from './controls';
import { CLOSE_THRESHOLD, SCROLL_LOCK_TIMEOUT, TRANSITIONS } from './constants'; import { CLOSE_THRESHOLD, SCROLL_LOCK_TIMEOUT, TRANSITIONS } from './constants';
import { DRAWER_STYLES, DRAWER_STYLE_ID } from './style'; import { DRAWER_STYLES, DRAWER_STYLE_ID, registerDrawerCssProperties } from './style';
defineOptions({ inheritAttrs: false }); defineOptions({ inheritAttrs: false });
@@ -45,6 +46,7 @@ const props = withDefaults(defineProps<DrawerRootProps>(), {
noBodyStyles: false, noBodyStyles: false,
handleOnly: false, handleOnly: false,
preventScrollRestoration: false, preventScrollRestoration: false,
snapToSequentialPoints: false,
}); });
const emit = defineEmits<DrawerRootEmits>(); const emit = defineEmits<DrawerRootEmits>();
@@ -52,6 +54,9 @@ const emit = defineEmits<DrawerRootEmits>();
// Inject the critical drawer CSS once (reference-counted across every drawer). // Inject the critical drawer CSS once (reference-counted across every drawer).
useStyleTag(DRAWER_STYLES, { id: DRAWER_STYLE_ID }); useStyleTag(DRAWER_STYLES, { id: DRAWER_STYLE_ID });
if (isClient)
registerDrawerCssProperties();
const fadeFromIndex = computed(() => props.fadeFromIndex ?? (props.snapPoints && props.snapPoints.length - 1)); const fadeFromIndex = computed(() => props.fadeFromIndex ?? (props.snapPoints && props.snapPoints.length - 1));
// `isOpen` is the single source of truth for the open state. It's seeded from the // `isOpen` is the single source of truth for the open state. It's seeded from the
@@ -64,14 +69,6 @@ watch(() => props.open, (value) => {
isOpen.value = value; isOpen.value = value;
}); });
// Every change to `isOpen` (from any source) notifies the consumer's `v-model`
// once and schedules `animationEnd`. Close-specific effects (`close`, snap reset)
// live in the engine's own watch on the same ref.
watch(isOpen, (o) => {
emit('update:open', o);
setTimeout(() => emit('animationEnd', o), TRANSITIONS.DURATION * 1000);
});
const localActiveSnapPoint = ref<number | string | null | undefined>( const localActiveSnapPoint = ref<number | string | null | undefined>(
props.activeSnapPoint ?? props.snapPoints?.[0] ?? null, props.activeSnapPoint ?? props.snapPoints?.[0] ?? null,
); );
@@ -91,7 +88,7 @@ const emitHandlers = {
emitClose: () => emit('close'), emitClose: () => emit('close'),
}; };
const { modal } = provideDrawerRootContext( const { modal, drawerRef, pendingReason, notifySettled, hasSnapPoints } = provideDrawerRootContext(
useDrawer({ useDrawer({
...emitHandlers, ...emitHandlers,
...toRefs(props), ...toRefs(props),
@@ -101,6 +98,68 @@ const { modal } = provideDrawerRootContext(
}), }),
); );
// `animationEnd` fires on the drawer element's own transitionend/animationend
// (so dynamic settle durations and consumer-tuned animations report honestly),
// with a fixed-duration timeout kept as an upper-bound fallback for
// reduced-motion and animation-less environments. The listener rides the
// reactive `drawerRef`, so it (re)attaches whenever the content (re)mounts;
// `pendingAnimationEnd` gates it to the transition armed by the open flip.
let pendingAnimationEnd: boolean | null = null;
let animationEndTimer: ReturnType<typeof setTimeout> | undefined;
function fireAnimationEnd() {
if (pendingAnimationEnd === null)
return;
const open = pendingAnimationEnd;
pendingAnimationEnd = null;
clearTimeout(animationEndTimer);
// Advance the engine's lifecycle phase first, so `animationEnd` observers see
// the settled state (e.g. the snap point already reset after a close).
notifySettled();
emit('animationEnd', open);
}
useEventListener(drawerRef, ['transitionend', 'animationend'], (event) => {
// Only the drawer's own settle counts ignore bubbled child transitions.
if (event.target !== event.currentTarget)
return;
if (event.type === 'transitionend') {
// Transform transitions signal a settle only for snap-point drawers; the
// keyframe-driven enter/exit of plain drawers also sees transform
// transitions from other sources (a nested child writing to this element,
// a drag settle) that must not consume an armed flip.
if (!hasSnapPoints.value || (event as TransitionEvent).propertyName !== 'transform')
return;
}
// Only the stylesheet's slide keyframes mark a settle; consumer keyframes on
// the content fall through to the fallback timeout instead.
else if (!(event as AnimationEvent).animationName.startsWith('slide')) {
return;
}
fireAnimationEnd();
});
// Every change to `isOpen` (from any source) notifies the consumer's `v-model`
// once tagged with the reason armed by whichever part caused the flip and
// arms `animationEnd`. Close-specific effects (`close`, snap reset) live in the
// engine's own watch on the same ref.
watch(isOpen, (o, _prev, onCleanup) => {
const reason = pendingReason.current;
pendingReason.current = undefined;
emit('update:open', o, reason ? { reason } : undefined);
pendingAnimationEnd = o;
animationEndTimer = setTimeout(fireAnimationEnd, TRANSITIONS.DURATION * 1000);
// Runs before the next flip re-arms, and on unmount the fallback never
// outlives the transition it was armed for.
onCleanup(() => clearTimeout(animationEndTimer));
});
// The Dialog reports its own dismissals (trigger, close button, escape, outside // The Dialog reports its own dismissals (trigger, close button, escape, outside
// click) here; mirror them into `isOpen` and let the watchers do the rest. // click) here; mirror them into `isOpen` and let the watchers do the rest.
function handleOpenChange(o: boolean) { function handleOpenChange(o: boolean) {
@@ -9,6 +9,7 @@
<script setup lang="ts"> <script setup lang="ts">
import DrawerRoot from './DrawerRoot.vue'; import DrawerRoot from './DrawerRoot.vue';
import type { DrawerRootEmits, DrawerRootProps } from './controls'; import type { DrawerRootEmits, DrawerRootProps } from './controls';
import type { DrawerOpenChangeDetails } from './types';
import { injectDrawerRootContext } from './context'; import { injectDrawerRootContext } from './context';
const props = defineProps<DrawerRootProps>(); const props = defineProps<DrawerRootProps>();
@@ -31,10 +32,10 @@ function onRelease(open: boolean) {
emit('release', open); emit('release', open);
} }
function onOpenChange(open: boolean) { function onOpenChange(open: boolean, details?: DrawerOpenChangeDetails) {
if (open) if (open)
onNestedOpenChange(open); onNestedOpenChange(open);
emit('update:open', open); emit('update:open', open, details);
} }
</script> </script>
@@ -0,0 +1,25 @@
<script lang="ts">
import type { DialogTriggerProps } from '../dialog';
/**
* The button that toggles the drawer open. A thin wrapper over Dialog's Trigger
* that tags the resulting `update:open` with the `trigger-press` reason.
*/
export interface DrawerTriggerProps extends DialogTriggerProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { DialogTrigger } from '../dialog';
import { injectDrawerRootContext } from './context';
const props = defineProps<DrawerTriggerProps>();
const { armReason } = injectDrawerRootContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<DialogTrigger v-bind="props" :ref="forwardRef" @click="armReason('trigger-press')">
<slot />
</DialogTrigger>
</template>
@@ -2,6 +2,7 @@ import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue'; import { defineComponent, h, nextTick, ref } from 'vue';
import type { VNode } from 'vue';
import { import {
DrawerClose, DrawerClose,
DrawerContent, DrawerContent,
@@ -13,6 +14,7 @@ import {
DrawerTitle, DrawerTitle,
DrawerTrigger, DrawerTrigger,
} from '../index'; } from '../index';
import { DRAWER_STYLE_ID } from '../style';
const wrappers: Array<VueWrapper<any>> = []; const wrappers: Array<VueWrapper<any>> = [];
@@ -20,7 +22,7 @@ afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount(); while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = ''; document.body.innerHTML = '';
document.body.removeAttribute('style'); document.body.removeAttribute('style');
document.getElementById('robonen-drawer')?.remove(); document.getElementById(DRAWER_STYLE_ID)?.remove();
}); });
function track<T extends VueWrapper<any>>(w: T): T { function track<T extends VueWrapper<any>>(w: T): T {
@@ -35,6 +37,16 @@ async function flush(): Promise<void> {
await nextTick(); await nextTick();
} }
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/** Waits out the 500ms "no dragging during the open animation" guard. */
async function openSettled(): Promise<void> {
await flush();
await sleep(600);
}
function $<T extends Element = HTMLElement>(selector: string): T | null { function $<T extends Element = HTMLElement>(selector: string): T | null {
return document.querySelector<T>(selector); return document.querySelector<T>(selector);
} }
@@ -51,14 +63,52 @@ function $close(): HTMLButtonElement | undefined {
return [...document.querySelectorAll('button')].find(b => b.textContent === 'Close'); return [...document.querySelectorAll('button')].find(b => b.textContent === 'Close');
} }
function pointer(el: Element, type: string, x: number, y: number) {
el.dispatchEvent(new PointerEvent(type, {
button: type === 'pointermove' ? -1 : 0,
pointerId: 1,
isPrimary: true,
clientX: x,
clientY: y,
bubbles: true,
cancelable: true,
}));
}
/**
* Quick drag: ~10ms between moves keeps the velocity tracker's samples fresh,
* so releasing right after reads as a fling.
*/
async function fastDrag(el: Element, points: Array<[number, number]>) {
pointer(el, 'pointerdown', points[0]![0], points[0]![1]);
for (const [x, y] of points.slice(1)) {
await sleep(10);
pointer(el, 'pointermove', x, y);
}
}
/** Drag, then pause past MAX_VELOCITY_AGE so the release velocity reads 0. */
async function slowDrag(el: Element, points: Array<[number, number]>) {
await fastDrag(el, points);
await sleep(120);
}
interface MountOptions { interface MountOptions {
open?: boolean; open?: boolean;
defaultOpen?: boolean; defaultOpen?: boolean;
modal?: boolean; modal?: boolean;
dismissible?: boolean; dismissible?: boolean;
direction?: 'top' | 'bottom' | 'left' | 'right'; direction?: 'top' | 'bottom' | 'left' | 'right';
snapPoints?: Array<number | string>;
handleOnly?: boolean;
withHandle?: boolean; withHandle?: boolean;
onUpdateOpen?: (v: boolean) => void; contentStyle?: Record<string, string>;
extraContent?: () => VNode;
onUpdateOpen?: (v: boolean, details?: { reason?: string }) => void;
onUpdateActiveSnapPoint?: (v: number | string) => void;
onRelease?: (open: boolean) => void;
onAnimationEnd?: (open: boolean) => void;
onClose?: () => void; onClose?: () => void;
} }
@@ -75,7 +125,12 @@ function mountDrawer(options: MountOptions = {}) {
modal: options.modal ?? true, modal: options.modal ?? true,
dismissible: options.dismissible ?? true, dismissible: options.dismissible ?? true,
direction: options.direction ?? 'bottom', direction: options.direction ?? 'bottom',
snapPoints: options.snapPoints,
handleOnly: options.handleOnly,
'onUpdate:open': options.onUpdateOpen, 'onUpdate:open': options.onUpdateOpen,
'onUpdate:activeSnapPoint': options.onUpdateActiveSnapPoint,
onRelease: options.onRelease,
onAnimationEnd: options.onAnimationEnd,
onClose: options.onClose, onClose: options.onClose,
}, },
{ {
@@ -84,12 +139,13 @@ function mountDrawer(options: MountOptions = {}) {
h(DrawerPortal, null, { h(DrawerPortal, null, {
default: () => [ default: () => [
h(DrawerOverlay, { 'data-testid': 'overlay' }), h(DrawerOverlay, { 'data-testid': 'overlay' }),
h(DrawerContent, null, { h(DrawerContent, { style: { height: '200px', width: '200px', ...options.contentStyle } }, {
default: () => [ default: () => [
withHandle ? h(DrawerHandle) : null, withHandle ? h(DrawerHandle) : null,
h(DrawerTitle, null, { default: () => 'Title' }), h(DrawerTitle, null, { default: () => 'Title' }),
h(DrawerDescription, null, { default: () => 'Desc' }), h(DrawerDescription, null, { default: () => 'Desc' }),
h(DrawerClose, null, { default: () => 'Close' }), h(DrawerClose, null, { default: () => 'Close' }),
options.extraContent ? options.extraContent() : null,
], ],
}), }),
], ],
@@ -113,7 +169,7 @@ describe('Drawer / markup', () => {
it('injects the critical drawer stylesheet once', async () => { it('injects the critical drawer stylesheet once', async () => {
mountDrawer({ defaultOpen: true }); mountDrawer({ defaultOpen: true });
await flush(); await flush();
const tags = document.querySelectorAll('#robonen-drawer'); const tags = document.querySelectorAll(`#${DRAWER_STYLE_ID}`);
expect(tags.length).toBe(1); expect(tags.length).toBe(1);
expect(tags[0]!.textContent).toContain('@keyframes slideFromBottom'); expect(tags[0]!.textContent).toContain('@keyframes slideFromBottom');
}); });
@@ -152,13 +208,13 @@ describe('Drawer / open state', () => {
expect($content()?.getAttribute('data-state') ?? 'closed').toBe('closed'); expect($content()?.getAttribute('data-state') ?? 'closed').toBe('closed');
}); });
it('emits update:open when the trigger is clicked (controlled)', async () => { it('emits update:open with a trigger-press reason (controlled)', async () => {
const onUpdateOpen = vi.fn(); const onUpdateOpen = vi.fn();
mountDrawer({ open: false, onUpdateOpen }); mountDrawer({ open: false, onUpdateOpen });
$trigger().click(); $trigger().click();
await flush(); await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(true); expect(onUpdateOpen).toHaveBeenCalledWith(true, { reason: 'trigger-press' });
}); });
it('emits close exactly once when dismissed via DrawerClose', async () => { it('emits close exactly once when dismissed via DrawerClose', async () => {
@@ -175,6 +231,7 @@ describe('Drawer / open state', () => {
// Regression: closing purely by setting the bound `open` prop to false (not // Regression: closing purely by setting the bound `open` prop to false (not
// via a dialog dismissal) must still run the close side effects. // via a dialog dismissal) must still run the close side effects.
const onClose = vi.fn(); const onClose = vi.fn();
const onUpdateOpen = vi.fn();
const state = ref(true); const state = ref(true);
const Wrapper = defineComponent({ const Wrapper = defineComponent({
setup() { setup() {
@@ -182,7 +239,10 @@ describe('Drawer / open state', () => {
DrawerRoot, DrawerRoot,
{ {
open: state.value, open: state.value,
'onUpdate:open': (v: boolean) => { state.value = v; }, 'onUpdate:open': (v: boolean, details?: unknown) => {
state.value = v;
onUpdateOpen(v, details);
},
onClose, onClose,
}, },
{ {
@@ -203,6 +263,8 @@ describe('Drawer / open state', () => {
state.value = false; state.value = false;
await flush(); await flush();
expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1);
// A programmatic flip carries no reason.
expect(onUpdateOpen).toHaveBeenCalledWith(false, undefined);
}); });
}); });
@@ -219,3 +281,497 @@ describe('Drawer / overlay', () => {
expect($('[data-drawer-overlay]')).toBeNull(); expect($('[data-drawer-overlay]')).toBeNull();
}); });
}); });
describe('Drawer / dismiss reasons', () => {
it('tags an Escape dismissal with escape-key', async () => {
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen });
await flush();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }));
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'escape-key' });
});
it('tags a DrawerClose click with close-press', async () => {
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen });
await flush();
$close()!.click();
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'close-press' });
});
});
describe('Drawer / handle', () => {
it('closes a dismissible drawer without snap points on a handle tap', async () => {
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen });
await flush();
$('[data-drawer-handle]')!.click();
await sleep(250); // past the double-tap window
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'handle-press' });
});
it('keeps a non-dismissible drawer open on a handle tap', async () => {
// Regression: the condition used to be inverted — a handle tap closed
// exactly the drawers that declared themselves non-dismissible.
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, dismissible: false, onUpdateOpen });
await flush();
$('[data-drawer-handle]')!.click();
await sleep(250);
await flush();
expect(onUpdateOpen).not.toHaveBeenCalled();
expect($content()!.getAttribute('data-state')).toBe('open');
});
it('cycles snap points on tap and reports the new active point', async () => {
const onUpdateActiveSnapPoint = vi.fn();
mountDrawer({ defaultOpen: true, snapPoints: [0.5, 1], onUpdateActiveSnapPoint });
await flush();
$('[data-drawer-handle]')!.click();
await sleep(250);
await flush();
expect(onUpdateActiveSnapPoint).toHaveBeenCalledWith(1);
});
it('cycles once on a double tap, not twice', async () => {
const onUpdateActiveSnapPoint = vi.fn();
mountDrawer({ defaultOpen: true, snapPoints: [0.3, 0.6, 1], onUpdateActiveSnapPoint });
await flush();
onUpdateActiveSnapPoint.mockClear();
const handle = $('[data-drawer-handle]')!;
// A full tap is pointerdown → pointerup → click. Both taps are dispatched
// in the same synchronous block: no timer can fire in between, so the
// second press deterministically lands inside the double-tap window and
// must cancel the first pending cycle.
pointer(handle, 'pointerdown', 100, 100);
pointer(handle, 'pointerup', 100, 100);
handle.click();
pointer(handle, 'pointerdown', 100, 100);
pointer(handle, 'pointerup', 100, 100);
handle.click();
await sleep(300);
await flush();
expect(onUpdateActiveSnapPoint).toHaveBeenCalledWith(0.6);
expect(onUpdateActiveSnapPoint).not.toHaveBeenCalledWith(1);
});
it('does not cycle when a short drag on the handle ends in a click', async () => {
const onUpdateActiveSnapPoint = vi.fn();
const onUpdateOpen = vi.fn();
// Full-height content: fraction snap points assume the drawer can cover
// the window, otherwise every release projects as "closer to closed".
mountDrawer({ defaultOpen: true, snapPoints: [0.5, 1], contentStyle: { height: '100vh' }, onUpdateActiveSnapPoint, onUpdateOpen });
await openSettled();
onUpdateActiveSnapPoint.mockClear();
const handle = $('[data-drawer-handle]')!;
// A small real drag from the handle (upward, so a dismissible drawer at
// its first snap point doesn't legitimately close), then the click the
// browser dispatches after release — pointer capture keeps it on the
// handle. The engaged drag must suppress the tap-to-cycle.
await slowDrag(handle, [[100, 300], [100, 290], [100, 280]]);
pointer(handle, 'pointerup', 100, 280);
handle.click();
await sleep(300);
await flush();
expect(onUpdateActiveSnapPoint).not.toHaveBeenCalledWith(1);
expect(onUpdateOpen).not.toHaveBeenCalled();
});
it('does not fire a pending tap cycle after the handle unmounts', async () => {
const showHandle = ref(true);
const onUpdateActiveSnapPoint = vi.fn();
// The root must stay mounted (its emitter alive) while only the handle
// unmounts — otherwise a leaked timer could never be observed.
const Wrapper = defineComponent({
setup() {
return () => h(
DrawerRoot,
{ defaultOpen: true, snapPoints: [0.5, 1], 'onUpdate:activeSnapPoint': onUpdateActiveSnapPoint },
{
default: () => h(DrawerPortal, null, {
default: () => h(DrawerContent, { style: { height: '200px' } }, {
default: () => [
showHandle.value ? h(DrawerHandle) : null,
h(DrawerTitle, null, { default: () => 'Title' }),
h(DrawerDescription, null, { default: () => 'Desc' }),
],
}),
}),
},
);
},
});
track(mount(Wrapper, { attachTo: document.body }));
await flush();
onUpdateActiveSnapPoint.mockClear();
$('[data-drawer-handle]')!.click();
showHandle.value = false; // unmount inside the 120ms window
await flush();
await sleep(250);
expect(onUpdateActiveSnapPoint).not.toHaveBeenCalled();
});
});
describe('Drawer / drag gesture', () => {
it('closes on a swipe past the close threshold with a swipe reason', async () => {
const onUpdateOpen = vi.fn();
const onRelease = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen, onRelease });
await openSettled();
const content = $content()!;
await slowDrag(content, [[100, 300], [100, 330], [100, 360], [100, 390]]);
pointer(content, 'pointerup', 100, 390);
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
expect(onRelease).toHaveBeenCalledWith(false);
});
it('marks the content and overlay with data-swiping while dragging', async () => {
mountDrawer({ defaultOpen: true });
await openSettled();
const content = $content()!;
// Small drag + pause: stays under both the distance and velocity
// thresholds, so the drawer remains open after release.
await slowDrag(content, [[100, 300], [100, 315], [100, 330]]);
expect(content.hasAttribute('data-swiping')).toBe(true);
expect(content.classList.contains('drawer-dragging')).toBe(true);
expect($('[data-drawer-overlay]')!.hasAttribute('data-swiping')).toBe(true);
pointer(content, 'pointerup', 100, 330);
await flush();
expect(content.getAttribute('data-state')).toBe('open');
expect(content.hasAttribute('data-swiping')).toBe(false);
});
it('settles back below the threshold when released without momentum', async () => {
const onUpdateOpen = vi.fn();
const onRelease = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen, onRelease });
await openSettled();
const content = $content()!;
// 30px of a 200px drawer — under the 25% threshold; pause kills momentum.
await slowDrag(content, [[100, 300], [100, 315], [100, 330]]);
pointer(content, 'pointerup', 100, 330);
await flush();
expect(onUpdateOpen).not.toHaveBeenCalled();
expect(onRelease).toHaveBeenCalledWith(true);
expect(content.style.transform).toBe('translate3d(0px, 0px, 0px)');
});
it('does not close after the user reverses past the cancel threshold', async () => {
// "Changed my mind": drag well past the close threshold, pull back, hold,
// release — the drawer must stay open even though the release point alone
// clears the distance threshold.
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen });
await openSettled();
const content = $content()!;
await slowDrag(content, [[100, 300], [100, 360], [100, 420], [100, 360]]);
pointer(content, 'pointerup', 100, 360);
await flush();
expect(onUpdateOpen).not.toHaveBeenCalled();
expect(content.getAttribute('data-state')).toBe('open');
});
it('recovers cleanly from pointercancel mid-drag', async () => {
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen });
await openSettled();
const content = $content()!;
await fastDrag(content, [[100, 300], [100, 330], [100, 360]]);
expect(content.classList.contains('drawer-dragging')).toBe(true);
pointer(content, 'pointercancel', 100, 360);
await flush();
expect(content.classList.contains('drawer-dragging')).toBe(false);
expect(content.hasAttribute('data-swiping')).toBe(false);
expect(onUpdateOpen).not.toHaveBeenCalled();
expect(content.style.transform).toBe('translate3d(0px, 0px, 0px)');
// The next gesture still works.
await slowDrag(content, [[100, 300], [100, 340], [100, 380], [100, 420]]);
pointer(content, 'pointerup', 100, 420);
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
});
it('ignores a cross-axis gesture (axis lock)', async () => {
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen });
await openSettled();
const content = $content()!;
// Mostly-horizontal movement on a bottom drawer must never latch a drag.
await fastDrag(content, [[100, 300], [140, 305], [180, 310], [220, 315]]);
pointer(content, 'pointerup', 220, 315);
await flush();
expect(content.classList.contains('drawer-dragging')).toBe(false);
expect(onUpdateOpen).not.toHaveBeenCalled();
});
it('scales the close-out animation with the fling velocity', async () => {
const onUpdateOpen = vi.fn();
const onAnimationEnd = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen, onAnimationEnd });
await openSettled();
const content = $content()!;
// Rapid successive moves keep the instantaneous velocity high.
await fastDrag(content, [[100, 300], [100, 330], [100, 360], [100, 390]]);
pointer(content, 'pointerup', 100, 390);
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
// The inline duration overrides the stylesheet's 0.5s for this close only.
expect(content.style.animationDuration).not.toBe('');
expect(Number.parseFloat(content.style.animationDuration)).toBeLessThan(0.5);
// animationEnd follows the (scaled) animation, via the real animationend.
await vi.waitFor(() => expect(onAnimationEnd).toHaveBeenCalledWith(false), { timeout: 1000 });
});
it('keeps the default close duration for a slow release past the threshold', async () => {
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen });
await openSettled();
const content = $content()!;
await slowDrag(content, [[100, 300], [100, 330], [100, 360], [100, 390]]);
pointer(content, 'pointerup', 100, 390);
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
expect(content.style.animationDuration).toBe('');
});
});
describe('Drawer / pointer capture', () => {
it('captures the pointer on the pressed element, not the drawer content', async () => {
mountDrawer({
defaultOpen: true,
extraContent: () => h('button', { 'data-testid': 'inner' }, 'Inner'),
});
await flush();
const content = $content()!;
const button = $<HTMLButtonElement>('[data-testid="inner"]')!;
const captured: Element[] = [];
for (const el of [content, button])
(el as any).setPointerCapture = () => captured.push(el);
pointer(button, 'pointerdown', 100, 300);
// Capturing on the drawer would retarget the compat mouse events, so the
// button would never receive `click` — the capture must land on the button.
expect(captured).toEqual([button]);
pointer(button, 'pointerup', 100, 300);
await flush();
expect(content.getAttribute('data-state')).toBe('open');
});
it('captures on the handle for handleOnly gestures', async () => {
mountDrawer({ defaultOpen: true, handleOnly: true });
await flush();
const content = $content()!;
const handle = $('[data-drawer-handle]')!;
const hitarea = $('[data-drawer-handle-hitarea]')!;
const captured: Element[] = [];
for (const el of [content, handle, hitarea])
(el as any).setPointerCapture = () => captured.push(el);
pointer(hitarea, 'pointerdown', 100, 300);
expect(captured).toEqual([handle]);
pointer(hitarea, 'pointerup', 100, 300);
await flush();
});
});
describe('Drawer / lifecycle machine', () => {
it('resets the active snap point only when a close actually settles', async () => {
const open = ref(true);
const active = ref<number | string | null | undefined>(0.9);
const Wrapper = defineComponent({
setup() {
return () => h(
DrawerRoot,
{
open: open.value,
snapPoints: [0.4, 0.9],
activeSnapPoint: active.value,
'onUpdate:open': (v: boolean) => { open.value = v; },
'onUpdate:activeSnapPoint': (v: number | string) => { active.value = v; },
},
{
default: () => h(DrawerPortal, null, {
default: () => h(DrawerContent, { style: { height: '200px' } }, {
default: () => [
h(DrawerTitle, null, { default: () => 'Title' }),
h(DrawerDescription, null, { default: () => 'Desc' }),
],
}),
}),
},
);
},
});
track(mount(Wrapper, { attachTo: document.body }));
await flush();
// Close, then reopen before the exit settles: the pending close cleanup
// must NOT fire on the now-live drawer (the old fixed 500ms timeout did).
open.value = false;
await flush();
await sleep(60);
open.value = true;
await flush();
await sleep(700);
expect(active.value).toBe(0.9);
// A close that actually settles still resets to the first snap point.
open.value = false;
await flush();
await sleep(700);
expect(active.value).toBe(0.4);
});
});
describe('Drawer / scroll containers', () => {
function scrollerContent(direction: 'vertical' | 'horizontal') {
return () => h(
'div',
{
'data-testid': 'scroller',
style: direction === 'vertical'
? 'height: 100px; overflow-y: auto;'
: 'width: 100px; overflow-x: auto;',
},
[h('div', {
style: direction === 'vertical' ? 'height: 400px;' : 'width: 400px; height: 20px;',
}, [h('span', { 'data-testid': 'leaf' }, 'content')])],
);
}
it('lets a mid-scroll container own the gesture (vertical)', async () => {
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen, extraContent: scrollerContent('vertical') });
await openSettled();
const content = $content()!;
const scroller = $('[data-testid="scroller"]')!;
const leaf = $('[data-testid="leaf"]')!;
scroller.scrollTop = 50;
await slowDrag(leaf, [[100, 300], [100, 340], [100, 380], [100, 420]]);
pointer(leaf, 'pointerup', 100, 420);
await flush();
expect(content.classList.contains('drawer-dragging')).toBe(false);
expect(onUpdateOpen).not.toHaveBeenCalled();
// At the top edge the same gesture is a dismiss.
scroller.scrollTop = 0;
await sleep(150); // clear the scroll-lock timeout
await slowDrag(leaf, [[100, 300], [100, 340], [100, 380], [100, 420]]);
pointer(leaf, 'pointerup', 100, 420);
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
});
it('respects horizontal scroll containers in side drawers', async () => {
// Regression: left/right drawers used to skip every shouldDrag check.
const onUpdateOpen = vi.fn();
mountDrawer({
defaultOpen: true,
direction: 'right',
onUpdateOpen,
extraContent: scrollerContent('horizontal'),
});
await openSettled();
const content = $content()!;
const scroller = $('[data-testid="scroller"]')!;
const leaf = $('[data-testid="leaf"]')!;
scroller.scrollLeft = 50;
await slowDrag(leaf, [[100, 300], [140, 300], [180, 300], [220, 300]]);
pointer(leaf, 'pointerup', 220, 300);
await flush();
expect(content.classList.contains('drawer-dragging')).toBe(false);
expect(onUpdateOpen).not.toHaveBeenCalled();
scroller.scrollLeft = 0;
await sleep(150);
await slowDrag(leaf, [[100, 300], [140, 300], [180, 300], [220, 300]]);
pointer(leaf, 'pointerup', 220, 300);
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
});
});
describe('Drawer / snap points', () => {
it('positions the drawer at the first snap point and exposes the offsets', async () => {
mountDrawer({ defaultOpen: true, snapPoints: [0.5, 1] });
await flush();
const content = $content()!;
expect(content.getAttribute('data-drawer-snap-points')).toBe('true');
const expected = Math.round(window.innerHeight - window.innerHeight * 0.5);
await vi.waitFor(() => {
expect(content.style.transform).toBe(`translate3d(0px, ${expected}px, 0px)`);
});
expect(content.style.getPropertyValue('--snap-point-height')).toBe(`${expected}px`);
});
it('resolves px and rem snap points', async () => {
mountDrawer({ defaultOpen: true, snapPoints: ['10rem', '500px'] });
await flush();
const content = $content()!;
const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
const expected = Math.round(window.innerHeight - 10 * rem);
await vi.waitFor(() => {
expect(content.style.transform).toBe(`translate3d(0px, ${expected}px, 0px)`);
});
});
});
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import axe from 'axe-core';
import { defineComponent, h, nextTick } from 'vue';
import {
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHandle,
DrawerOverlay,
DrawerPortal,
DrawerRoot,
DrawerTitle,
DrawerTrigger,
} from '../index';
async function violations(element: Element) {
const results = await axe.run(element);
return results.violations;
}
async function flush() {
await nextTick();
await nextTick();
await nextTick();
}
function drawerFixture(defaultOpen: boolean) {
return defineComponent({
setup() {
return () => h(DrawerRoot, { defaultOpen }, {
default: () => [
h(DrawerTrigger, null, { default: () => 'Open drawer' }),
h(DrawerPortal, null, {
default: () => [
h(DrawerOverlay),
h(DrawerContent, null, {
default: () => [
h(DrawerHandle),
h(DrawerTitle, null, { default: () => 'Drawer title' }),
h(DrawerDescription, null, { default: () => 'Drawer description' }),
h(DrawerClose, null, { default: () => 'Close' }),
],
}),
],
}),
],
});
},
});
}
describe('Drawer a11y', () => {
let wrapper: ReturnType<typeof mount> | undefined;
afterEach(() => {
wrapper?.unmount();
wrapper = undefined;
document.body.innerHTML = '';
document.body.removeAttribute('style');
});
it('has no axe violations when closed', async () => {
wrapper = mount(drawerFixture(false), { attachTo: document.body });
await flush();
expect(await violations(document.body)).toHaveLength(0);
});
it('has no axe violations when open', async () => {
wrapper = mount(drawerFixture(true), { attachTo: document.body });
await flush();
expect(await violations(document.body)).toHaveLength(0);
});
});
@@ -0,0 +1,224 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
computeSettleDuration,
createReverseCancelTracker,
createVelocityTracker,
findScrollableAncestor,
isAtScrollEdge,
} from '../gesture';
import { MAX_VELOCITY_AGE, MIN_SETTLE_DURATION, MIN_VELOCITY_DT, TRANSITIONS } from '../constants';
afterEach(() => {
document.body.innerHTML = '';
});
describe('createVelocityTracker', () => {
it('computes instantaneous velocity from the trailing pair of samples', () => {
const tracker = createVelocityTracker();
tracker.add(0, 0);
tracker.add(10, 20); // 0.5 px/ms — but superseded below
tracker.add(50, 40); // (50-10)/20 = 2 px/ms
expect(tracker.read(45)).toBe(2);
});
it('reads 0 when the pointer paused before release', () => {
const tracker = createVelocityTracker();
tracker.add(0, 0);
tracker.add(100, 20);
expect(tracker.read(20 + MAX_VELOCITY_AGE + 1)).toBe(0);
});
it('clamps tiny sample intervals so same-frame bursts do not spike', () => {
const tracker = createVelocityTracker();
tracker.add(0, 0);
tracker.add(32, 1); // dt clamped 1 → MIN_VELOCITY_DT
expect(tracker.read(2)).toBe(32 / MIN_VELOCITY_DT);
});
it('reads 0 before two samples exist', () => {
const tracker = createVelocityTracker();
expect(tracker.read(0)).toBe(0);
tracker.add(10, 0);
expect(tracker.read(1)).toBe(0);
});
it('ignores out-of-order samples', () => {
const tracker = createVelocityTracker();
tracker.add(0, 100);
tracker.add(50, 120);
tracker.add(999, 90); // stale timestamp — must not produce a velocity
expect(tracker.read(121)).toBe(50 / 20);
});
});
describe('createReverseCancelTracker', () => {
it('cancels once an armed gesture pulls back past the threshold', () => {
const tracker = createReverseCancelTracker();
tracker.update(60); // armed (> 20)
expect(tracker.cancelled).toBe(false);
tracker.update(45); // pulled back 15 > 10
expect(tracker.cancelled).toBe(true);
});
it('does not cancel before the arm distance', () => {
const tracker = createReverseCancelTracker();
tracker.update(15);
tracker.update(0); // pulled back 15, but max never armed
expect(tracker.cancelled).toBe(false);
});
it('tolerates jitter below the reverse threshold', () => {
const tracker = createReverseCancelTracker();
tracker.update(80);
tracker.update(72); // only 8 back
expect(tracker.cancelled).toBe(false);
});
it('re-arms when the drag surpasses its previous furthest point', () => {
const tracker = createReverseCancelTracker();
tracker.update(60);
tracker.update(40);
expect(tracker.cancelled).toBe(true);
tracker.update(70); // renewed intent
expect(tracker.cancelled).toBe(false);
});
});
describe('computeSettleDuration', () => {
it('keeps the default duration for slow releases', () => {
expect(computeSettleDuration(300, 0.1)).toBe(TRANSITIONS.DURATION);
expect(computeSettleDuration(300, 0)).toBe(TRANSITIONS.DURATION);
});
it('scales the duration down with a hard flick', () => {
// 100px left at 2px/ms → 50ms, clamped up to the minimum.
expect(computeSettleDuration(100, 2)).toBe(MIN_SETTLE_DURATION / 1000);
// 400px left at 1px/ms → 400ms.
expect(computeSettleDuration(400, 1)).toBe(0.4);
});
it('never exceeds the default duration', () => {
expect(computeSettleDuration(10_000, 0.5)).toBe(TRANSITIONS.DURATION);
});
it('falls back on degenerate distances', () => {
expect(computeSettleDuration(0, 3)).toBe(TRANSITIONS.DURATION);
expect(computeSettleDuration(Number.NaN, 3)).toBe(TRANSITIONS.DURATION);
});
});
function scrollableFixture() {
document.body.innerHTML = `
<div id="drawer" style="height: 200px;">
<div id="scroller" style="height: 100px; width: 100px; overflow: auto;">
<div id="inner" style="height: 400px; width: 400px;">
<span id="leaf">content</span>
</div>
</div>
</div>
`;
return {
drawer: document.getElementById('drawer')! as HTMLElement,
scroller: document.getElementById('scroller')! as HTMLElement,
leaf: document.getElementById('leaf')! as HTMLElement,
};
}
describe('findScrollableAncestor', () => {
it('finds the nearest scrollable ancestor along the axis', () => {
const { drawer, scroller, leaf } = scrollableFixture();
expect(findScrollableAncestor(leaf, drawer, 'y')).toBe(scroller);
expect(findScrollableAncestor(leaf, drawer, 'x')).toBe(scroller);
});
it('returns null when nothing scrolls', () => {
const { drawer } = scrollableFixture();
expect(findScrollableAncestor(drawer, drawer, 'y')).toBeNull();
});
it('stops at the boundary', () => {
const { scroller, leaf } = scrollableFixture();
const inner = document.getElementById('inner')! as HTMLElement;
// Boundary below the scroller — the walk must not escape it.
expect(findScrollableAncestor(leaf, inner, 'y')).toBeNull();
void scroller;
});
it('ignores overflow visible/hidden containers', () => {
document.body.innerHTML = `
<div id="drawer">
<div id="clipped" style="height: 50px; overflow: hidden;">
<div style="height: 300px;"><span id="leaf">x</span></div>
</div>
</div>
`;
const drawer = document.getElementById('drawer')! as HTMLElement;
const leaf = document.getElementById('leaf')! as HTMLElement;
expect(findScrollableAncestor(leaf, drawer, 'y')).toBeNull();
});
});
describe('isAtScrollEdge', () => {
it('bottom drawer requires the scroller at its top', () => {
const { scroller } = scrollableFixture();
scroller.scrollTop = 0;
expect(isAtScrollEdge(scroller, 'bottom')).toBe(true);
scroller.scrollTop = 50;
expect(isAtScrollEdge(scroller, 'bottom')).toBe(false);
});
it('top drawer requires the scroller at its bottom', () => {
const { scroller } = scrollableFixture();
scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
expect(isAtScrollEdge(scroller, 'top')).toBe(true);
scroller.scrollTop = 0;
expect(isAtScrollEdge(scroller, 'top')).toBe(false);
});
it('right drawer requires the scroller at its left edge', () => {
const { scroller } = scrollableFixture();
scroller.scrollLeft = 0;
expect(isAtScrollEdge(scroller, 'right')).toBe(true);
scroller.scrollLeft = 40;
expect(isAtScrollEdge(scroller, 'right')).toBe(false);
});
it('left drawer requires the scroller at its right edge', () => {
const { scroller } = scrollableFixture();
scroller.scrollLeft = scroller.scrollWidth - scroller.clientWidth;
expect(isAtScrollEdge(scroller, 'left')).toBe(true);
scroller.scrollLeft = 0;
expect(isAtScrollEdge(scroller, 'left')).toBe(false);
});
});
@@ -0,0 +1,192 @@
import { describe, expect, it } from 'vitest';
import {
findSnapPointIndex,
projectSnapRelease,
resolveSnapPointOffset,
resolveSnapPointSize,
} from '../snapping';
const WINDOW = 800;
const REM = 16;
describe('resolveSnapPointSize', () => {
it('treats numbers in (0, 1] as window fractions', () => {
expect(resolveSnapPointSize(0.5, WINDOW, REM)).toBe(400);
expect(resolveSnapPointSize(1, WINDOW, REM)).toBe(WINDOW);
});
it('treats numbers above 1 as pixels', () => {
expect(resolveSnapPointSize(620, WINDOW, REM)).toBe(620);
});
it('parses px strings', () => {
expect(resolveSnapPointSize('148px', WINDOW, REM)).toBe(148);
expect(resolveSnapPointSize('148.6px', WINDOW, REM)).toBe(149);
});
it('parses rem strings against the root font size', () => {
expect(resolveSnapPointSize('30rem', WINDOW, REM)).toBe(480);
expect(resolveSnapPointSize('30rem', WINDOW, 20)).toBe(600);
});
it('rejects unknown units and degenerate values', () => {
expect(resolveSnapPointSize('50%', WINDOW, REM)).toBeNull();
expect(resolveSnapPointSize('10vh', WINDOW, REM)).toBeNull();
expect(resolveSnapPointSize('abc', WINDOW, REM)).toBeNull();
expect(resolveSnapPointSize('-10px', WINDOW, REM)).toBeNull();
expect(resolveSnapPointSize(0, WINDOW, REM)).toBeNull();
expect(resolveSnapPointSize(-0.5, WINDOW, REM)).toBeNull();
expect(resolveSnapPointSize(Number.NaN, WINDOW, REM)).toBeNull();
});
});
describe('resolveSnapPointOffset', () => {
it('signs the translate toward the anchored edge', () => {
expect(resolveSnapPointOffset(0.25, 'bottom', WINDOW, REM)).toBe(600);
expect(resolveSnapPointOffset(0.25, 'right', WINDOW, REM)).toBe(600);
expect(resolveSnapPointOffset(0.25, 'top', WINDOW, REM)).toBe(-600);
expect(resolveSnapPointOffset(0.25, 'left', WINDOW, REM)).toBe(-600);
});
it('clamps oversized snap points at fully open', () => {
expect(resolveSnapPointOffset(1200, 'bottom', WINDOW, REM)).toBe(0);
});
it('maps invalid points to NaN', () => {
expect(resolveSnapPointOffset('50%', 'bottom', WINDOW, REM)).toBeNaN();
});
});
describe('findSnapPointIndex', () => {
const points = [0.25, '400px', '30rem'];
it('matches by identity first', () => {
expect(findSnapPointIndex(points, '400px', WINDOW, REM)).toBe(1);
});
it('matches equivalent representations by resolved size', () => {
expect(findSnapPointIndex(points, 0.5, WINDOW, REM)).toBe(1); // 0.5 * 800 = 400px
expect(findSnapPointIndex(points, 480, WINDOW, REM)).toBe(2); // 30rem = 480px
});
it('returns null when nothing matches', () => {
expect(findSnapPointIndex(points, 0.9, WINDOW, REM)).toBeNull();
expect(findSnapPointIndex(points, null, WINDOW, REM)).toBeNull();
expect(findSnapPointIndex(points, undefined, WINDOW, REM)).toBeNull();
});
});
describe('projectSnapRelease', () => {
// Dismiss-positive space on an 800px-tall drawer: fully open = 0, closed = 800.
const base = {
offsets: [600, 400, 0], // least → most visible
drawerSize: 800,
dismissible: true,
sequential: false,
};
it('snaps to the point nearest the drag target when slow', () => {
// From 600, dragged 180 toward open → 420 → nearest is 400.
expect(projectSnapRelease({
...base,
activeIndex: 0,
draggedDistance: 180,
velocity: 0,
})).toEqual({ type: 'snap', index: 1 });
});
it('stays on the active point after a tiny slow drag', () => {
expect(projectSnapRelease({
...base,
activeIndex: 0,
draggedDistance: 40,
velocity: 0,
})).toEqual({ type: 'snap', index: 0 });
});
it('projects a fling across points the drag alone would not reach', () => {
// From 600, dragged only 40 toward open, but flung at -1.5 px/ms
// (toward open) → 560 - 450 = 110 → nearest is 0 (fully open).
expect(projectSnapRelease({
...base,
activeIndex: 0,
draggedDistance: 40,
velocity: -1.5,
})).toEqual({ type: 'snap', index: 2 });
});
it('closes when the projection lands nearer to fully-closed', () => {
// From 600, dragged 100 toward dismiss → 700; 100 from closed vs 100 from
// 600 — ties stay open; add a dismiss fling to push past.
expect(projectSnapRelease({
...base,
activeIndex: 0,
draggedDistance: -100,
velocity: 0.6,
})).toEqual({ type: 'close' });
});
it('never closes a non-dismissible drawer', () => {
expect(projectSnapRelease({
...base,
dismissible: false,
activeIndex: 0,
draggedDistance: -150,
velocity: 2,
})).toEqual({ type: 'snap', index: 0 });
});
it('clamps the fling velocity', () => {
// Absurd velocity toward open must land on the last point, not overshoot
// into an invalid index.
expect(projectSnapRelease({
...base,
activeIndex: 0,
draggedDistance: 0,
velocity: -50,
})).toEqual({ type: 'snap', index: 2 });
});
it('skips NaN offsets', () => {
expect(projectSnapRelease({
...base,
offsets: [600, Number.NaN, 0],
activeIndex: 0,
draggedDistance: 250, // → 350, nearest usable is 600? |350-600|=250 vs |350-0|=350
velocity: 0,
})).toEqual({ type: 'snap', index: 0 });
});
describe('sequential mode', () => {
const sequential = { ...base, sequential: true };
it('advances a single step on a physical crossing', () => {
// From 600 dragged far toward open (target 100, crossed 400) — but only
// one step is allowed.
expect(projectSnapRelease({
...sequential,
activeIndex: 0,
draggedDistance: 500,
velocity: 0,
})).toEqual({ type: 'snap', index: 1 });
});
it('advances on a fast fling without a crossing', () => {
expect(projectSnapRelease({
...sequential,
activeIndex: 1,
draggedDistance: 60,
velocity: -0.8,
})).toEqual({ type: 'snap', index: 2 });
});
it('stays put on a slow drag without a crossing', () => {
expect(projectSnapRelease({
...sequential,
activeIndex: 1,
draggedDistance: 60,
velocity: 0,
})).toEqual({ type: 'snap', index: 1 });
});
});
});
@@ -24,3 +24,33 @@ export const WINDOW_TOP_OFFSET = 26;
/** Class applied to the drawer element while a drag is in progress. */ /** Class applied to the drawer element while a drag is in progress. */
export const DRAG_CLASS = 'drawer-dragging'; export const DRAG_CLASS = 'drawer-dragging';
/** Smallest dt (ms) a velocity sample may span — clamps out same-frame event spikes. */
export const MIN_VELOCITY_DT = 16;
/** A velocity sample older than this (ms) at release means the pointer stopped — velocity is 0. */
export const MAX_VELOCITY_AGE = 80;
/** Dismiss displacement (px) a gesture must reach before the reverse-cancel detector arms. */
export const REVERSE_CANCEL_ARM_DISTANCE = 20;
/** Pulling back this many px from the gesture's furthest point cancels the dismiss. */
export const REVERSE_CANCEL_THRESHOLD = 10;
/** Pointer movement (px) needed before the gesture locks onto an axis. */
export const AXIS_LOCK_DISTANCE = 2;
/** Snap release: velocity (px/ms) below which the fling projection is skipped. */
export const SNAP_VELOCITY_THRESHOLD = 0.5;
/** Snap release: ms worth of travel a fling projects the release target ahead. */
export const SNAP_VELOCITY_MULTIPLIER = 300;
/** Snap release: velocity clamp (px/ms) for the fling projection. */
export const MAX_SNAP_VELOCITY = 4;
/** Release velocity (px/ms) below which the settle keeps the default duration. */
export const SETTLE_VELOCITY_THRESHOLD = 0.2;
/** Fastest settle transition (ms) a hard flick can produce. */
export const MIN_SETTLE_DURATION = 80;
+38 -10
View File
@@ -1,13 +1,24 @@
import type { Ref } from 'vue'; import type { Ref, ShallowRef } from 'vue';
import { useContextFactory } from '@robonen/vue'; import { useContextFactory } from '@robonen/vue';
import type { MaybeElementRef } from '@robonen/vue'; import type { MaybeElementRef } from '@robonen/vue';
import type { DrawerDirection } from './types'; import type { DrawerDirection, DrawerOpenChangeReason, DrawerPhase } from './types';
export interface DrawerRootContext { export interface DrawerRootContext {
/** Source-of-truth open state (also bound to the underlying Dialog). */ /** Source-of-truth open state (also bound to the underlying Dialog). */
open: Ref<boolean>; open: Ref<boolean>;
/** Alias of {@link open}; kept for parity with consumers reading `isOpen`. */ /** Alias of {@link open}; kept for parity with consumers reading `isOpen`. */
isOpen: Ref<boolean>; isOpen: Ref<boolean>;
/**
* Lifecycle phase of the drawer unlike {@link open}, the enter/exit
* transitions are explicit states (`opening`/`closing`).
*/
phase: Readonly<ShallowRef<DrawerPhase>>;
/**
* Signal that the open/close animation settled. Called by DrawerRoot when the
* drawer element's transition/animation ends (or its fallback timeout fires);
* advances {@link phase} out of `opening`/`closing`.
*/
notifySettled: () => void;
/** Whether the drawer blocks the rest of the page (focus trap, scroll lock). */ /** Whether the drawer blocks the rest of the page (focus trap, scroll lock). */
modal: Ref<boolean>; modal: Ref<boolean>;
/** Becomes `true` the first time the drawer opens; gates Safari position fixes. */ /** Becomes `true` the first time the drawer opens; gates Safari position fixes. */
@@ -20,11 +31,11 @@ export interface DrawerRootContext {
handleRef: MaybeElementRef<HTMLElement | undefined>; handleRef: MaybeElementRef<HTMLElement | undefined>;
/** Whether a pointer drag is currently in progress. */ /** Whether a pointer drag is currently in progress. */
isDragging: Ref<boolean>; isDragging: Ref<boolean>;
/** Timestamp the active drag started, for velocity calculations. */ /** `event.timeStamp` of the active drag's start (ms, `performance.now()` clock). */
dragStartTime: Ref<Date | null>; dragStartTime: Ref<number | null>;
/** Latched once a drag is permitted, so it can't be cancelled mid-gesture. */ /** Latched once a drag is permitted, so it can't be cancelled mid-gesture. */
isAllowedToDrag: Ref<boolean>; isAllowedToDrag: Ref<boolean>;
/** Configured snap points (fractions of the screen or px strings). */ /** Configured snap points (fractions of the screen, px numbers, or px/rem strings). */
snapPoints: Ref<Array<number | string> | undefined>; snapPoints: Ref<Array<number | string> | undefined>;
/** Whether any snap points are configured. */ /** Whether any snap points are configured. */
hasSnapPoints: Ref<boolean>; hasSnapPoints: Ref<boolean>;
@@ -38,18 +49,35 @@ export interface DrawerRootContext {
dismissible: Ref<boolean>; dismissible: Ref<boolean>;
/** Measured height of the drawer content in px. */ /** Measured height of the drawer content in px. */
drawerHeightRef: Ref<number>; drawerHeightRef: Ref<number>;
/** Pixel offset of each snap point along the drag axis. */ /** Pixel offset of each snap point along the drag axis (`NaN` for invalid points). */
snapPointsOffset: Ref<number[]>; snapPointsOffset: Ref<number[]>;
/** The edge the drawer is anchored to. */ /** The edge the drawer is anchored to. */
direction: Ref<DrawerDirection>; direction: Ref<DrawerDirection>;
/** Begin a drag gesture. */ /**
onPress: (event: PointerEvent) => void; * Begin a drag gesture. `captureTarget` is the element that receives pointer
* capture (defaults to the pressed element capturing any higher, e.g. on
* the drawer itself, would retarget `click` away from controls inside; the
* handle passes itself so `handleOnly` gestures keep receiving moves).
*/
onPress: (event: PointerEvent, captureTarget?: HTMLElement) => void;
/** Update the drawer position during a drag. */ /** Update the drawer position during a drag. */
onDrag: (event: PointerEvent) => void; onDrag: (event: PointerEvent) => void;
/** Settle the drawer (snap, close, or reset) when the pointer is released. */ /** Settle the drawer (snap, close, or reset) when the pointer is released. */
onRelease: (event: PointerEvent) => void; onRelease: (event: PointerEvent) => void;
/** Programmatically close the drawer. */ /**
closeDrawer: () => void; * Abort the active drag without a user release (`pointercancel`, lost
* capture): resets the drag state and settles the drawer back in place.
*/
onCancel: (event: PointerEvent) => void;
/** Programmatically close the drawer, optionally tagging what caused it. */
closeDrawer: (reason?: DrawerOpenChangeReason) => void;
/**
* Tag the next open-state flip with a reason. Consumed (and cleared) by
* DrawerRoot's `update:open` emitter; auto-expires when no flip follows.
*/
armReason: (reason: DrawerOpenChangeReason) => void;
/** Reason armed for the next open-state flip, if any. */
pendingReason: { current: DrawerOpenChangeReason | undefined };
/** Whether the overlay should fade with the drag at the current snap point. */ /** Whether the overlay should fade with the drag at the current snap point. */
shouldFade: Ref<boolean>; shouldFade: Ref<boolean>;
/** Snap point index from which the overlay starts fading. */ /** Snap point index from which the overlay starts fading. */
+436 -197
View File
@@ -2,21 +2,31 @@ import type { Ref } from 'vue';
import { computed, ref, shallowRef, watch, watchEffect } from 'vue'; import { computed, ref, shallowRef, watch, watchEffect } from 'vue';
import { isClient } from '@robonen/platform/multi'; import { isClient } from '@robonen/platform/multi';
import { getTranslate, resetStyle, setStyle } from '@robonen/platform/browsers'; import { getTranslate, resetStyle, setStyle } from '@robonen/platform/browsers';
import { dampenValue, getDrawerWrapper, isVertical } from './helpers'; import { useStateMachine, useTextSelection, useWindowSize } from '@robonen/vue';
import { BORDER_RADIUS, DRAG_CLASS, NESTED_DISPLACEMENT, TRANSITIONS, VELOCITY_THRESHOLD, WINDOW_TOP_OFFSET } from './constants'; import { dampenValue, getDrawerWrapper, getScaleFactor, isVertical, translate3d, translateAxis, writeTransform } from './helpers';
import {
AXIS_LOCK_DISTANCE,
BORDER_RADIUS,
DRAG_CLASS,
NESTED_DISPLACEMENT,
TRANSITIONS,
VELOCITY_THRESHOLD,
} from './constants';
import type { GestureAxis, ReverseCancelTracker, VelocityTracker } from './gesture';
import { computeSettleDuration, createReverseCancelTracker, createVelocityTracker, findScrollableAncestor, isAtScrollEdge } from './gesture';
import { useSnapPoints } from './useSnapPoints'; import { useSnapPoints } from './useSnapPoints';
import { usePositionFixed } from './usePositionFixed'; import { usePositionFixed } from './usePositionFixed';
import type { DrawerRootContext } from './context'; import type { DrawerRootContext } from './context';
import type { DrawerDirection } from './types'; import type { DrawerDirection, DrawerOpenChangeDetails, DrawerOpenChangeReason } from './types';
/** Shared, never-mutated — avoids allocating `{ transition: 'none' }` per drag frame. */ /** Shared, never-mutated — avoids allocating `{ transition: 'none' }` per drag frame. */
const STYLE_NO_TRANSITION = { transition: 'none' }; const STYLE_NO_TRANSITION = { transition: 'none' };
export interface WithoutFadeFromProps { export interface WithoutFadeFromProps {
/** /**
* Fractions (01) of the screen each snap point occupies, ordered from least * Snap points ordered from least to most visible: fractions (01) of the
* to most visible e.g. `[0.2, 0.5, 0.8]`. Px strings (e.g. `'200px'`) are * screen, raw pixel numbers (> 1), or `'Npx'`/`'Nrem'` strings e.g.
* also accepted and ignore screen height. * `[0.2, '148px', 0.8]`.
*/ */
snapPoints?: Array<number | string>; snapPoints?: Array<number | string>;
/** Index of the snap point from which the overlay fade begins. Defaults to the last. */ /** Index of the snap point from which the overlay fade begins. Defaults to the last. */
@@ -82,6 +92,12 @@ export type DrawerRootProps = {
handleOnly?: boolean; handleOnly?: boolean;
/** Don't restore scroll position when the drawer closes after a navigation. */ /** Don't restore scroll position when the drawer closes after a navigation. */
preventScrollRestoration?: boolean; preventScrollRestoration?: boolean;
/**
* Settle on the snap point adjacent to the active one (one step per gesture)
* instead of the nearest to where the drag ended.
* @default false
*/
snapToSequentialPoints?: boolean;
} & WithoutFadeFromProps; } & WithoutFadeFromProps;
export interface UseDrawerProps { export interface UseDrawerProps {
@@ -101,6 +117,7 @@ export interface UseDrawerProps {
noBodyStyles: Ref<boolean>; noBodyStyles: Ref<boolean>;
preventScrollRestoration: Ref<boolean>; preventScrollRestoration: Ref<boolean>;
handleOnly: Ref<boolean>; handleOnly: Ref<boolean>;
snapToSequentialPoints: Ref<boolean>;
} }
export interface DrawerRootEmits { export interface DrawerRootEmits {
@@ -110,8 +127,8 @@ export interface DrawerRootEmits {
(e: 'release', open: boolean): void; (e: 'release', open: boolean): void;
/** Fired when the drawer begins closing. */ /** Fired when the drawer begins closing. */
(e: 'close'): void; (e: 'close'): void;
/** Two-way binding for the open state. */ /** Two-way binding for the open state. `details.reason` says what flipped it. */
(e: 'update:open', open: boolean): void; (e: 'update:open', open: boolean, details?: DrawerOpenChangeDetails): void;
/** Two-way binding for the active snap point. */ /** Two-way binding for the active snap point. */
(e: 'update:activeSnapPoint', val: string | number): void; (e: 'update:activeSnapPoint', val: string | number): void;
/** Fired after the open/close animation ends, with the open state at that time. */ /** Fired after the open/close animation ends, with the open state at that time. */
@@ -129,6 +146,47 @@ export interface DrawerHandleProps {
preventCycle?: boolean; preventCycle?: boolean;
} }
/**
* Everything the drag hot path needs, snapshotted once at `onPress` so no
* pointer-move ever reads layout (`getBoundingClientRect`/`getComputedStyle`),
* queries the document, or allocates. Discarded on release/cancel.
*/
interface GestureState {
pointerId: number;
captureTarget: Element;
vertical: boolean;
/** +1 when the dismiss direction increases the client coordinate (bottom/right). */
multiplier: 1 | -1;
startX: number;
startY: number;
/** Drawer size (px) along the drag axis, measured once at press. */
size: number;
/** Window dimension (px) along the drag axis. */
windowSize: number;
/** Background-scale factor, cached so drag frames don't read `window.innerWidth`. */
scale: number;
/**
* Inline translate currently applied to the drawer (px, signed). Seeded from
* the computed style once at press (so a mid-animation grab starts from the
* on-screen position) and mirrored on every write afterwards the drag path
* never reads computed styles.
*/
translate: number;
wrapper: HTMLElement | null;
/** Nearest scrollable ancestor under the pointer along the drag axis. */
scroller: HTMLElement | null;
/** Whether the first significant movement has locked the gesture's axis. */
axisLocked: boolean;
/** The gesture locked onto the cross axis — never a drawer drag. */
blocked: boolean;
velocity: VelocityTracker;
reverse: ReverseCancelTracker;
/** Last written overlay opacity (`''` = none yet) — skips redundant writes. */
lastOverlayOpacity: string;
/** Last wrapper-scale progress written (`-1` = none yet) — skips redundant writes. */
lastWrapperProgress: number;
}
function usePropOrDefaultRef<T>(prop: Ref<T | undefined> | undefined, defaultRef: Ref<T>): Ref<T> { function usePropOrDefaultRef<T>(prop: Ref<T | undefined> | undefined, defaultRef: Ref<T>): Ref<T> {
return prop && !!prop.value ? (prop as Ref<T>) : defaultRef; return prop && !!prop.value ? (prop as Ref<T>) : defaultRef;
} }
@@ -157,19 +215,19 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
noBodyStyles, noBodyStyles,
handleOnly, handleOnly,
preventScrollRestoration, preventScrollRestoration,
snapToSequentialPoints,
} = props; } = props;
const hasBeenOpened = ref(open.value); const hasBeenOpened = ref(open.value);
const isDragging = ref(false); const isDragging = ref(false);
const justReleased = ref(false); const isAllowedToDrag = ref(false);
const dragStartTime = ref<number | null>(null);
const overlayRef = shallowRef<HTMLElement | undefined>(undefined); const overlayRef = shallowRef<HTMLElement | undefined>(undefined);
const openTime = ref<Date | null>(null); // Timestamps on the `performance.now()` clock (same origin as event.timeStamp).
const dragStartTime = ref<Date | null>(null); let openTime: number | null = null;
const dragEndTime = ref<Date | null>(null); let lastTimeDragPrevented: number | null = null;
const lastTimeDragPrevented = ref<Date | null>(null);
const isAllowedToDrag = ref(false);
const nestedOpenChangeTimer = ref<number | null>(null); const nestedOpenChangeTimer = ref<number | null>(null);
@@ -185,11 +243,31 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
const handleRef = shallowRef<HTMLElement | undefined>(undefined); const handleRef = shallowRef<HTMLElement | undefined>(undefined);
// Shared reactive window dimensions (0 during SSR) and text selection — one
// listener each, reused by the gesture, the scale math, and the snap engine.
const { width: windowWidth, height: windowHeight } = useWindowSize({ initialWidth: 0, initialHeight: 0 });
const { text: selectedText } = useTextSelection();
/** Reason armed for the next open-state flip; consumed by DrawerRoot's emitter. */
const pendingReason: { current: DrawerOpenChangeReason | undefined } = { current: undefined };
function armReason(reason: DrawerOpenChangeReason) {
pendingReason.current = reason;
// Auto-expire so a dismiss that ends up prevented can't mislabel a later
// programmatic flip. The open watcher (microtask) always wins this timeout.
setTimeout(() => {
if (pendingReason.current === reason)
pendingReason.current = undefined;
}, 0);
}
const { const {
activeSnapPointIndex, activeSnapPointIndex,
onRelease: onReleaseSnapPoints, onRelease: onReleaseSnapPoints,
snapPointsOffset, snapPointsOffset,
onDrag: onDragSnapPoints, onDrag: onDragSnapPoints,
restoreActiveSnapPoint,
shouldFade, shouldFade,
getPercentageDragged: getSnapPointsPercentageDragged, getPercentageDragged: getSnapPointsPercentageDragged,
} = useSnapPoints({ } = useSnapPoints({
@@ -200,13 +278,16 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
overlayRef, overlayRef,
onSnapPointChange, onSnapPointChange,
direction, direction,
snapToSequentialPoints,
windowWidth,
windowHeight,
}); });
function onSnapPointChange(activeSnapPointIndex: number, snapPointsOffset: number[]) { function onSnapPointChange(activeSnapPointIndex: number, snapPointsOffset: number[]) {
// Refresh openTime when we reach the last snap point so scrollable content // Refresh openTime when we reach the last snap point so scrollable content
// there isn't immediately draggable. // there isn't immediately draggable.
if (snapPoints.value && activeSnapPointIndex === snapPointsOffset.length - 1) if (snapPoints.value && activeSnapPointIndex === snapPointsOffset.length - 1)
openTime.value = new Date(); openTime = performance.now();
} }
usePositionFixed({ usePositionFixed({
@@ -218,100 +299,197 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
preventScrollRestoration, preventScrollRestoration,
}); });
function getScale() { // The drawer's lifecycle as explicit phases. `OPEN`/`CLOSE` are driven by the
return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth; // shared `open` ref below; `SETTLE` arrives from DrawerRoot when the enter/exit
} // animation actually ends (element event or its fallback timeout). Close-side
// cleanup lives on the `closed` entry hook instead of duration-guessing
// timeouts: re-opening mid-close moves `closing → opening`, so it can never
// fire on a live drawer.
const lifecycle = useStateMachine({
initial: open.value ? 'open' : 'closed',
states: {
closed: {
entry: () => {
if (snapPoints.value)
activeSnapPoint.value = snapPoints.value[0];
},
on: { OPEN: 'opening' },
},
opening: {
entry: () => {
openTime = performance.now();
hasBeenOpened.value = true;
// A fast-flick close writes an inline animation-duration override;
// reopening before that exit settles reuses the SAME element
// (Presence keeps it alive), so clear the override here or the enter
// — and any later gentle exit — replays at flick speed.
drawerRef.value?.style.removeProperty('animation-duration');
overlayRef.value?.style.removeProperty('animation-duration');
},
on: { SETTLE: 'open', CLOSE: 'closing' },
},
open: { on: { CLOSE: 'closing' } },
closing: { on: { SETTLE: 'closed', OPEN: 'opening' } },
},
});
let gesture: GestureState | null = null;
function shouldDrag(el: EventTarget | null, isDraggingInDirection: boolean, now: number): boolean {
const g = gesture!;
function shouldDrag(el: EventTarget | null, isDraggingInDirection: boolean) {
if (!el) if (!el)
return false; return false;
let element = el as HTMLElement;
const highlightedText = globalThis.getSelection()?.toString();
const swipeAmount = drawerRef.value ? getTranslate(drawerRef.value, isVertical(direction.value) ? 'y' : 'x') : null;
const date = new Date();
if (element.hasAttribute('data-drawer-no-drag') || element.closest('[data-drawer-no-drag]')) const element = el as HTMLElement;
if (element.closest?.('[data-drawer-no-drag]'))
return false; return false;
if (direction.value === 'right' || direction.value === 'left')
return true;
// Allow scrolling during the open animation. // Allow scrolling during the open animation.
if (openTime.value && date.getTime() - openTime.value.getTime() < 500) if (openTime !== null && now - openTime < 500)
return false; return false;
if (swipeAmount !== null) { // Partially hidden (a snap point below fully open, or a mid-animation
if (direction.value === 'bottom' ? swipeAmount > 0 : swipeAmount < 0) // grab) — the drawer is always draggable.
return true; const swipeAmount = g.translate;
}
// Don't drag when text is selected. if (g.multiplier === 1 ? swipeAmount > 0 : swipeAmount < 0)
if (highlightedText && highlightedText.length > 0) return true;
// Don't drag when text is selected (reactive — no per-move getSelection).
if (selectedText.value.length > 0)
return false; return false;
// Don't drag right after scrolling inside the drawer. // Don't drag right after scrolling inside the drawer.
if ( if (
lastTimeDragPrevented.value lastTimeDragPrevented !== null
&& date.getTime() - lastTimeDragPrevented.value.getTime() < scrollLockTimeout.value && now - lastTimeDragPrevented < scrollLockTimeout.value
&& swipeAmount === 0 && swipeAmount === 0
) { ) {
lastTimeDragPrevented.value = date; lastTimeDragPrevented = now;
return false; return false;
} }
if (isDraggingInDirection) { if (isDraggingInDirection) {
lastTimeDragPrevented.value = date; lastTimeDragPrevented = now;
// Dragging in the open direction → allow scrolling instead. // Dragging in the open direction → allow scrolling instead.
return false; return false;
} }
// Walk up the tree; if a scrollable ancestor isn't at the top, scroll it instead of dragging. // A scroll container under the pointer owns the gesture unless it already
while (element) { // sits at the edge the dismiss direction pulls away from.
if (element.scrollHeight > element.clientHeight) { if (g.scroller && !isAtScrollEdge(g.scroller, direction.value)) {
if (element.scrollTop !== 0) { lastTimeDragPrevented = now;
lastTimeDragPrevented.value = new Date();
return false; return false;
} }
if (element.getAttribute('role') === 'dialog')
return true; return true;
} }
element = element.parentNode as HTMLElement; function onPress(event: PointerEvent, captureTarget?: HTMLElement) {
// One gesture at a time; a second touch never steals an active drag. But a
// gesture whose capture element left the DOM can never finish (its
// lostpointercapture fires at the document, past our listeners) — reclaim
// it instead of wedging every future drag.
if (gesture) {
if (gesture.captureTarget.isConnected)
return;
gesture = null;
isAllowedToDrag.value = false;
isDragging.value = false;
drawerRef.value?.classList.remove(DRAG_CLASS);
} }
return true;
}
// Measured once per gesture in onPress and reused every move — avoids a
// per-frame getBoundingClientRect (forced reflow) and document.querySelector.
let dragStartHeight = 0;
let dragWrapper: HTMLElement | null = null;
function onPress(event: PointerEvent) {
if (!dismissible.value && !snapPoints.value) if (!dismissible.value && !snapPoints.value)
return; return;
if (drawerRef.value && !drawerRef.value.contains(event.target as Node)) if (event.button > 0)
return; return;
isDragging.value = true;
dragStartTime.value = new Date();
dragStartHeight = drawerRef.value?.getBoundingClientRect().height || 0;
dragWrapper = getDrawerWrapper();
(event.target as HTMLElement).setPointerCapture(event.pointerId); const el = drawerRef.value;
pointerStart.value = isVertical(direction.value) ? event.clientY : event.clientX;
if (!el || !el.contains(event.target as Node))
return;
const vertical = isVertical(direction.value);
const axis: GestureAxis = vertical ? 'y' : 'x';
const rect = el.getBoundingClientRect();
// Capture on the pressed element, never the drawer: while a capture is
// active the compat mouse events retarget to the capturing element, so
// capturing on the drawer would swallow `click` for every control inside it.
const capture = captureTarget ?? (event.target as Element);
// Synthetic pointers (tests) and already-released pointers have no active
// pointer id to capture — the drag still works, only retargeting is lost.
try {
capture.setPointerCapture(event.pointerId);
}
catch {
// No active pointer to capture — the drag still works, only retargeting is lost.
}
isDragging.value = true;
dragStartTime.value = event.timeStamp;
pointerStart.value = vertical ? event.clientY : event.clientX;
gesture = {
pointerId: event.pointerId,
captureTarget: capture,
vertical,
multiplier: direction.value === 'bottom' || direction.value === 'right' ? 1 : -1,
startX: event.clientX,
startY: event.clientY,
size: (vertical ? rect.height : rect.width) || 0,
windowSize: vertical ? windowHeight.value : windowWidth.value,
scale: getScaleFactor(windowWidth.value),
// The one intentional computed-style read of the gesture: catches the
// drawer mid-animation so the drag continues from the on-screen position.
translate: getTranslate(el, axis) ?? 0,
wrapper: getDrawerWrapper(),
scroller: findScrollableAncestor(event.target as Element, el, axis),
axisLocked: false,
blocked: false,
velocity: createVelocityTracker(),
reverse: createReverseCancelTracker(),
lastOverlayOpacity: '',
lastWrapperProgress: -1,
};
} }
function onDrag(event: PointerEvent) { function onDrag(event: PointerEvent) {
if (!drawerRef.value) const g = gesture;
if (!g || event.pointerId !== g.pointerId || !isDragging.value || g.blocked || !drawerRef.value)
return; return;
if (isDragging.value) { const dx = event.clientX - g.startX;
const directionMultiplier = direction.value === 'bottom' || direction.value === 'right' ? 1 : -1; const dy = event.clientY - g.startY;
const draggedDistance
= (pointerStart.value - (isVertical(direction.value) ? event.clientY : event.clientX)) * directionMultiplier; // Lock onto an axis on the first significant movement. A gesture that
// locks onto the cross axis is a scroll/pan — never a drawer drag.
if (!g.axisLocked) {
const absX = Math.abs(dx);
const absY = Math.abs(dy);
if (absX < AXIS_LOCK_DISTANCE && absY < AXIS_LOCK_DISTANCE)
return;
g.axisLocked = true;
if ((absX > absY) === g.vertical) {
g.blocked = true;
return;
}
}
g.velocity.add(g.vertical ? event.clientY : event.clientX, event.timeStamp);
const draggedDistance = (g.vertical ? g.startY - event.clientY : g.startX - event.clientX) * g.multiplier;
const isDraggingInDirection = draggedDistance > 0; const isDraggingInDirection = draggedDistance > 0;
// Dismiss-positive displacement feeds the "changed my mind" detector.
g.reverse.update(-draggedDistance);
// Don't allow dragging toward close past the first snap point when not dismissible. // Don't allow dragging toward close past the first snap point when not dismissible.
const noCloseSnapPointsPreCondition = snapPoints.value && !dismissible.value && !isDraggingInDirection; const noCloseSnapPointsPreCondition = snapPoints.value && !dismissible.value && !isDraggingInDirection;
@@ -319,10 +497,9 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
return; return;
const absDraggedDistance = Math.abs(draggedDistance); const absDraggedDistance = Math.abs(draggedDistance);
const wrapper = dragWrapper;
// 1 means the closed position. Height cached at drag start (no reflow). // 1 means the closed position. Size cached at drag start (no reflow).
let percentageDragged = absDraggedDistance / (dragStartHeight || 1); let percentageDragged = absDraggedDistance / (g.size || 1);
const snapPointPercentageDragged = getSnapPointsPercentageDragged(absDraggedDistance, isDraggingInDirection); const snapPointPercentageDragged = getSnapPointsPercentageDragged(absDraggedDistance, isDraggingInDirection);
if (snapPointPercentageDragged !== null) if (snapPointPercentageDragged !== null)
@@ -335,7 +512,7 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
// for the whole gesture, so the class add + transition writes fire ONCE, // for the whole gesture, so the class add + transition writes fire ONCE,
// not on every move. // not on every move.
if (!isAllowedToDrag.value) { if (!isAllowedToDrag.value) {
if (!shouldDrag(event.target, isDraggingInDirection)) if (!shouldDrag(event.target, isDraggingInDirection, event.timeStamp))
return; return;
isAllowedToDrag.value = true; isAllowedToDrag.value = true;
drawerRef.value.classList.add(DRAG_CLASS); drawerRef.value.classList.add(DRAG_CLASS);
@@ -343,78 +520,79 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
setStyle(overlayRef.value, STYLE_NO_TRANSITION); setStyle(overlayRef.value, STYLE_NO_TRANSITION);
} }
if (snapPoints.value) if (snapPoints.value) {
onDragSnapPoints({ draggedDistance }); const applied = onDragSnapPoints({ draggedDistance });
if (applied !== null)
g.translate = applied;
}
// Rubber-band past the open position when there are no snap points. // Rubber-band past the open position when there are no snap points.
if (isDraggingInDirection && !snapPoints.value) { if (isDraggingInDirection && !snapPoints.value) {
const dampenedDraggedDistance = dampenValue(draggedDistance); const dampenedDraggedDistance = dampenValue(draggedDistance);
const translateValue = Math.min(dampenedDraggedDistance * -1, 0) * g.multiplier;
const translateValue = Math.min(dampenedDraggedDistance * -1, 0) * directionMultiplier; writeTransform(drawerRef.value, translateAxis(g.vertical, translateValue));
setStyle(drawerRef.value, { g.translate = translateValue;
transform: isVertical(direction.value)
? `translate3d(0, ${translateValue}px, 0)`
: `translate3d(${translateValue}px, 0, 0)`,
});
return; return;
} }
const opacityValue = 1 - percentageDragged;
if (shouldFade.value || (fadeFromIndex.value && activeSnapPointIndex.value === fadeFromIndex.value - 1)) { if (shouldFade.value || (fadeFromIndex.value && activeSnapPointIndex.value === fadeFromIndex.value - 1)) {
emitDrag(percentageDragged); emitDrag(percentageDragged);
setStyle(overlayRef.value, { opacity: `${opacityValue}`, transition: 'none' }, true); const overlay = overlayRef.value;
const opacity = `${1 - percentageDragged}`;
if (overlay && opacity !== g.lastOverlayOpacity) {
g.lastOverlayOpacity = opacity;
overlay.style.opacity = opacity;
overlay.style.transition = 'none';
}
} }
if (wrapper && overlayRef.value && shouldScaleBackground.value) { if (g.wrapper && overlayRef.value && shouldScaleBackground.value && percentageDragged !== g.lastWrapperProgress) {
const scaleValue = Math.min(getScale() + percentageDragged * (1 - getScale()), 1); g.lastWrapperProgress = percentageDragged;
const scaleValue = Math.min(g.scale + percentageDragged * (1 - g.scale), 1);
const borderRadiusValue = 8 - percentageDragged * 8; const borderRadiusValue = 8 - percentageDragged * 8;
const translateValue = Math.max(0, 14 - percentageDragged * 14); const translateValue = Math.max(0, 14 - percentageDragged * 14);
const style = g.wrapper.style;
setStyle( style.borderRadius = `${borderRadiusValue}px`;
wrapper, style.transform = g.vertical
{
borderRadius: `${borderRadiusValue}px`,
transform: isVertical(direction.value)
? `scale(${scaleValue}) translate3d(0, ${translateValue}px, 0)` ? `scale(${scaleValue}) translate3d(0, ${translateValue}px, 0)`
: `scale(${scaleValue}) translate3d(${translateValue}px, 0, 0)`, : `scale(${scaleValue}) translate3d(${translateValue}px, 0, 0)`;
transition: 'none', style.transition = 'none';
},
true,
);
} }
if (!snapPoints.value) { if (!snapPoints.value) {
const translateValue = absDraggedDistance * directionMultiplier; const translateValue = absDraggedDistance * g.multiplier;
setStyle(drawerRef.value, { writeTransform(drawerRef.value, translateAxis(g.vertical, translateValue));
transform: isVertical(direction.value) g.translate = translateValue;
? `translate3d(0, ${translateValue}px, 0)`
: `translate3d(${translateValue}px, 0, 0)`,
});
}
} }
} }
function resetDrawer() { function resetDrawer(duration: number = TRANSITIONS.DURATION, currentSwipeAmount?: number | null) {
if (!drawerRef.value) if (!drawerRef.value)
return; return;
const wrapper = getDrawerWrapper(); const wrapper = getDrawerWrapper();
const currentSwipeAmount = getTranslate(drawerRef.value, isVertical(direction.value) ? 'y' : 'x'); const swipeAmount = currentSwipeAmount
?? getTranslate(drawerRef.value, isVertical(direction.value) ? 'y' : 'x');
const ease = `cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
setStyle(drawerRef.value, { setStyle(drawerRef.value, {
transform: 'translate3d(0, 0, 0)', transform: 'translate3d(0, 0, 0)',
transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`, transition: `transform ${duration}s ${ease}`,
}); });
setStyle(overlayRef.value, { setStyle(overlayRef.value, {
transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`, transition: `opacity ${duration}s ${ease}`,
opacity: '1', opacity: '1',
}); });
// Keep the background scaled if we didn't swipe back down. // Keep the background scaled if we didn't swipe back down.
if (shouldScaleBackground.value && currentSwipeAmount && currentSwipeAmount > 0 && open.value) { if (shouldScaleBackground.value && swipeAmount && swipeAmount > 0 && open.value) {
setStyle( setStyle(
wrapper, wrapper,
{ {
@@ -422,11 +600,11 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
overflow: 'hidden', overflow: 'hidden',
...(isVertical(direction.value) ...(isVertical(direction.value)
? { ? {
transform: `scale(${getScale()}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)`, transform: `scale(${getScaleFactor(windowWidth.value)}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)`,
transformOrigin: 'top', transformOrigin: 'top',
} }
: { : {
transform: `scale(${getScale()}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)`, transform: `scale(${getScaleFactor(windowWidth.value)}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)`,
transformOrigin: 'left', transformOrigin: 'left',
}), }),
transitionProperty: 'transform, border-radius', transitionProperty: 'transform, border-radius',
@@ -442,13 +620,136 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
// snap-point reset, update:open) is driven off the `open` transition below, so // snap-point reset, update:open) is driven off the `open` transition below, so
// this stays the single place that closes — whatever the trigger (drag, handle, // this stays the single place that closes — whatever the trigger (drag, handle,
// dialog dismissal, or a controlled `v-model:open` flip). // dialog dismissal, or a controlled `v-model:open` flip).
function closeDrawer() { function closeDrawer(reason?: DrawerOpenChangeReason) {
if (!drawerRef.value) if (!drawerRef.value)
return; return;
if (reason)
armReason(reason);
open.value = false; open.value = false;
} }
/**
* Close via the exit keyframes, scaled to the fling: the inline
* `animation-duration` overrides the stylesheet's 0.5s so a hard flick
* finishes in as little as 80ms. A reopen before the exit settles reuses the
* same element (Presence holds it), so the `opening` entry hook clears the
* override before the enter plays.
*/
function closeWithSettle(remainingDistance: number, velocity: number) {
const duration = computeSettleDuration(remainingDistance, velocity);
if (duration !== TRANSITIONS.DURATION) {
const durationValue = `${duration}s`;
if (drawerRef.value)
drawerRef.value.style.animationDuration = durationValue;
if (overlayRef.value)
overlayRef.value.style.animationDuration = durationValue;
}
closeDrawer('swipe');
}
function endGesture(event: PointerEvent): GestureState | null {
const g = gesture;
if (!g || event.pointerId !== g.pointerId)
return null;
gesture = null;
drawerRef.value?.classList.remove(DRAG_CLASS);
try {
g.captureTarget.releasePointerCapture(event.pointerId);
}
catch {
// Capture was never acquired (synthetic pointer) or already released.
}
const wasAllowed = isAllowedToDrag.value;
isAllowedToDrag.value = false;
isDragging.value = false;
return wasAllowed ? g : null;
}
function onRelease(event: PointerEvent) {
if (!isDragging.value || !drawerRef.value) {
endGesture(event);
return;
}
const g = endGesture(event);
if (!g)
return;
const swipeAmount = g.translate;
const cancelled = g.reverse.cancelled;
const rawVelocity = g.velocity.read(event.timeStamp);
const velocityToDismiss = cancelled ? 0 : rawVelocity * g.multiplier;
const distMoved = g.vertical ? g.startY - event.clientY : g.startX - event.clientX;
const draggedDistance = distMoved * g.multiplier;
if (snapPoints.value) {
onReleaseSnapPoints({
draggedDistance,
closeDrawer: () => closeDrawer('swipe'),
velocity: velocityToDismiss,
dismissible: dismissible.value,
drawerSize: g.size,
});
emitRelease(true);
return;
}
// Moved toward open, or pulled back to cancel → settle into place.
if (draggedDistance > 0 || cancelled) {
resetDrawer(computeSettleDuration(Math.abs(swipeAmount), rawVelocity), swipeAmount);
emitRelease(true);
return;
}
const dismissTravel = swipeAmount * g.multiplier;
const remaining = Math.max(g.size - dismissTravel, 0);
if (velocityToDismiss > VELOCITY_THRESHOLD) {
closeWithSettle(remaining, velocityToDismiss);
emitRelease(false);
return;
}
const visibleSize = Math.min(g.size || 0, g.windowSize);
if (dismissTravel >= visibleSize * closeThreshold.value) {
closeWithSettle(remaining, velocityToDismiss);
emitRelease(false);
return;
}
emitRelease(true);
resetDrawer(computeSettleDuration(dismissTravel, rawVelocity), swipeAmount);
}
function onCancel(event: PointerEvent) {
const g = endGesture(event);
if (!g)
return;
// A cancelled pointer is not a user decision — settle back where the
// drawer was, never close.
if (snapPoints.value)
restoreActiveSnapPoint();
else
resetDrawer(TRANSITIONS.DURATION, g.translate);
emitRelease(true);
}
watchEffect(() => { watchEffect(() => {
if (!open.value && shouldScaleBackground.value && isClient) { if (!open.value && shouldScaleBackground.value && isClient) {
// The component is invisible by the time onAnimationEnd would fire, so use a timeout. // The component is invisible by the time onAnimationEnd would fire, so use a timeout.
@@ -462,99 +763,28 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
return undefined; return undefined;
}); });
function onRelease(event: PointerEvent) {
if (!isDragging.value || !drawerRef.value)
return;
drawerRef.value.classList.remove(DRAG_CLASS);
isAllowedToDrag.value = false;
isDragging.value = false;
dragWrapper = null;
dragEndTime.value = new Date();
const swipeAmount = getTranslate(drawerRef.value, isVertical(direction.value) ? 'y' : 'x');
if (!shouldDrag(event.target, false) || !swipeAmount || Number.isNaN(swipeAmount))
return;
if (dragStartTime.value === null)
return;
const timeTaken = dragEndTime.value.getTime() - dragStartTime.value.getTime();
const distMoved = pointerStart.value - (isVertical(direction.value) ? event.clientY : event.clientX);
const velocity = Math.abs(distMoved) / timeTaken;
if (velocity > 0.05) {
// Prevents the drawer from focusing an input as the drag ends.
justReleased.value = true;
globalThis.setTimeout(() => {
justReleased.value = false;
}, 200);
}
if (snapPoints.value) {
const directionMultiplier = direction.value === 'bottom' || direction.value === 'right' ? 1 : -1;
onReleaseSnapPoints({
draggedDistance: distMoved * directionMultiplier,
closeDrawer,
velocity,
dismissible: dismissible.value,
});
emitRelease(true);
return;
}
// Moved in the open direction → settle back.
if (direction.value === 'bottom' || direction.value === 'right' ? distMoved > 0 : distMoved < 0) {
resetDrawer();
emitRelease(true);
return;
}
if (velocity > VELOCITY_THRESHOLD) {
closeDrawer();
emitRelease(false);
return;
}
const visibleDrawerHeight = Math.min(drawerRef.value.getBoundingClientRect().height ?? 0, window.innerHeight);
if (swipeAmount >= visibleDrawerHeight * closeThreshold.value) {
closeDrawer();
emitRelease(false);
return;
}
emitRelease(true);
resetDrawer();
}
// Single owner of open/close side effects. Reacts to every source that writes // Single owner of open/close side effects. Reacts to every source that writes
// the shared `open` ref: the drag/handle paths (closeDrawer), the dialog's // the shared `open` ref: the drag/handle paths (closeDrawer), the dialog's
// dismissals (DrawerRoot.handleOpenChange), and a controlled `v-model:open` // dismissals (DrawerRoot.handleOpenChange), and a controlled `v-model:open`
// flip (DrawerRoot's prop watch). `update:open`/`animationEnd` are emitted by // flip (DrawerRoot's prop watch). `update:open`/`animationEnd` are emitted by
// DrawerRoot's own watch on the same ref. // DrawerRoot's own watch on the same ref; everything else rides the lifecycle
// machine's entry hooks.
watch(open, (o) => { watch(open, (o) => {
if (o) { if (o) {
openTime.value = new Date(); lifecycle.send('OPEN');
hasBeenOpened.value = true;
} }
else { else {
emitClose(); emitClose();
globalThis.setTimeout(() => { lifecycle.send('CLOSE');
if (snapPoints.value)
activeSnapPoint.value = snapPoints.value[0];
}, TRANSITIONS.DURATION * 1000);
} }
}); });
function onNestedOpenChange(o: boolean) { function onNestedOpenChange(o: boolean) {
const scale = o ? (window.innerWidth - NESTED_DISPLACEMENT) / window.innerWidth : 1; const scale = o ? (windowWidth.value - NESTED_DISPLACEMENT) / windowWidth.value : 1;
const y = o ? -NESTED_DISPLACEMENT : 0; const y = o ? -NESTED_DISPLACEMENT : 0;
if (nestedOpenChangeTimer.value) if (nestedOpenChangeTimer.value)
globalThis.clearTimeout(nestedOpenChangeTimer.value); clearTimeout(nestedOpenChangeTimer.value);
setStyle(drawerRef.value, { setStyle(drawerRef.value, {
transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`, transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
@@ -562,13 +792,11 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
}); });
if (!o && drawerRef.value) { if (!o && drawerRef.value) {
nestedOpenChangeTimer.value = globalThis.setTimeout(() => { nestedOpenChangeTimer.value = setTimeout(() => {
const translateValue = getTranslate(drawerRef.value!, isVertical(direction.value) ? 'y' : 'x'); const translateValue = getTranslate(drawerRef.value!, isVertical(direction.value) ? 'y' : 'x');
setStyle(drawerRef.value, { setStyle(drawerRef.value, {
transition: 'none', transition: 'none',
transform: isVertical(direction.value) transform: translate3d(direction.value, translateValue ?? 0),
? `translate3d(0, ${translateValue}px, 0)`
: `translate3d(${translateValue}px, 0, 0)`,
}); });
}, 500); }, 500);
} }
@@ -578,21 +806,25 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
if (percentageDragged < 0) if (percentageDragged < 0)
return; return;
const initialDim = isVertical(direction.value) ? window.innerHeight : window.innerWidth; const el = drawerRef.value;
if (!el)
return;
const initialDim = isVertical(direction.value) ? windowHeight.value : windowWidth.value;
const initialScale = (initialDim - NESTED_DISPLACEMENT) / initialDim; const initialScale = (initialDim - NESTED_DISPLACEMENT) / initialDim;
const newScale = initialScale + percentageDragged * (1 - initialScale); const newScale = initialScale + percentageDragged * (1 - initialScale);
const newTranslate = -NESTED_DISPLACEMENT + percentageDragged * NESTED_DISPLACEMENT; const newTranslate = -NESTED_DISPLACEMENT + percentageDragged * NESTED_DISPLACEMENT;
setStyle(drawerRef.value, { // Per-frame path (driven by the child's drag) — direct writes, no setStyle.
transform: isVertical(direction.value) el.style.transform = isVertical(direction.value)
? `scale(${newScale}) translate3d(0, ${newTranslate}px, 0)` ? `scale(${newScale}) translate3d(0, ${newTranslate}px, 0)`
: `scale(${newScale}) translate3d(${newTranslate}px, 0, 0)`, : `scale(${newScale}) translate3d(${newTranslate}px, 0, 0)`;
transition: 'none', el.style.transition = 'none';
});
} }
function onNestedRelease(o: boolean) { function onNestedRelease(o: boolean) {
const dim = isVertical(direction.value) ? window.innerHeight : window.innerWidth; const dim = isVertical(direction.value) ? windowHeight.value : windowWidth.value;
const scale = o ? (dim - NESTED_DISPLACEMENT) / dim : 1; const scale = o ? (dim - NESTED_DISPLACEMENT) / dim : 1;
const translate = o ? -NESTED_DISPLACEMENT : 0; const translate = o ? -NESTED_DISPLACEMENT : 0;
@@ -609,6 +841,10 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
return { return {
open, open,
isOpen: open, isOpen: open,
phase: lifecycle.state,
notifySettled: () => {
lifecycle.send('SETTLE');
},
modal, modal,
keyboardIsOpen, keyboardIsOpen,
hasBeenOpened, hasBeenOpened,
@@ -633,7 +869,10 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
onPress, onPress,
onDrag, onDrag,
onRelease, onRelease,
onCancel,
closeDrawer, closeDrawer,
armReason,
pendingReason,
onNestedDrag, onNestedDrag,
onNestedRelease, onNestedRelease,
onNestedOpenChange, onNestedOpenChange,
@@ -0,0 +1,178 @@
import { clamp } from '@robonen/stdlib';
import type { DrawerDirection } from './types';
import {
MAX_VELOCITY_AGE,
MIN_SETTLE_DURATION,
MIN_VELOCITY_DT,
REVERSE_CANCEL_ARM_DISTANCE,
REVERSE_CANCEL_THRESHOLD,
SETTLE_VELOCITY_THRESHOLD,
TRANSITIONS,
} from './constants';
/** The client-coordinate axis a drawer drags along. */
export type GestureAxis = 'x' | 'y';
export interface VelocityTracker {
/** Record a pointer sample (client coordinate along the axis + event timeStamp). */
add: (position: number, time: number) => void;
/**
* Instantaneous velocity (px/ms) over the two newest samples. Returns 0 when
* the last sample is older than {@link MAX_VELOCITY_AGE} the pointer paused
* before release, so no fling momentum should apply.
*/
read: (now: number) => number;
reset: () => void;
}
/**
* Instantaneous release velocity from the trailing pair of pointer samples,
* instead of averaging the whole gesture: "slow pull, then flick" reads as a
* flick, and "fast start, stop, release" reads as a stop.
*/
export function createVelocityTracker(): VelocityTracker {
let lastPosition = 0;
let lastTime = Number.NaN;
let velocity = 0;
return {
add(position, time) {
if (!Number.isNaN(lastTime) && time > lastTime) {
// Clamp dt so same-frame event bursts don't produce huge spikes.
const dt = Math.max(time - lastTime, MIN_VELOCITY_DT);
velocity = (position - lastPosition) / dt;
}
lastPosition = position;
lastTime = time;
},
read(now) {
if (Number.isNaN(lastTime) || now - lastTime > MAX_VELOCITY_AGE)
return 0;
return velocity;
},
reset() {
lastPosition = 0;
lastTime = Number.NaN;
velocity = 0;
},
};
}
export interface ReverseCancelTracker {
/** Feed the current dismiss-positive displacement (px). */
update: (displacement: number) => void;
/** Whether the gesture pulled back far enough to cancel the dismiss. */
readonly cancelled: boolean;
reset: () => void;
}
/**
* Detects the "changed my mind" gesture: once the drawer has been dragged at
* least {@link REVERSE_CANCEL_ARM_DISTANCE} toward dismiss, pulling back by
* {@link REVERSE_CANCEL_THRESHOLD} from the furthest point cancels the dismiss
* even if the release still sits past the close threshold. Dragging past the
* previous furthest point re-arms the dismiss (renewed intent).
*/
export function createReverseCancelTracker(): ReverseCancelTracker {
let max = 0;
let cancelled = false;
return {
update(displacement) {
if (displacement >= max) {
max = displacement;
cancelled = false;
return;
}
if (max > REVERSE_CANCEL_ARM_DISTANCE && max - displacement > REVERSE_CANCEL_THRESHOLD)
cancelled = true;
},
get cancelled() {
return cancelled;
},
reset() {
max = 0;
cancelled = false;
},
};
}
/**
* Settle duration (in seconds) scaled by the release velocity: a hard flick
* over a short remaining distance settles in as little as
* {@link MIN_SETTLE_DURATION}ms, while a gentle release keeps the default
* {@link TRANSITIONS} duration. Never returns a duration longer than the default.
*/
export function computeSettleDuration(remainingDistance: number, velocity: number): number {
const fallback = TRANSITIONS.DURATION;
if (!Number.isFinite(remainingDistance) || remainingDistance <= 0)
return fallback;
const speed = Math.abs(velocity);
if (!Number.isFinite(speed) || speed < SETTLE_VELOCITY_THRESHOLD)
return fallback;
return clamp(remainingDistance / speed, MIN_SETTLE_DURATION, fallback * 1000) / 1000;
}
/**
* The nearest ancestor (from `start` up to and including `boundary`) that can
* scroll along `axis`. The `getComputedStyle` read runs at most once per
* candidate and only at gesture start never per pointer move.
*/
export function findScrollableAncestor(
start: Element | null,
boundary: HTMLElement,
axis: GestureAxis,
): HTMLElement | null {
let element: Element | null = start;
while (element) {
if (element instanceof HTMLElement) {
const canScroll = axis === 'y'
? element.scrollHeight > element.clientHeight
: element.scrollWidth > element.clientWidth;
if (canScroll) {
const overflow = getComputedStyle(element)[axis === 'y' ? 'overflowY' : 'overflowX'];
if (overflow === 'auto' || overflow === 'scroll')
return element;
}
}
if (element === boundary)
break;
element = element.parentElement;
}
return null;
}
/**
* Whether a scroll container sits at the edge the dismiss gesture pulls away
* from only then may a drag that starts inside it become a drawer gesture;
* otherwise the user is scrolling, not dismissing:
* - `bottom` drawer dismisses downward the scroller must be at its top;
* - `top` drawer dismisses upward at its bottom;
* - `right` drawer dismisses rightward at its left edge;
* - `left` drawer dismisses leftward at its right edge.
*/
export function isAtScrollEdge(scroller: HTMLElement, direction: DrawerDirection): boolean {
switch (direction) {
case 'bottom':
return scroller.scrollTop <= 0;
case 'top':
return scroller.scrollTop >= scroller.scrollHeight - scroller.clientHeight;
case 'right':
return scroller.scrollLeft <= 0;
case 'left':
return scroller.scrollLeft >= scroller.scrollWidth - scroller.clientWidth;
}
}
@@ -1,4 +1,5 @@
import type { DrawerDirection } from './types'; import type { DrawerDirection } from './types';
import { WINDOW_TOP_OFFSET } from './constants';
/** /**
* Whether a direction runs along the vertical axis (`top`/`bottom`) as opposed * Whether a direction runs along the vertical axis (`top`/`bottom`) as opposed
@@ -25,3 +26,39 @@ export function dampenValue(v: number): number {
export function getDrawerWrapper(): HTMLElement | null { export function getDrawerWrapper(): HTMLElement | null {
return document.querySelector<HTMLElement>('[data-drawer-wrapper]'); return document.querySelector<HTMLElement>('[data-drawer-wrapper]');
} }
/**
* The background-scale factor for a given window width (the stacked-card look
* leaves {@link WINDOW_TOP_OFFSET}px of the page peeking out).
*/
export function getScaleFactor(windowWidth: number): number {
return (windowWidth - WINDOW_TOP_OFFSET) / windowWidth;
}
/**
* A GPU-friendly translate along an axis, from a pre-resolved axis flag the
* drag hot path variant: no direction-string comparisons per frame.
*/
export function translateAxis(vertical: boolean, value: number): string {
return vertical
? `translate3d(0, ${value}px, 0)`
: `translate3d(${value}px, 0, 0)`;
}
/**
* {@link translateAxis} keyed by direction, for cold paths that hold the
* direction string rather than a gesture snapshot.
*/
export function translate3d(direction: DrawerDirection, value: number): string {
return translateAxis(isVertical(direction), value);
}
/**
* Per-frame single-property transform write for the drag hot path. Unlike
* `setStyle` this allocates nothing (no patch object, no `Object.entries`, no
* restore snapshot) restoration is handled wholesale on release.
*/
export function writeTransform(element: HTMLElement | undefined | null, value: string): void {
if (element)
element.style.transform = value;
}
+5 -5
View File
@@ -3,11 +3,15 @@ export { default as DrawerRootNested } from './DrawerRootNested.vue';
export { default as DrawerContent } from './DrawerContent.vue'; export { default as DrawerContent } from './DrawerContent.vue';
export { default as DrawerOverlay } from './DrawerOverlay.vue'; export { default as DrawerOverlay } from './DrawerOverlay.vue';
export { default as DrawerHandle } from './DrawerHandle.vue'; export { default as DrawerHandle } from './DrawerHandle.vue';
export { default as DrawerTrigger } from './DrawerTrigger.vue';
export { default as DrawerClose } from './DrawerClose.vue';
export type { DrawerRootEmits, DrawerRootProps, DrawerHandleProps } from './controls'; export type { DrawerRootEmits, DrawerRootProps, DrawerHandleProps } from './controls';
export type { DrawerContentEmits, DrawerContentProps } from './DrawerContent.vue'; export type { DrawerContentEmits, DrawerContentProps } from './DrawerContent.vue';
export type { DrawerOverlayProps } from './DrawerOverlay.vue'; export type { DrawerOverlayProps } from './DrawerOverlay.vue';
export type { DrawerDirection, SnapPoint } from './types'; export type { DrawerTriggerProps } from './DrawerTrigger.vue';
export type { DrawerCloseProps } from './DrawerClose.vue';
export type { DrawerDirection, DrawerOpenChangeDetails, DrawerOpenChangeReason } from './types';
export { injectDrawerRootContext, provideDrawerRootContext } from './context'; export { injectDrawerRootContext, provideDrawerRootContext } from './context';
export type { DrawerRootContext } from './context'; export type { DrawerRootContext } from './context';
@@ -15,17 +19,13 @@ export type { DrawerRootContext } from './context';
// Parts with no drawer-specific behaviour reuse Dialog directly, re-exported // Parts with no drawer-specific behaviour reuse Dialog directly, re-exported
// under Drawer names so consumers stay within one namespace. // under Drawer names so consumers stay within one namespace.
export { export {
DialogClose as DrawerClose,
DialogDescription as DrawerDescription, DialogDescription as DrawerDescription,
DialogPortal as DrawerPortal, DialogPortal as DrawerPortal,
DialogTitle as DrawerTitle, DialogTitle as DrawerTitle,
DialogTrigger as DrawerTrigger,
} from '../dialog'; } from '../dialog';
export type { export type {
DialogCloseProps as DrawerCloseProps,
DialogDescriptionProps as DrawerDescriptionProps, DialogDescriptionProps as DrawerDescriptionProps,
DialogPortalProps as DrawerPortalProps, DialogPortalProps as DrawerPortalProps,
DialogTitleProps as DrawerTitleProps, DialogTitleProps as DrawerTitleProps,
DialogTriggerProps as DrawerTriggerProps,
} from '../dialog'; } from '../dialog';
@@ -0,0 +1,184 @@
import { clamp } from '@robonen/stdlib';
import type { DrawerDirection } from './types';
import { MAX_SNAP_VELOCITY, SNAP_VELOCITY_MULTIPLIER, SNAP_VELOCITY_THRESHOLD } from './constants';
const PX_RE = /^-?(?:\d+(?:\.\d+)?|\.\d+)px$/;
const REM_RE = /^-?(?:\d+(?:\.\d+)?|\.\d+)rem$/;
/**
* Resolve a snap point to the visible size (px) it gives the drawer along the
* drag axis:
* - a number in (0, 1] is a fraction of the window dimension;
* - a number above 1 is pixels;
* - `'Npx'` / `'Nrem'` strings are pixels (rem scaled by the root font size).
*
* Unknown units (`'50%'`, `'10vh'`) and non-finite/non-positive results are
* unsupported and resolve to `null` so they never reach the geometry as `NaN`.
*/
export function resolveSnapPointSize(
point: number | string,
windowSize: number,
rootFontSize: number,
): number | null {
let size: number | null = null;
if (typeof point === 'number')
size = point > 1 ? point : point * windowSize;
else if (PX_RE.test(point))
size = Number.parseFloat(point);
else if (REM_RE.test(point))
size = Number.parseFloat(point) * rootFontSize;
if (size === null || !Number.isFinite(size) || size <= 0)
return null;
return Math.round(size);
}
/**
* The inline translate (px, signed the way the drawer's transform is written)
* that shows exactly `point` worth of the drawer: positive toward the
* bottom/right edge, negative toward the top/left edge, clamped so a snap point
* larger than the window rests at fully open. Unresolvable points map to `NaN`
* callers must `Number.isFinite`-guard before using an offset.
*/
export function resolveSnapPointOffset(
point: number | string,
direction: DrawerDirection,
windowSize: number,
rootFontSize: number,
): number {
const size = resolveSnapPointSize(point, windowSize, rootFontSize);
if (size === null)
return Number.NaN;
const distance = Math.max(Math.round(windowSize - size), 0);
return direction === 'bottom' || direction === 'right' ? distance : -distance;
}
/**
* Index of the active snap point: matched by identity first, then by resolved
* size within a 1px tolerance, so a controlled drawer may use interchangeable
* representations (`0.5` vs `'360px'` on a 720px window). Returns `null` when
* nothing matches.
*/
export function findSnapPointIndex(
snapPoints: Array<number | string>,
active: number | string | null | undefined,
windowSize: number,
rootFontSize: number,
): number | null {
if (active === null || active === undefined)
return null;
const byIdentity = snapPoints.indexOf(active);
if (byIdentity !== -1)
return byIdentity;
const activeSize = resolveSnapPointSize(active, windowSize, rootFontSize);
if (activeSize === null)
return null;
const bySize = snapPoints.findIndex((point) => {
const size = resolveSnapPointSize(point, windowSize, rootFontSize);
return size !== null && Math.abs(size - activeSize) <= 1;
});
return bySize === -1 ? null : bySize;
}
export interface SnapReleaseInput {
/**
* Snap offsets in dismiss-positive space: 0 is fully open, larger is more
* hidden. `NaN` entries (unresolvable points) are skipped.
*/
offsets: number[];
activeIndex: number | null;
/** Drag distance since press, positive toward open/expand. */
draggedDistance: number;
/** Instantaneous release velocity, positive toward dismiss (px/ms). */
velocity: number;
/** Drawer size (px) along the drag axis — the fully-closed offset. */
drawerSize: number;
dismissible: boolean;
/** Step at most one snap point per gesture instead of jumping to the nearest. */
sequential: boolean;
}
export type SnapReleaseResult = { type: 'close' } | { type: 'snap'; index: number };
/**
* Where a snap-point drawer settles on release: the drag target is projected
* ahead along the release velocity (a fling crosses points a slow drag would
* not), then the nearest snap point wins or the drawer closes when the
* projection lands strictly closer to fully-closed and the drawer is
* dismissible. In `sequential` mode the result is clamped to the snap point
* adjacent to the active one.
*/
export function projectSnapRelease(input: SnapReleaseInput): SnapReleaseResult {
const { offsets, activeIndex, draggedDistance, velocity, drawerSize, dismissible, sequential } = input;
const active = activeIndex !== null && Number.isFinite(offsets[activeIndex])
? offsets[activeIndex]
: 0;
// Where the drag alone left the drawer, clamped to its travel range.
const dragTarget = clamp(active - draggedDistance, 0, drawerSize);
let target = dragTarget;
if (Math.abs(velocity) >= SNAP_VELOCITY_THRESHOLD)
target = dragTarget + clamp(velocity, -MAX_SNAP_VELOCITY, MAX_SNAP_VELOCITY) * SNAP_VELOCITY_MULTIPLIER;
let closestIndex = -1;
let closestDistance = Number.POSITIVE_INFINITY;
for (const [index, offset] of offsets.entries()) {
if (!Number.isFinite(offset))
continue;
const distance = Math.abs(target - offset);
if (distance < closestDistance) {
closestIndex = index;
closestDistance = distance;
}
}
if (closestIndex === -1)
return { type: 'snap', index: activeIndex ?? 0 };
if (dismissible && Math.abs(target - drawerSize) < closestDistance)
return { type: 'close' };
if (!sequential || activeIndex === null)
return { type: 'snap', index: closestIndex };
// Sequential mode: rank the usable points by offset and move at most one
// step toward the drag; a fast fling or a physical crossing of the adjacent
// point advances, anything else stays.
const stepDirection = Math.sign(dragTarget - active);
if (stepDirection === 0)
return { type: 'snap', index: activeIndex };
const order = offsets
.map((offset, index) => ({ offset, index }))
.filter(entry => Number.isFinite(entry.offset))
.sort((a, b) => a.offset - b.offset);
const rank = order.findIndex(entry => entry.index === activeIndex);
if (rank === -1)
return { type: 'snap', index: closestIndex };
const adjacent = order[clamp(rank + stepDirection, 0, order.length - 1)];
const flungPast = Math.sign(velocity) === stepDirection && Math.abs(velocity) >= SNAP_VELOCITY_THRESHOLD;
const crossed = stepDirection > 0 ? target > adjacent.offset : target < adjacent.offset;
return { type: 'snap', index: flungPast || crossed ? adjacent.index : activeIndex };
}
+29 -1
View File
@@ -8,7 +8,35 @@
* The selectors here mirror the `data-drawer-*` attributes set in the component * The selectors here mirror the `data-drawer-*` attributes set in the component
* templates and {@link ./controls} keep them in sync. * templates and {@link ./controls} keep them in sync.
*/ */
export const DRAWER_STYLE_ID = 'robonen-drawer'; export const DRAWER_STYLE_ID = 'drawer';
let cssPropertiesRegistered = false;
/**
* Registers the drawer's animated custom properties with `inherits: false`, so
* a per-frame write of `--snap-point-height` on the content invalidates only
* that element instead of cascading a var recompute over its whole subtree.
* `--initial-transform` is deliberately NOT registered: consumers may set it on
* an ancestor and rely on inheritance. No-op where the API is missing.
*/
export function registerDrawerCssProperties(): void {
if (cssPropertiesRegistered || typeof CSS === 'undefined' || !CSS.registerProperty)
return;
cssPropertiesRegistered = true;
try {
CSS.registerProperty({
name: '--snap-point-height',
syntax: '<length>',
inherits: false,
initialValue: '0px',
});
}
catch {
// Older engines without @property support simply keep var inheritance.
}
}
export const DRAWER_STYLES = ` export const DRAWER_STYLES = `
[data-drawer] { [data-drawer] {
+20 -5
View File
@@ -4,10 +4,25 @@
export type DrawerDirection = 'top' | 'bottom' | 'left' | 'right'; export type DrawerDirection = 'top' | 'bottom' | 'left' | 'right';
/** /**
* A resolved snap point: the original `fraction` (01 of the screen, or a raw * Lifecycle phase of the drawer. `opening`/`closing` last for the duration of
* px value) paired with its computed pixel `height`. * the enter/exit animation; the settle signal (animation end or its fallback
* timeout) advances them to `open`/`closed`.
*/ */
export interface SnapPoint { export type DrawerPhase = 'closed' | 'opening' | 'open' | 'closing';
fraction: number;
height: number; /**
* What flipped the drawer's open state. Absent details mean a programmatic
* change (a controlled `v-model:open` write).
*/
export type DrawerOpenChangeReason
= | 'swipe'
| 'escape-key'
| 'outside-press'
| 'trigger-press'
| 'close-press'
| 'handle-press';
/** Extra context attached to `update:open`. */
export interface DrawerOpenChangeDetails {
reason?: DrawerOpenChangeReason;
} }
@@ -79,7 +79,7 @@ export function usePositionFixed(options: PositionFixedOptions) {
Object.assign(document.body.style, previousBodyPosition); Object.assign(document.body.style, previousBodyPosition);
globalThis.requestAnimationFrame(() => { requestAnimationFrame(() => {
if (preventScrollRestoration.value && activeUrl.value !== globalThis.location.href) { if (preventScrollRestoration.value && activeUrl.value !== globalThis.location.href) {
activeUrl.value = globalThis.location.href; activeUrl.value = globalThis.location.href;
return; return;
@@ -2,8 +2,8 @@ import { onWatcherCleanup, ref, watchEffect } from 'vue';
import { isClient } from '@robonen/platform/multi'; import { isClient } from '@robonen/platform/multi';
import { assignStyle } from '@robonen/platform/browsers'; import { assignStyle } from '@robonen/platform/browsers';
import { injectDrawerRootContext } from './context'; import { injectDrawerRootContext } from './context';
import { getDrawerWrapper, isVertical } from './helpers'; import { getDrawerWrapper, getScaleFactor, isVertical } from './helpers';
import { BORDER_RADIUS, TRANSITIONS, WINDOW_TOP_OFFSET } from './constants'; import { BORDER_RADIUS, TRANSITIONS } from './constants';
/** /**
* Scales the page background down behind the drawer (the stacked-card effect), * Scales the page background down behind the drawer (the stacked-card effect),
@@ -16,10 +16,6 @@ export function useScaleBackground() {
const timeoutIdRef = ref<number | null>(null); const timeoutIdRef = ref<number | null>(null);
const initialBackgroundColor = ref(typeof document !== 'undefined' ? document.body.style.backgroundColor : ''); const initialBackgroundColor = ref(typeof document !== 'undefined' ? document.body.style.backgroundColor : '');
function getScale() {
return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth;
}
watchEffect(() => { watchEffect(() => {
// `flush: 'pre'` watchers run during SSR; this effect touches document/window, // `flush: 'pre'` watchers run during SSR; this effect touches document/window,
// so it must stay client-only. // so it must stay client-only.
@@ -42,17 +38,18 @@ export function useScaleBackground() {
transitionTimingFunction: `cubic-bezier(${TRANSITIONS.EASE.join(',')})`, transitionTimingFunction: `cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
}); });
const scale = getScaleFactor(window.innerWidth);
const wrapperStylesCleanup = assignStyle(wrapper, { const wrapperStylesCleanup = assignStyle(wrapper, {
borderRadius: `${BORDER_RADIUS}px`, borderRadius: `${BORDER_RADIUS}px`,
overflow: 'hidden', overflow: 'hidden',
...(isVertical(direction.value) ...(isVertical(direction.value)
? { transform: `scale(${getScale()}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)` } ? { transform: `scale(${scale}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)` }
: { transform: `scale(${getScale()}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)` }), : { transform: `scale(${scale}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)` }),
}); });
onWatcherCleanup(() => { onWatcherCleanup(() => {
wrapperStylesCleanup(); wrapperStylesCleanup();
timeoutIdRef.value = globalThis.setTimeout(() => { timeoutIdRef.value = setTimeout(() => {
if (initialBackgroundColor.value) if (initialBackgroundColor.value)
document.body.style.background = initialBackgroundColor.value; document.body.style.background = initialBackgroundColor.value;
else else

Some files were not shown because too many files have changed in this diff Show More