Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bc142af14 | |||
| e59e05ac05 | |||
| 85313c6046 | |||
| f444feb7b3 | |||
| 38c2cd5504 | |||
| c6b6b93e81 | |||
| 53e831894a | |||
| a8e5f63415 | |||
| 1cab48ca48 | |||
| 8ea85c4aab | |||
| 151e106aa7 | |||
| 655f30a658 | |||
| ab6d8f6ce0 | |||
| e73e8d2cdd | |||
| 6e70d4edd1 | |||
| c8c9676d1e | |||
| 91fa464d95 | |||
| b24291ac3a | |||
| 98b76f46cf | |||
| 0679160cb0 | |||
| 4c8c3a396e | |||
| 8adc2522c6 | |||
| be667df3d8 | |||
| a147ec0730 | |||
| aa2938cb34 | |||
| 858cd8f8e0 |
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 < upper</code>
|
<code class="text-accent-text">keyBetween</code> requires <code>lower < 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
@@ -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 & parsing</h3>
|
<h3 class="text-sm font-semibold text-fg">Smart bodies & 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 & errors</h3>
|
<h3 class="text-sm font-semibold text-fg">Retry, timeout & 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 & plugins</h3>
|
<h3 class="text-sm font-semibold text-fg">Hooks & 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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 & guides…
|
Type to search functions, components & guides…
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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, A–Z). */
|
||||||
|
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, A–Z), 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -20,21 +20,21 @@ useHead({ title: '@robonen/tools — Documentation' });
|
|||||||
|
|
||||||
<div class="comment-label mb-5">field manual · generated from source & jsdoc</div>
|
<div class="comment-label mb-5">field manual · generated from source & 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
@@ -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;
|
||||||
|
}, {}),
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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" */
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 |
@@ -0,0 +1,2 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
Generated
+4746
-5260
File diff suppressed because it is too large
Load Diff
+37
-10
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
globalThis.setTimeout(() => {
|
// The exit hook covers every transition; this covers unmount mid-window.
|
||||||
handleCycleSnapPoints();
|
onScopeDispose(() => clearTimeout(cycleTimer));
|
||||||
}, DOUBLE_TAP_TIMEOUT);
|
|
||||||
|
// 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCycleSnapPoints() {
|
// A long hold suppresses the tap-to-cycle. `distanceThreshold: false` keeps the
|
||||||
// Don't treat an accidental tap during a resize as a cycle.
|
// original semantics: the hold counts even while the pointer drags the drawer.
|
||||||
if (isDragging.value || preventCycle || shouldCancelInteraction.value) {
|
onLongPress(handleElement, () => {
|
||||||
handleCancelInteraction();
|
tap.send('LONG_PRESS');
|
||||||
return;
|
}, { delay: LONG_HANDLE_PRESS_TIMEOUT, distanceThreshold: false });
|
||||||
}
|
|
||||||
|
|
||||||
handleCancelInteraction();
|
function cycleSnapPoints() {
|
||||||
|
// Don't treat an accidental tap during a resize as a cycle.
|
||||||
|
if (isDragging.value || preventCycle)
|
||||||
|
return;
|
||||||
|
|
||||||
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)`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -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;
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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 (0–1) of the screen each snap point occupies, ordered from least
|
* Snap points ordered from least to most visible: fractions (0–1) 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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] {
|
||||||
|
|||||||
@@ -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` (0–1 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
Reference in New Issue
Block a user