Compare commits
17 Commits
3ab94768a4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 551b3bd921 | |||
| 1d105b1f55 | |||
| 66d9faad22 | |||
| edcccf16d8 | |||
| d2838ba8ee | |||
| cc93715c03 | |||
| ea96d720f2 | |||
| 6da5ecaa83 | |||
| 1d2130f279 | |||
| e59e05ac05 | |||
| 85313c6046 | |||
| f444feb7b3 | |||
| 38c2cd5504 | |||
| c6b6b93e81 | |||
| 53e831894a | |||
| a8e5f63415 | |||
| 1cab48ca48 |
@@ -53,7 +53,7 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"url": "git+https://github.com/robonen/tools.git",
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
"directory": "configs/eslint"
|
"directory": "configs/eslint"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.7.0",
|
"packageManager": "pnpm@11.18.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.16.0"
|
"node": ">=24.16.0"
|
||||||
},
|
},
|
||||||
@@ -48,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.20",
|
"@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": "^67.0.0",
|
"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.1",
|
"typescript-eslint": "^8.65.0",
|
||||||
"vue-eslint-parser": "^10.4.1"
|
"vue-eslint-parser": "^10.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@robonen/tsconfig",
|
"name": "@robonen/tsconfig",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"description": "Base typescript configuration for projects",
|
"description": "Base typescript configuration for projects",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -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@11.7.0",
|
"packageManager": "pnpm@11.18.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.16.0"
|
"node": ">=24.16.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"vueCompilerOptions": {
|
"vueCompilerOptions": {
|
||||||
"strictTemplates": true,
|
"strictTemplates": true,
|
||||||
"fallthroughAttributes": true,
|
"fallthroughAttributes": true,
|
||||||
|
"htmlAttributes": ["aria-*", "data-*"],
|
||||||
"inferTemplateDollarAttrs": true,
|
"inferTemplateDollarAttrs": true,
|
||||||
"inferTemplateDollarEl": true,
|
"inferTemplateDollarEl": true,
|
||||||
"inferTemplateDollarRefs": true
|
"inferTemplateDollarRefs": true
|
||||||
|
|||||||
@@ -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@11.7.0",
|
"packageManager": "pnpm@11.18.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.16.0"
|
"node": ">=24.16.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"url": "git+https://github.com/robonen/tools.git",
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
"directory": "core/crdt"
|
"directory": "core/crdt"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.7.0",
|
"packageManager": "pnpm@11.18.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.16.0"
|
"node": ">=24.16.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"url": "git+https://github.com/robonen/tools.git",
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
"directory": "core/encoding"
|
"directory": "core/encoding"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.7.0",
|
"packageManager": "pnpm@11.18.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.16.0"
|
"node": ">=24.16.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,7 +15,7 @@
|
|||||||
"url": "git+https://github.com/robonen/tools.git",
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
"directory": "core/fetch"
|
"directory": "core/fetch"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.7.0",
|
"packageManager": "pnpm@11.18.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.16.0"
|
"node": ">=24.16.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"url": "git+https://github.com/robonen/tools.git",
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
"directory": "packages/platform"
|
"directory": "packages/platform"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.7.0",
|
"packageManager": "pnpm@11.18.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.16.0"
|
"node": ">=24.16.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@robonen/stdlib",
|
"name": "@robonen/stdlib",
|
||||||
"version": "0.0.10",
|
"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,7 +18,7 @@
|
|||||||
"url": "git+https://github.com/robonen/tools.git",
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
"directory": "packages/stdlib"
|
"directory": "packages/stdlib"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.7.0",
|
"packageManager": "pnpm@11.18.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.16.0"
|
"node": ">=24.16.0"
|
||||||
},
|
},
|
||||||
@@ -52,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';
|
||||||
|
|||||||
@@ -89,7 +89,12 @@ const roleColor: Record<string, string> = {
|
|||||||
<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">
|
<div v-if="part.exposes?.length" class="mb-3">
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-wider text-fg-subtle mb-2">Exposes (template ref)</div>
|
||||||
|
<DocsExposesTable :exposes="part.exposes" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="part.props.length === 0 && part.emits.length === 0 && !part.exposes?.length" 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>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ defineProps<{
|
|||||||
<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>
|
||||||
|
<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>
|
||||||
@@ -22,6 +23,10 @@ defineProps<{
|
|||||||
<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>
|
||||||
|
<td class="py-2.5 px-4 text-fg-muted min-w-48">
|
||||||
|
<DocsText v-if="e.description" :text="e.description" />
|
||||||
|
<span v-else>—</span>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">import type { PropertyMeta } from '../../modules/extractor/types';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
exposes: PropertyMeta[];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="exposes.length > 0" class="overflow-x-auto rounded-xl border border-border">
|
||||||
|
<table class="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-bg-subtle text-left">
|
||||||
|
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Name</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">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="x in exposes" :key="x.name" class="border-t border-border align-top">
|
||||||
|
<td class="py-2.5 px-4 whitespace-nowrap">
|
||||||
|
<code class="text-accent-text font-mono text-[13px] font-medium">{{ x.name }}</code><span v-if="x.optional" class="text-fg-subtle text-xs">?</span>
|
||||||
|
</td>
|
||||||
|
<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">{{ x.type }}</code>
|
||||||
|
</td>
|
||||||
|
<td class="py-2.5 px-4 text-fg-muted min-w-48">
|
||||||
|
<DocsText v-if="x.description" :text="x.description" />
|
||||||
|
<span v-else>—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
import { basename, dirname, relative, resolve } from 'node:path';
|
import { basename, dirname, relative, resolve } from 'node:path';
|
||||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||||
import { Node, Project, SyntaxKind } from 'ts-morph';
|
import { Node, Project, SyntaxKind, ts } from 'ts-morph';
|
||||||
import type { ClassDeclaration, FunctionDeclaration, InterfaceDeclaration, JSDoc, JSDocTag, MethodDeclaration, PropertyDeclaration, PropertySignature, SourceFile, TypeAliasDeclaration, VariableDeclaration } from 'ts-morph';
|
import type { ClassDeclaration, FunctionDeclaration, InterfaceDeclaration, JSDoc, JSDocTag, MethodDeclaration, PropertyDeclaration, PropertySignature, SourceFile, TypeAliasDeclaration, VariableDeclaration } from 'ts-morph';
|
||||||
import type {
|
import type {
|
||||||
CategoryMeta,
|
CategoryMeta,
|
||||||
@@ -858,6 +858,144 @@ function extractScriptBlock(sfc: string, setup: boolean): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── SFC type project ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One type-checking project per components package: every real `src/**\/*.ts`
|
||||||
|
* file plus, for each SFC part, a virtual `<file>.vue.ts` mirror holding its
|
||||||
|
* two script blocks. TS resolves a `./X.vue` specifier by appending `.ts`, so
|
||||||
|
* the mirrors make cross-file shapes resolve for real — `defineEmits<XEmits>()`
|
||||||
|
* where the interface lives in another block, a sibling `.ts` or another SFC,
|
||||||
|
* and `defineExpose({ ...api })` where the spread's type is a composable's
|
||||||
|
* return. The per-part regexes never saw any of those, which is exactly how
|
||||||
|
* half of Flow's API ended up invisible in the docs.
|
||||||
|
*/
|
||||||
|
function buildSfcProject(pkgDir: string): Project {
|
||||||
|
const srcDir = resolve(pkgDir, 'src');
|
||||||
|
const tsconfigPath = resolve(pkgDir, 'tsconfig.json');
|
||||||
|
const project = new Project({
|
||||||
|
tsConfigFilePath: existsSync(tsconfigPath) ? tsconfigPath : undefined,
|
||||||
|
skipAddingFilesFromTsConfig: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
project.addSourceFilesAtPaths([`${srcDir}/**/*.ts`, `!${srcDir}/**/__test__/**`]);
|
||||||
|
|
||||||
|
for (const entry of readdirSync(srcDir, { recursive: true, withFileTypes: true })) {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith('.vue') || entry.name === 'demo.vue') continue;
|
||||||
|
|
||||||
|
const full = resolve(entry.parentPath, entry.name);
|
||||||
|
if (full.includes('__test__')) continue;
|
||||||
|
|
||||||
|
const sfc = readFileSync(full, 'utf-8');
|
||||||
|
const script = `${extractScriptBlock(sfc, false)}\n${extractScriptBlock(sfc, true)}`;
|
||||||
|
if (script.trim()) project.createSourceFile(`${full}.ts`, script, { overwrite: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type display: keep alias names (`Ref<T>`, not its expansion), never truncate. */
|
||||||
|
const TYPE_TEXT_FLAGS = ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | ts.TypeFormatFlags.NoTruncation;
|
||||||
|
|
||||||
|
/** JSDoc description of a declaration; a const's doc sits on its statement. */
|
||||||
|
function describeDecl(node: Node | undefined): string {
|
||||||
|
if (!node) return '';
|
||||||
|
const holder = Node.isVariableDeclaration(node) ? node.getVariableStatement() ?? node : node;
|
||||||
|
if (!Node.isJSDocable(holder)) return '';
|
||||||
|
const jsdocs = holder.getJsDocs();
|
||||||
|
return getDescription(jsdocs, getJsDocTags(jsdocs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits through the checker's view of `defineEmits<T>()`: the inline literal
|
||||||
|
* AND a named interface (same block, sibling `.ts`, another SFC via the
|
||||||
|
* mirrors), `extends` chains included — with each member's JSDoc.
|
||||||
|
*/
|
||||||
|
function extractEmitsFrom(sf: SourceFile): EmitMeta[] {
|
||||||
|
const call = sf.getDescendantsOfKind(SyntaxKind.CallExpression)
|
||||||
|
.find(c => c.getExpression().getText() === 'defineEmits');
|
||||||
|
const typeArg = call?.getTypeArguments()[0];
|
||||||
|
if (!call || !typeArg) return [];
|
||||||
|
|
||||||
|
const emits: EmitMeta[] = [];
|
||||||
|
for (const prop of typeArg.getType().getProperties()) {
|
||||||
|
const decl = prop.getDeclarations()[0];
|
||||||
|
const written = decl && Node.isPropertySignature(decl) ? decl.getTypeNode()?.getText() : undefined;
|
||||||
|
|
||||||
|
emits.push({
|
||||||
|
name: prop.getName(),
|
||||||
|
payload: cleanType(written ?? prop.getTypeAtLocation(call).getText(call, TYPE_TEXT_FLAGS)),
|
||||||
|
description: describeDecl(decl),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return emits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `defineExpose({ … })` → the template-ref surface. Spreads expand through the
|
||||||
|
* checker (`...api` lists every member of the composable's return type with its
|
||||||
|
* JSDoc), so the docs show the full instance API instead of nothing at all.
|
||||||
|
*/
|
||||||
|
function extractExposesFrom(sf: SourceFile): PropertyMeta[] {
|
||||||
|
const call = sf.getDescendantsOfKind(SyntaxKind.CallExpression)
|
||||||
|
.find(c => c.getExpression().getText() === 'defineExpose');
|
||||||
|
const arg = call?.getArguments()[0];
|
||||||
|
if (!arg || !Node.isObjectLiteralExpression(arg)) return [];
|
||||||
|
|
||||||
|
const out: PropertyMeta[] = [];
|
||||||
|
const push = (name: string, type: string, description: string, optional = false) => {
|
||||||
|
if (!out.some(p => p.name === name))
|
||||||
|
out.push({ name, type: cleanType(type), description, optional, defaultValue: null, readonly: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const member of arg.getProperties()) {
|
||||||
|
if (Node.isSpreadAssignment(member)) {
|
||||||
|
const spreadType = member.getExpression().getType();
|
||||||
|
const props = spreadType.getProperties();
|
||||||
|
|
||||||
|
// Unresolvable spread — surface it verbatim rather than dropping it.
|
||||||
|
if (spreadType.isAny() || props.length === 0) {
|
||||||
|
push(member.getText(), '', '');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const prop of props) {
|
||||||
|
const decl = prop.getDeclarations()[0];
|
||||||
|
const written = decl && Node.isPropertySignature(decl) ? decl.getTypeNode()?.getText() : undefined;
|
||||||
|
push(
|
||||||
|
prop.getName(),
|
||||||
|
written ?? prop.getTypeAtLocation(member).getText(member, TYPE_TEXT_FLAGS),
|
||||||
|
describeDecl(decl),
|
||||||
|
decl !== undefined && Node.isQuestionTokenable(decl) && decl.hasQuestionToken(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (Node.isShorthandPropertyAssignment(member)) {
|
||||||
|
const local = sf.getProject().getTypeChecker().getShorthandAssignmentValueSymbol(member);
|
||||||
|
push(
|
||||||
|
member.getName(),
|
||||||
|
member.getType().getText(member, TYPE_TEXT_FLAGS),
|
||||||
|
describeDecl(local?.getDeclarations()[0]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else if (Node.isPropertyAssignment(member)) {
|
||||||
|
const init = member.getInitializer();
|
||||||
|
const initDecl = init && Node.isIdentifier(init) ? init.getSymbol()?.getDeclarations()[0] : undefined;
|
||||||
|
push(
|
||||||
|
member.getName().replaceAll(/^['"]|['"]$/g, ''),
|
||||||
|
(init ?? member).getType().getText(member, TYPE_TEXT_FLAGS),
|
||||||
|
describeDecl(member) || describeDecl(initDecl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else if (Node.isMethodDeclaration(member)) {
|
||||||
|
push(member.getName(), member.getType().getText(member, TYPE_TEXT_FLAGS), describeDecl(member));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Parse `defineEmits<{ 'a': [x: T]; b: [] }>()` from a setup block. */
|
/** Parse `defineEmits<{ 'a': [x: T]; b: [] }>()` from a setup block. */
|
||||||
function extractEmits(setupScript: string): EmitMeta[] {
|
function extractEmits(setupScript: string): EmitMeta[] {
|
||||||
const m = setupScript.match(/defineEmits<\{([\s\S]*?)\}>\s*\(\s*\)/);
|
const m = setupScript.match(/defineEmits<\{([\s\S]*?)\}>\s*\(\s*\)/);
|
||||||
@@ -907,7 +1045,7 @@ function extractModels(setupScript: string): { props: PropertyMeta[]; emits: Emi
|
|||||||
defaultValue: null,
|
defaultValue: null,
|
||||||
readonly: false,
|
readonly: false,
|
||||||
});
|
});
|
||||||
emits.push({ name: `update:${name}`, payload: `[value: ${type}]`, description: '' });
|
emits.push({ name: `update:${name}`, payload: `[value: ${type}]`, description: `Emitted when \`v-model${name === 'modelValue' ? '' : `:${name}`}\` updates.` });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { props, emits };
|
return { props, emits };
|
||||||
@@ -968,7 +1106,7 @@ function roleFromName(componentName: string, base: string): string {
|
|||||||
* not a component group (no `.vue`). `category` is the display label; `entryPoint`
|
* not a component group (no `.vue`). `category` is the display label; `entryPoint`
|
||||||
* is the package subpath (e.g. `./forms/checkbox`).
|
* is the package subpath (e.g. `./forms/checkbox`).
|
||||||
*/
|
*/
|
||||||
function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string): ComponentMeta | null {
|
function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string, sfcProject?: Project): ComponentMeta | null {
|
||||||
// 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) return null;
|
if (vueFiles.length === 0) return null;
|
||||||
@@ -1001,16 +1139,22 @@ function buildComponentAt(dir: string, slug: string, category: string, entryPoin
|
|||||||
const role = roleFromName(name, base);
|
const role = roleFromName(name, base);
|
||||||
if (role === 'Root' && description && !groupDescription) groupDescription = description;
|
if (role === 'Root' && description && !groupDescription) groupDescription = description;
|
||||||
|
|
||||||
|
// Emits/exposes come from the typed SFC project when it has this part;
|
||||||
|
// the regex parser stays as the fallback for inline-literal emits.
|
||||||
|
const virtual = sfcProject?.getSourceFile(`${resolve(dir, file)}.ts`);
|
||||||
|
let emits = virtual ? extractEmitsFrom(virtual) : [];
|
||||||
|
if (emits.length === 0) emits = extractEmits(setup);
|
||||||
|
const exposes = virtual ? extractExposesFrom(virtual) : [];
|
||||||
|
|
||||||
// Merge in `defineModel` v-model props/emits (invisible to the interface/
|
// Merge in `defineModel` v-model props/emits (invisible to the interface/
|
||||||
// defineEmits parsers), de-duping against any explicitly-declared ones.
|
// defineEmits parsers), de-duping against any explicitly-declared ones.
|
||||||
const models = extractModels(setup);
|
const models = extractModels(setup);
|
||||||
const emits = extractEmits(setup);
|
|
||||||
for (const mp of models.props)
|
for (const mp of models.props)
|
||||||
if (!props.some(p => p.name === mp.name)) props.push(mp);
|
if (!props.some(p => p.name === mp.name)) props.push(mp);
|
||||||
for (const me of models.emits)
|
for (const me of models.emits)
|
||||||
if (!emits.some(e => e.name === me.name)) emits.push(me);
|
if (!emits.some(e => e.name === me.name)) emits.push(me);
|
||||||
|
|
||||||
parts.push({ name, role, description, props, emits });
|
parts.push({ name, role, description, props, emits, exposes });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1030,6 +1174,7 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
|
|||||||
const srcDir = resolve(pkgDir, 'src');
|
const srcDir = resolve(pkgDir, 'src');
|
||||||
if (!existsSync(srcDir)) return [];
|
if (!existsSync(srcDir)) return [];
|
||||||
|
|
||||||
|
const sfcProject = buildSfcProject(pkgDir);
|
||||||
const components: ComponentMeta[] = [];
|
const components: ComponentMeta[] = [];
|
||||||
|
|
||||||
// Components live one level deep, in category folders: src/<category>/<component>/.
|
// Components live one level deep, in category folders: src/<category>/<component>/.
|
||||||
@@ -1048,13 +1193,14 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
|
|||||||
compEntry.name,
|
compEntry.name,
|
||||||
label,
|
label,
|
||||||
`./${catEntry.name}/${compEntry.name}`,
|
`./${catEntry.name}/${compEntry.name}`,
|
||||||
|
sfcProject,
|
||||||
);
|
);
|
||||||
if (c) components.push(c);
|
if (c) components.push(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Backward-compat: a flat component dir directly under src.
|
// Backward-compat: a flat component dir directly under src.
|
||||||
const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`);
|
const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`, sfcProject);
|
||||||
if (c) components.push(c);
|
if (c) components.push(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ export interface ComponentPartMeta {
|
|||||||
props: PropertyMeta[];
|
props: PropertyMeta[];
|
||||||
/** Emitted events parsed from `defineEmits` */
|
/** Emitted events parsed from `defineEmits` */
|
||||||
emits: EmitMeta[];
|
emits: EmitMeta[];
|
||||||
|
/**
|
||||||
|
* The template-ref surface parsed from `defineExpose`, spreads expanded
|
||||||
|
* through the type checker (`...api` lists the composable's whole return).
|
||||||
|
*/
|
||||||
|
exposes?: PropertyMeta[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmitMeta {
|
export interface EmitMeta {
|
||||||
|
|||||||
@@ -226,6 +226,11 @@ function renderComponentPart(part: ComponentPartMeta): string[] {
|
|||||||
const rows = part.emits.map(e => [cell(e.name), cell(`\`${e.payload}\``), cell(e.description)]);
|
const rows = part.emits.map(e => [cell(e.name), cell(`\`${e.payload}\``), cell(e.description)]);
|
||||||
out.push('#### Emits', '', table(['Event', 'Payload', 'Description'], rows), '');
|
out.push('#### Emits', '', table(['Event', 'Payload', 'Description'], rows), '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (part.exposes && part.exposes.length > 0) {
|
||||||
|
const rows = part.exposes.map(x => [cell(x.name), cell(`\`${x.type}\``), cell(x.description)]);
|
||||||
|
out.push('#### Exposes (template ref)', '', table(['Name', 'Type', 'Description'], rows), '');
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -17,22 +17,22 @@
|
|||||||
"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.1",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"jiti": "^2.7.0",
|
"jiti": "^2.7.0",
|
||||||
"nuxt": "catalog:",
|
"nuxt": "catalog:",
|
||||||
"tailwindcss": "^4.3.1",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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@11.7.0",
|
"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.228.0"
|
"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@11.7.0",
|
"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.3",
|
"@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
+3296
-3043
File diff suppressed because it is too large
Load Diff
+23
-10
@@ -16,17 +16,30 @@ allowBuilds:
|
|||||||
|
|
||||||
catalog:
|
catalog:
|
||||||
'@stylistic/eslint-plugin': ^5.10.0
|
'@stylistic/eslint-plugin': ^5.10.0
|
||||||
'@vitest/browser': ^4.1.9
|
'@vitest/browser': ^4.1.10
|
||||||
'@vitest/coverage-v8': ^4.1.9
|
'@vitest/coverage-v8': ^4.1.10
|
||||||
'@vitest/ui': ^4.1.9
|
'@vitest/ui': ^4.1.10
|
||||||
'@vue/shared': ^3.5.38
|
'@vue/shared': ^3.5.40
|
||||||
'@vue/test-utils': ^2.4.11
|
'@vue/test-utils': ^2.4.11
|
||||||
eslint: ^10.5.0
|
eslint: ^10.8.0
|
||||||
jsdom: ^29.1.1
|
jsdom: ^30.0.1
|
||||||
nuxt: ^4.4.8
|
nuxt: ^4.5.1
|
||||||
tsdown: ^0.22.3
|
tsdown: ^0.22.14
|
||||||
vitest: ^4.1.9
|
typescript: npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2
|
||||||
vue: ^3.5.38
|
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'
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<!-- title: Building flow graphs -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Prose + snippets only — static content, prerenders cleanly.
|
||||||
|
|
||||||
|
const minimal = `<script setup lang="ts">
|
||||||
|
import { FlowBackground, FlowControls, FlowRoot } from '@robonen/primitives';
|
||||||
|
import type { FlowEdge, FlowNode } from '@robonen/primitives';
|
||||||
|
|
||||||
|
const nodes: FlowNode[] = [
|
||||||
|
{ id: 'a', position: { x: 0, y: 0 }, data: { label: 'Start' } },
|
||||||
|
{ id: 'b', position: { x: 260, y: 120 }, data: { label: 'Finish' } },
|
||||||
|
];
|
||||||
|
const edges: FlowEdge[] = [
|
||||||
|
{ id: 'a-b', source: 'a', target: 'b', label: 'then' },
|
||||||
|
];
|
||||||
|
<\/script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- The pane fills this element — give it a real height. -->
|
||||||
|
<div style="height: 480px">
|
||||||
|
<FlowRoot :default-nodes="nodes" :default-edges="edges" fit-view-on-mount>
|
||||||
|
<template #node-default="{ node }">
|
||||||
|
<div class="card">{{ node.data.label }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<FlowBackground />
|
||||||
|
<FlowControls />
|
||||||
|
</FlowRoot>
|
||||||
|
</div>
|
||||||
|
</template>`;
|
||||||
|
|
||||||
|
const customNode = `<!-- Register per-type renderers via nodeTypes (module-level map)… -->
|
||||||
|
<FlowRoot :node-types="{ scene: SceneNode }" … />
|
||||||
|
|
||||||
|
<!-- …or inline via a #node-<type> scoped slot: -->
|
||||||
|
<FlowRoot :default-nodes="nodes">
|
||||||
|
<template #node-scene="{ node, selected }">
|
||||||
|
<article :data-selected="selected" class="scene">
|
||||||
|
<h4>{{ node.data.title }}</h4>
|
||||||
|
|
||||||
|
<!-- One SOURCE handle per row: anchor it to the row, not the side's
|
||||||
|
midpoint — same-position handles of one type otherwise overlap. -->
|
||||||
|
<div v-for="option in node.data.options" :key="option.id" class="row">
|
||||||
|
{{ option.label }}
|
||||||
|
<FlowHandle
|
||||||
|
:id="'opt:' + option.id"
|
||||||
|
type="source"
|
||||||
|
position="right"
|
||||||
|
class="row-port"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</FlowRoot>`;
|
||||||
|
|
||||||
|
const nodrag = `<!-- Form controls inside a node already win over dragging:
|
||||||
|
input, textarea, select, button, [contenteditable], [data-handleid]
|
||||||
|
start no drag. Everything else opts out with the .nodrag class: -->
|
||||||
|
<template #node-scene="{ node }">
|
||||||
|
<div class="scene">
|
||||||
|
<button @click="open(node.id)">Edit</button> <!-- just works -->
|
||||||
|
<div class="nodrag">
|
||||||
|
<MyColorWheel /> <!-- opted out -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>`;
|
||||||
|
|
||||||
|
const events = `<FlowRoot
|
||||||
|
:default-nodes="nodes"
|
||||||
|
@node-click="(id) => select(id)"
|
||||||
|
@node-double-click="(id) => openEditor(id)"
|
||||||
|
@node-drag-stop="(ids) => persistPositions(ids)"
|
||||||
|
@pane-click="clearInspector()"
|
||||||
|
@edge-click="(id) => selectEdge(id)"
|
||||||
|
/>`;
|
||||||
|
|
||||||
|
const instance = `<script setup lang="ts">
|
||||||
|
import { useTemplateRef } from 'vue';
|
||||||
|
import { FlowRoot } from '@robonen/primitives';
|
||||||
|
|
||||||
|
const flow = useTemplateRef('flow');
|
||||||
|
|
||||||
|
function frameSelection(ids: string[]) {
|
||||||
|
flow.value?.fitView({ padding: 0.2, nodes: ids });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAtCursor(event: MouseEvent) {
|
||||||
|
const position = flow.value!.screenToFlowPosition({
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
});
|
||||||
|
// …push a node at \`position\`
|
||||||
|
}
|
||||||
|
<\/script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FlowRoot ref="flow" :default-nodes="nodes" fit-view-on-mount />
|
||||||
|
</template>`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>Building flow graphs</h1>
|
||||||
|
<p>
|
||||||
|
<code>Flow</code> is a headless node-and-edge canvas: panning, zooming,
|
||||||
|
dragging, connecting, selection and virtualization are handled for you;
|
||||||
|
every pixel of a node is yours. This guide covers the contracts that are
|
||||||
|
easy to miss: sizing, custom nodes, drag opt-out, events and the
|
||||||
|
imperative API.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>A minimal graph</h2>
|
||||||
|
<p>
|
||||||
|
The pane fills its nearest sized ancestor — the graph lives in
|
||||||
|
absolutely-positioned layers, so the <em>host</em> element must have a
|
||||||
|
real height. <code>fit-view-on-mount</code> frames the graph once nodes
|
||||||
|
are measured; it is skipped when you control the viewport yourself
|
||||||
|
(<code>v-model:viewport</code> / <code>defaultViewport</code>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode :code="minimal" lang="vue" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Custom nodes</h2>
|
||||||
|
<p>
|
||||||
|
Nodes render through a component map (<code>nodeTypes</code>, keyed by
|
||||||
|
<code>node.type</code>) or a <code>#node-<type></code> scoped
|
||||||
|
slot. The slot receives the internal node (<code>node.data</code> is
|
||||||
|
yours) and its <code>selected</code> state. Place
|
||||||
|
<code>FlowHandle</code>s anywhere inside — give repeated same-side
|
||||||
|
handles their own anchors, since handles of one type default to the
|
||||||
|
side's midpoint and would overlap.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode :code="customNode" lang="vue" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Interactive content and <code>.nodrag</code></h2>
|
||||||
|
<p>
|
||||||
|
The drag layer owns <code>pointerdown</code> on the node. Native form
|
||||||
|
controls (<code>input</code>, <code>textarea</code>, <code>select</code>,
|
||||||
|
<code>button</code>), <code>[contenteditable]</code> elements and
|
||||||
|
handles are excluded automatically; any other interactive element opts
|
||||||
|
out of dragging with the <code>.nodrag</code> class.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode :code="nodrag" lang="vue" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Click, double-click, drag</h2>
|
||||||
|
<p>
|
||||||
|
The drag layer distinguishes a settled click from a drag, so
|
||||||
|
<code>@node-click</code> never fires after a real move, and
|
||||||
|
<code>@node-double-click</code> pairs two settled clicks — double-click
|
||||||
|
on a node does <em>not</em> zoom the canvas. Positions are persisted
|
||||||
|
from <code>@node-drag-stop</code>, which reports every node that moved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode :code="events" lang="vue" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>The instance API</h2>
|
||||||
|
<p>
|
||||||
|
<code>FlowRoot</code> exposes its whole imperative surface through the
|
||||||
|
template ref — <code>fitView</code>, zooming, viewport get/set,
|
||||||
|
coordinate conversion (<code>screenToFlowPosition</code> /
|
||||||
|
<code>flowToScreenPosition</code>), node/edge lookups and selection
|
||||||
|
control. The full list is on the <code>Flow</code> component page under
|
||||||
|
<em>Exposes</em>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode :code="instance" lang="vue" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Edge labels</h2>
|
||||||
|
<p>
|
||||||
|
An edge with a <code>label</code> renders it at the path midpoint as
|
||||||
|
<code>[data-flow-edge-label]</code>, haloed with
|
||||||
|
<code>--flow-edge-label-halo</code> (defaults to white) so it stays
|
||||||
|
readable over the wire. For richer labels, take over the edge with
|
||||||
|
<code>edgeTypes</code> or an <code>#edge-<type></code> slot.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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.6",
|
||||||
"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.6",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"description": "Collection of UI primitives",
|
"description": "Collection of UI primitives",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"url": "git+https://github.com/robonen/tools.git",
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
"directory": "vue/primitives"
|
"directory": "vue/primitives"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.7.0",
|
"packageManager": "pnpm@11.18.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.16.0"
|
"node": ">=24.16.0"
|
||||||
},
|
},
|
||||||
@@ -59,19 +59,19 @@
|
|||||||
"@robonen/tsconfig": "workspace:*",
|
"@robonen/tsconfig": "workspace:*",
|
||||||
"@robonen/tsdown": "workspace:*",
|
"@robonen/tsdown": "workspace:*",
|
||||||
"@vitest/browser": "catalog:",
|
"@vitest/browser": "catalog:",
|
||||||
"@vitest/browser-playwright": "^4.1.9",
|
"@vitest/browser-playwright": "^4.1.10",
|
||||||
"@vue/test-utils": "catalog:",
|
"@vue/test-utils": "catalog:",
|
||||||
"axe-core": "^4.12.1",
|
"axe-core": "^4.12.1",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"playwright": "^1.61.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": "catalog:",
|
"vue": "catalog:",
|
||||||
"vue-tsc": "^3.3.5"
|
"vue-tsc": "^3.3.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/vue": "^2.0.0",
|
"@floating-ui/vue": "^2.0.1",
|
||||||
"@robonen/encoding": "workspace:*",
|
"@robonen/encoding": "workspace:*",
|
||||||
"@robonen/platform": "workspace:*",
|
"@robonen/platform": "workspace:*",
|
||||||
"@robonen/stdlib": "workspace:*",
|
"@robonen/stdlib": "workspace:*",
|
||||||
|
|||||||
@@ -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.1",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.7",
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
"tailwindcss": "^4.3.1",
|
"tailwindcss": "^4.3.3",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.1.5",
|
||||||
"vue-tsc": "^3.3.5"
|
"vue-tsc": "^3.3.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const linePath = computed(() => {
|
|||||||
<svg
|
<svg
|
||||||
data-flow-background=""
|
data-flow-background=""
|
||||||
:data-variant="variant"
|
:data-variant="variant"
|
||||||
:style="{ position: 'absolute', inset: '0', width: '100%', height: '100%', pointerEvents: 'none', color }"
|
:style="{ position: 'absolute', inset: '0', width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0, color }"
|
||||||
>
|
>
|
||||||
<pattern
|
<pattern
|
||||||
:id="patternId"
|
:id="patternId"
|
||||||
|
|||||||
@@ -134,13 +134,14 @@ function onPointerdown(event: PointerEvent): void {
|
|||||||
if (event.button !== 0 || edge.value?.selectable === false || !ctx.elementsSelectable.value) return;
|
if (event.button !== 0 || edge.value?.selectable === false || !ctx.elementsSelectable.value) return;
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
ctx.selectEdge(id, event.shiftKey || event.metaKey || event.ctrlKey);
|
ctx.selectEdge(id, event.shiftKey || event.metaKey || event.ctrlKey);
|
||||||
|
ctx.emitEdgeClick(id, event);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<g
|
<g
|
||||||
v-if="endpoints"
|
v-if="endpoints"
|
||||||
v-memo="[path[0], selected, edge?.animated, edge?.selectable, edge?.data, markerStartRef, markerEndRef]"
|
v-memo="[path[0], selected, edge?.animated, edge?.selectable, edge?.data, edge?.label, markerStartRef, markerEndRef]"
|
||||||
data-flow-edge=""
|
data-flow-edge=""
|
||||||
:data-id="id"
|
:data-id="id"
|
||||||
:data-type="resolvedType"
|
:data-type="resolvedType"
|
||||||
@@ -176,6 +177,21 @@ function onPointerdown(event: PointerEvent): void {
|
|||||||
:style="interactionPathStyle"
|
:style="interactionPathStyle"
|
||||||
@pointerdown="onPointerdown"
|
@pointerdown="onPointerdown"
|
||||||
/>
|
/>
|
||||||
|
<!-- The halo (paint-order + stroke) keeps the text legible over the
|
||||||
|
path and the background without the consumer styling anything. -->
|
||||||
|
<text
|
||||||
|
v-if="edge?.label"
|
||||||
|
data-flow-edge-label=""
|
||||||
|
:x="path[1]"
|
||||||
|
:y="path[2]"
|
||||||
|
text-anchor="middle"
|
||||||
|
dominant-baseline="middle"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="var(--flow-edge-label-halo, white)"
|
||||||
|
stroke-width="3"
|
||||||
|
paint-order="stroke"
|
||||||
|
:style="{ pointerEvents: 'none', fontSize: '12px' }"
|
||||||
|
>{{ edge.label }}</text>
|
||||||
</template>
|
</template>
|
||||||
</g>
|
</g>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -65,8 +65,10 @@ useKeyboard(currentElement, ctx, useViewportApi(ctx));
|
|||||||
|
|
||||||
useEventListener(currentElement, 'click', (event: MouseEvent) => {
|
useEventListener(currentElement, 'click', (event: MouseEvent) => {
|
||||||
const target = event.target as Element | null;
|
const target = event.target as Element | null;
|
||||||
if (target && !target.closest('[data-flow-node],[data-flow-edge]'))
|
if (target && !target.closest('[data-flow-node],[data-flow-edge]')) {
|
||||||
ctx.clearSelection();
|
ctx.clearSelection();
|
||||||
|
ctx.emitPaneClick(event as PointerEvent);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -79,7 +81,16 @@ useEventListener(currentElement, 'click', (event: MouseEvent) => {
|
|||||||
:data-interactive="ctx.interactive.value ? '' : undefined"
|
:data-interactive="ctx.interactive.value ? '' : undefined"
|
||||||
:role="ctx.disableKeyboardA11y.value ? undefined : 'application'"
|
:role="ctx.disableKeyboardA11y.value ? undefined : 'application'"
|
||||||
:tabindex="ctx.disableKeyboardA11y.value ? undefined : 0"
|
:tabindex="ctx.disableKeyboardA11y.value ? undefined : 0"
|
||||||
:style="{ position: 'relative', overflow: 'hidden', touchAction: 'none' }"
|
:style="{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
touchAction: 'none',
|
||||||
|
// Everything inside is absolutely positioned, so content-sizing always
|
||||||
|
// collapsed to 0×N and the graph rendered into an invisible strip.
|
||||||
|
// Vue merges a consumer's style attr over this, so it stays overridable.
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ const { forwardRef } = useForwardExpose();
|
|||||||
|
|
||||||
const style = computed<CSSProperties>(() => {
|
const style = computed<CSSProperties>(() => {
|
||||||
const [v, h] = position.split('-') as ['top' | 'bottom', 'left' | 'center' | 'right'];
|
const [v, h] = position.split('-') as ['top' | 'bottom', 'left' | 'center' | 'right'];
|
||||||
const s: CSSProperties = { position: 'absolute', pointerEvents: 'all' };
|
// Above the viewport's explicit layer (zIndex 1): a positioned sibling
|
||||||
|
// with z-index auto would otherwise paint underneath the graph.
|
||||||
|
const s: CSSProperties = { position: 'absolute', pointerEvents: 'all', zIndex: 2 };
|
||||||
s[v] = '0';
|
s[v] = '0';
|
||||||
if (h === 'center') {
|
if (h === 'center') {
|
||||||
s.left = '50%';
|
s.left = '50%';
|
||||||
|
|||||||
@@ -73,26 +73,46 @@ export interface FlowRootProps extends PrimitiveProps {
|
|||||||
isValidConnection?: IsValidConnection;
|
isValidConnection?: IsValidConnection;
|
||||||
/** Cull nodes/edges outside the viewport — for large graphs. @default false */
|
/** Cull nodes/edges outside the viewport — for large graphs. @default false */
|
||||||
onlyRenderVisibleElements?: boolean;
|
onlyRenderVisibleElements?: boolean;
|
||||||
|
/**
|
||||||
|
* Frame the whole graph once after the initial nodes are measured. Skipped
|
||||||
|
* when an explicit `viewport` / `defaultViewport` is provided — a restored
|
||||||
|
* viewport must not be stomped by a fit. With virtualization the fit uses
|
||||||
|
* whatever is measured plus declared node sizes; fully unmeasured nodes are
|
||||||
|
* framed by position alone. @default false
|
||||||
|
*/
|
||||||
|
fitViewOnMount?: boolean | FitViewParams;
|
||||||
/** Extra px kept rendered around the viewport when virtualizing. @default 200 */
|
/** Extra px kept rendered around the viewport when virtualizing. @default 200 */
|
||||||
virtualizationBuffer?: number;
|
virtualizationBuffer?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FlowRootEmits {
|
export interface FlowRootEmits {
|
||||||
|
/** Granular node mutations (position, selection, removal) — apply them to your controlled state. */
|
||||||
nodesChange: [changes: NodeChange[]];
|
nodesChange: [changes: NodeChange[]];
|
||||||
|
/** Granular edge mutations (selection, removal). */
|
||||||
edgesChange: [changes: EdgeChange[]];
|
edgesChange: [changes: EdgeChange[]];
|
||||||
|
/** A connection gesture completed between two handles. */
|
||||||
connect: [connection: Connection];
|
connect: [connection: Connection];
|
||||||
|
/** A connection gesture started from a handle. */
|
||||||
connectStart: [payload: { nodeId: string; handleId: string | null; handleType: HandleType }];
|
connectStart: [payload: { nodeId: string; handleId: string | null; handleType: HandleType }];
|
||||||
|
/** The connection gesture ended, successfully or not. */
|
||||||
connectEnd: [];
|
connectEnd: [];
|
||||||
|
/** A node drag finished; ids of every node that moved. */
|
||||||
nodeDragStop: [ids: string[]];
|
nodeDragStop: [ids: string[]];
|
||||||
|
/** The set of selected nodes/edges changed. */
|
||||||
selectionChange: [selection: { nodes: string[]; edges: string[] }];
|
selectionChange: [selection: { nodes: string[]; edges: string[] }];
|
||||||
|
/** A click landed on the empty pane — not on a node or an edge. */
|
||||||
paneClick: [event: PointerEvent];
|
paneClick: [event: PointerEvent];
|
||||||
|
/** A settled click on a node (a drag that never started moving). */
|
||||||
nodeClick: [id: string, event: PointerEvent];
|
nodeClick: [id: string, event: PointerEvent];
|
||||||
|
/** Two settled clicks on the same node within the double-click interval. */
|
||||||
|
nodeDoubleClick: [id: string, event: PointerEvent];
|
||||||
|
/** A click on an edge path. */
|
||||||
edgeClick: [id: string, event: PointerEvent];
|
edgeClick: [id: string, event: PointerEvent];
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, shallowRef, toRef, triggerRef, useSlots, watch } from 'vue';
|
import { computed, getCurrentInstance, shallowRef, toRef, triggerRef, useSlots, watch } from 'vue';
|
||||||
import { useId } from '@robonen/vue';
|
import { useId } from '@robonen/vue';
|
||||||
import FlowPane from './FlowPane.vue';
|
import FlowPane from './FlowPane.vue';
|
||||||
import FlowViewport from './FlowViewport.vue';
|
import FlowViewport from './FlowViewport.vue';
|
||||||
@@ -124,6 +144,7 @@ const {
|
|||||||
disableKeyboardA11y = false,
|
disableKeyboardA11y = false,
|
||||||
isValidConnection,
|
isValidConnection,
|
||||||
onlyRenderVisibleElements = false,
|
onlyRenderVisibleElements = false,
|
||||||
|
fitViewOnMount = false,
|
||||||
virtualizationBuffer = 200,
|
virtualizationBuffer = 200,
|
||||||
as = 'div',
|
as = 'div',
|
||||||
} = defineProps<FlowRootProps>();
|
} = defineProps<FlowRootProps>();
|
||||||
@@ -135,6 +156,7 @@ const flowId = useId(undefined, 'flow').value;
|
|||||||
|
|
||||||
// ── models (controlled + uncontrolled) ────────────────────────────────────
|
// ── models (controlled + uncontrolled) ────────────────────────────────────
|
||||||
const localNodes = shallowRef<FlowNode[]>(defaultNodes ? defaultNodes.slice() : []);
|
const localNodes = shallowRef<FlowNode[]>(defaultNodes ? defaultNodes.slice() : []);
|
||||||
|
/** Current nodes (controlled `v-model:nodes` or internal state). */
|
||||||
const nodes = defineModel<FlowNode[]>('nodes', {
|
const nodes = defineModel<FlowNode[]>('nodes', {
|
||||||
get: external => external ?? localNodes.value,
|
get: external => external ?? localNodes.value,
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
@@ -144,6 +166,7 @@ const nodes = defineModel<FlowNode[]>('nodes', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const localEdges = shallowRef<FlowEdge[]>(defaultEdges ? defaultEdges.slice() : []);
|
const localEdges = shallowRef<FlowEdge[]>(defaultEdges ? defaultEdges.slice() : []);
|
||||||
|
/** Current edges (controlled `v-model:edges` or internal state). */
|
||||||
const edges = defineModel<FlowEdge[]>('edges', {
|
const edges = defineModel<FlowEdge[]>('edges', {
|
||||||
get: external => external ?? localEdges.value,
|
get: external => external ?? localEdges.value,
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
@@ -153,6 +176,7 @@ const edges = defineModel<FlowEdge[]>('edges', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const localViewport = shallowRef<Viewport>(defaultViewport ?? { x: 0, y: 0, zoom: 1 });
|
const localViewport = shallowRef<Viewport>(defaultViewport ?? { x: 0, y: 0, zoom: 1 });
|
||||||
|
/** Current viewport (controlled `v-model:viewport` or internal state). */
|
||||||
const viewport = defineModel<Viewport>('viewport', {
|
const viewport = defineModel<Viewport>('viewport', {
|
||||||
get: external => external ?? localViewport.value,
|
get: external => external ?? localViewport.value,
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
@@ -168,6 +192,7 @@ const viewport = defineModel<Viewport>('viewport', {
|
|||||||
// would never visually update). ────────────────────────────────────────────
|
// would never visually update). ────────────────────────────────────────────
|
||||||
const nodeLookup = shallowRef(new Map<string, InternalNode>());
|
const nodeLookup = shallowRef(new Map<string, InternalNode>());
|
||||||
const edgeLookup = shallowRef(new Map<string, FlowEdge>());
|
const edgeLookup = shallowRef(new Map<string, FlowEdge>());
|
||||||
|
/** Selected node/edge id sets. */
|
||||||
const selection = shallowRef<FlowSelection>({ nodes: new Set(), edges: new Set() });
|
const selection = shallowRef<FlowSelection>({ nodes: new Set(), edges: new Set() });
|
||||||
const paneRect = shallowRef({ left: 0, top: 0, width: 0, height: 0 });
|
const paneRect = shallowRef({ left: 0, top: 0, width: 0, height: 0 });
|
||||||
const isDragging = shallowRef(false);
|
const isDragging = shallowRef(false);
|
||||||
@@ -330,6 +355,7 @@ function setNodeMeasured(id: string, size: Dimensions, handleBounds: InternalNod
|
|||||||
// pick up the fresh measurement / handle geometry.
|
// pick up the fresh measurement / handle geometry.
|
||||||
map.set(id, { ...n, measured: sizeChanged ? size : n.measured, handleBounds });
|
map.set(id, { ...n, measured: sizeChanged ? size : n.measured, handleBounds });
|
||||||
triggerRef(nodeLookup);
|
triggerRef(nodeLookup);
|
||||||
|
maybeFitOnMount();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateNode(id: string, patch: Partial<FlowNode>): void {
|
function updateNode(id: string, patch: Partial<FlowNode>): void {
|
||||||
@@ -346,6 +372,7 @@ function emitSelection(): void {
|
|||||||
emit('selectionChange', { nodes: [...selection.value.nodes], edges: [...selection.value.edges] });
|
emit('selectionChange', { nodes: [...selection.value.nodes], edges: [...selection.value.edges] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Select a node — replacing the selection, or adding to it. */
|
||||||
function selectNode(id: string, additive = false): void {
|
function selectNode(id: string, additive = false): void {
|
||||||
if (!elementsSelectable) return;
|
if (!elementsSelectable) return;
|
||||||
const sel = selection.value;
|
const sel = selection.value;
|
||||||
@@ -357,6 +384,7 @@ function selectNode(id: string, additive = false): void {
|
|||||||
emitSelection();
|
emitSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Select an edge — replacing the selection, or adding to it. */
|
||||||
function selectEdge(id: string, additive = false): void {
|
function selectEdge(id: string, additive = false): void {
|
||||||
if (!elementsSelectable) return;
|
if (!elementsSelectable) return;
|
||||||
const sel = selection.value;
|
const sel = selection.value;
|
||||||
@@ -368,17 +396,20 @@ function selectEdge(id: string, additive = false): void {
|
|||||||
emitSelection();
|
emitSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Replace the selection with exactly these nodes and edges. */
|
||||||
function setSelection(nodeIds: string[], edgeIds: string[]): void {
|
function setSelection(nodeIds: string[], edgeIds: string[]): void {
|
||||||
selection.value = { nodes: new Set(nodeIds), edges: new Set(edgeIds) };
|
selection.value = { nodes: new Set(nodeIds), edges: new Set(edgeIds) };
|
||||||
emitSelection();
|
emitSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Deselect everything. */
|
||||||
function clearSelection(): void {
|
function clearSelection(): void {
|
||||||
if (selection.value.nodes.size === 0 && selection.value.edges.size === 0) return;
|
if (selection.value.nodes.size === 0 && selection.value.edges.size === 0) return;
|
||||||
selection.value = { nodes: new Set(), edges: new Set() };
|
selection.value = { nodes: new Set(), edges: new Set() };
|
||||||
emitSelection();
|
emitSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove every selected node (with its edges) and selected edge. */
|
||||||
function removeSelected(): void {
|
function removeSelected(): void {
|
||||||
const sel = selection.value;
|
const sel = selection.value;
|
||||||
if (sel.nodes.size === 0 && sel.edges.size === 0) return;
|
if (sel.nodes.size === 0 && sel.edges.size === 0) return;
|
||||||
@@ -515,12 +546,56 @@ const context: FlowContext = {
|
|||||||
endConnection,
|
endConnection,
|
||||||
emitNodesChange: changes => emit('nodesChange', changes),
|
emitNodesChange: changes => emit('nodesChange', changes),
|
||||||
emitEdgesChange: changes => emit('edgesChange', changes),
|
emitEdgesChange: changes => emit('edgesChange', changes),
|
||||||
|
emitNodeClick: (id, event) => emit('nodeClick', id, event),
|
||||||
|
emitNodeDoubleClick: (id, event) => emit('nodeDoubleClick', id, event),
|
||||||
|
emitEdgeClick: (id, event) => emit('edgeClick', id, event),
|
||||||
|
emitPaneClick: event => emit('paneClick', event),
|
||||||
};
|
};
|
||||||
provideFlowContext(context);
|
provideFlowContext(context);
|
||||||
|
|
||||||
// Imperative API, also exposed so consumers can drive the flow via a template ref.
|
// Imperative API, also exposed so consumers can drive the flow via a template ref.
|
||||||
const api = useViewportApi(context);
|
const api = useViewportApi(context);
|
||||||
|
|
||||||
|
// ── fitViewOnMount ────────────────────────────────────────────────────────
|
||||||
|
// A viewport the consumer controls (v-model:viewport) or seeds
|
||||||
|
// (defaultViewport) is restored state; a fit must never stomp it. Model
|
||||||
|
// getters fall back to a local default, so controlledness is read off the
|
||||||
|
// vnode, not the value.
|
||||||
|
const vnodeProps = getCurrentInstance()?.vnode.props ?? {};
|
||||||
|
let fitOnMountPending = fitViewOnMount !== false
|
||||||
|
&& defaultViewport === undefined
|
||||||
|
&& !('viewport' in vnodeProps)
|
||||||
|
&& !('onUpdate:viewport' in vnodeProps);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Armed until it fires once: waits for every RENDERED node to report a
|
||||||
|
* measurement — fitting to unmeasured nodes fits to nothing. Under
|
||||||
|
* virtualization only the rendered subset ever measures; the rest contribute
|
||||||
|
* their declared or positional bounds through `fitView` itself.
|
||||||
|
*/
|
||||||
|
function maybeFitOnMount(): void {
|
||||||
|
if (!fitOnMountPending) return;
|
||||||
|
|
||||||
|
// Nodes can finish measuring before the pane has a size (or the reverse);
|
||||||
|
// the shot must not burn against a 0×0 container, so both gates hold it and
|
||||||
|
// the pane-rect watcher below re-arms the attempt.
|
||||||
|
const rect = paneRect.value;
|
||||||
|
if (rect.width === 0 || rect.height === 0) return;
|
||||||
|
|
||||||
|
const map = nodeLookup.value;
|
||||||
|
if (map.size === 0) return;
|
||||||
|
|
||||||
|
for (const id of visibleNodeIds.value) {
|
||||||
|
const n = map.get(id);
|
||||||
|
if (n && n.measured.width === 0 && n.measured.height === 0) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fitOnMountPending = false;
|
||||||
|
api.fitView(typeof fitViewOnMount === 'object' ? fitViewOnMount : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(paneRect, maybeFitOnMount);
|
||||||
|
|
||||||
const nodeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'node' || n.startsWith('node-')));
|
const nodeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'node' || n.startsWith('node-')));
|
||||||
const edgeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'edge' || n.startsWith('edge-')));
|
const edgeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'edge' || n.startsWith('edge-')));
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ const transform = computed(() => {
|
|||||||
left: '0',
|
left: '0',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
// The slot (background, panels) renders after this element; explicit
|
||||||
|
// layers keep the graph above the background and below the chrome.
|
||||||
|
zIndex: 1,
|
||||||
transformOrigin: '0 0',
|
transformOrigin: '0 0',
|
||||||
transform,
|
transform,
|
||||||
willChange: ctx.isInteracting.value ? 'transform' : undefined,
|
willChange: ctx.isInteracting.value ? 'transform' : undefined,
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import type { VueWrapper } from '@vue/test-utils';
|
||||||
|
import type { FlowEdge, FlowNode } from '../index';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { h, nextTick } from 'vue';
|
||||||
|
import { FlowBackground, FlowPanel, FlowRoot } from '../index';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regressions found by building a real story-map consumer: the pane rendered
|
||||||
|
* into zero area, the background painted over the graph, edge labels never
|
||||||
|
* rendered, the declared click emits never fired, and dblclick on a node
|
||||||
|
* zoomed the canvas. Each test pins the fixed contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const wrappers: Array<VueWrapper<any>> = [];
|
||||||
|
afterEach(() => {
|
||||||
|
while (wrappers.length) wrappers.pop()!.unmount();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
function track<T extends VueWrapper<any>>(w: T): T {
|
||||||
|
wrappers.push(w);
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes: FlowNode[] = [
|
||||||
|
{ id: 'a', position: { x: 0, y: 0 } },
|
||||||
|
{ id: 'b', position: { x: 300, y: 200 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
function pointer(el: Element, type: string, x = 10, y = 10) {
|
||||||
|
el.dispatchEvent(new PointerEvent(type, { button: 0, pointerId: 1, clientX: x, clientY: y, bubbles: true, cancelable: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pane sizes to its parent; give the test-utils wrapper a real box. */
|
||||||
|
function sizeWrapper(w: VueWrapper<any>, width = 600, height = 400) {
|
||||||
|
const el = w.element as HTMLElement;
|
||||||
|
el.style.width = `${width}px`;
|
||||||
|
el.style.height = `${height}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const edges: FlowEdge[] = [
|
||||||
|
{ id: 'a-b', source: 'a', target: 'b', label: 'take me' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function flow(props: Record<string, unknown> = {}, slots: Record<string, unknown> = {}) {
|
||||||
|
return track(mount(FlowRoot, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: { defaultNodes: nodes, defaultEdges: edges, ...props },
|
||||||
|
slots: { 'node-default': () => h('div', { style: 'width:120px;height:40px' }, 'n'), ...slots },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('pane sizing', () => {
|
||||||
|
it('fills its parent instead of collapsing to zero height', () => {
|
||||||
|
const w = flow();
|
||||||
|
sizeWrapper(w);
|
||||||
|
|
||||||
|
const pane = w.find('[data-flow-pane]').element as HTMLElement;
|
||||||
|
|
||||||
|
// All pane content is absolutely positioned; without an own height the
|
||||||
|
// whole graph rendered inside an invisible 0px strip.
|
||||||
|
expect(pane.clientHeight).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stacking', () => {
|
||||||
|
it('layers background under the graph and panels above it', () => {
|
||||||
|
const w = flow({}, {
|
||||||
|
default: () => [h(FlowBackground), h(FlowPanel, { position: 'top-right' }, () => 'p')],
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewport = (w.find('[data-flow-viewport]').element as HTMLElement).style.zIndex;
|
||||||
|
const background = (w.find('[data-flow-background]').element as HTMLElement).style.zIndex;
|
||||||
|
const panel = (w.find('[data-flow-panel]').element as HTMLElement).style.zIndex;
|
||||||
|
|
||||||
|
// The slot chrome renders AFTER the viewport in DOM order; without these
|
||||||
|
// layers the background dots painted over every node.
|
||||||
|
expect(Number(background)).toBeLessThan(Number(viewport));
|
||||||
|
expect(Number(panel)).toBeGreaterThan(Number(viewport));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge labels', () => {
|
||||||
|
it('renders the label the type always promised', async () => {
|
||||||
|
const w = flow();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const label = w.find('[data-flow-edge-label]');
|
||||||
|
|
||||||
|
expect(label.exists()).toBe(true);
|
||||||
|
expect(label.text()).toBe('take me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no label element when there is none', async () => {
|
||||||
|
const w = flow({ defaultEdges: [{ id: 'a-b', source: 'a', target: 'b' }] });
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(w.find('[data-flow-edge-label]').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the click family', () => {
|
||||||
|
async function settle(w: VueWrapper<any>, selector: string, times = 1, gap = 50) {
|
||||||
|
const el = w.find(selector).element;
|
||||||
|
|
||||||
|
for (let index = 0; index < times; index++) {
|
||||||
|
pointer(el, 'pointerdown');
|
||||||
|
pointer(el, 'pointerup');
|
||||||
|
await nextTick();
|
||||||
|
if (gap)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, gap));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('emits nodeClick for a settled click', async () => {
|
||||||
|
const w = flow();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
await settle(w, '[data-flow-node][data-id="a"]');
|
||||||
|
|
||||||
|
expect(w.emitted('nodeClick')?.[0]?.[0]).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pairs two settled clicks into nodeDoubleClick', async () => {
|
||||||
|
const w = flow();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
await settle(w, '[data-flow-node][data-id="a"]', 2, 40);
|
||||||
|
|
||||||
|
expect(w.emitted('nodeDoubleClick')?.[0]?.[0]).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits paneClick only for background clicks', async () => {
|
||||||
|
const w = flow();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
await w.find('[data-flow-pane]').trigger('click');
|
||||||
|
expect(w.emitted('paneClick')).toHaveLength(1);
|
||||||
|
|
||||||
|
await w.find('[data-flow-node][data-id="a"] div').trigger('click');
|
||||||
|
expect(w.emitted('paneClick')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits edgeClick when the edge is picked', async () => {
|
||||||
|
const w = flow();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
pointer(w.findAll('[data-flow-edge] path')[1]!.element, 'pointerdown');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(w.emitted('edgeClick')?.[0]?.[0]).toBe('a-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not zoom on a node double click', async () => {
|
||||||
|
const w = flow();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const before = (w.find('[data-flow-viewport]').element as HTMLElement).style.transform;
|
||||||
|
await w.find('[data-flow-node][data-id="a"]').trigger('dblclick');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// The gesture belongs to the node (nodeDoubleClick), not the camera.
|
||||||
|
expect((w.find('[data-flow-viewport]').element as HTMLElement).style.transform).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fitViewOnMount', () => {
|
||||||
|
it('frames the graph once nodes are measured', async () => {
|
||||||
|
const w = flow({ fitViewOnMount: true });
|
||||||
|
sizeWrapper(w);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const t = (w.find('[data-flow-viewport]').element as HTMLElement).style.transform;
|
||||||
|
expect(t).not.toBe('translate(0px, 0px) scale(1)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never stomps a consumer-controlled viewport', async () => {
|
||||||
|
const w = flow({
|
||||||
|
fitViewOnMount: true,
|
||||||
|
viewport: { x: 17, y: 23, zoom: 1.5 },
|
||||||
|
'onUpdate:viewport': () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 120));
|
||||||
|
|
||||||
|
// A bound viewport is restored state; the fit must skip it entirely.
|
||||||
|
expect((w.find('[data-flow-viewport]').element as HTMLElement).style.transform)
|
||||||
|
.toBe('translate(17px, 23px) scale(1.5)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,9 @@ export interface NodeDragOptions {
|
|||||||
/** Elements inside a node that must not initiate a drag. */
|
/** Elements inside a node that must not initiate a drag. */
|
||||||
const NO_DRAG_SELECTOR = 'input, textarea, select, button, [contenteditable="true"], [data-handleid], .nodrag';
|
const NO_DRAG_SELECTOR = 'input, textarea, select, button, [contenteditable="true"], [data-handleid], .nodrag';
|
||||||
|
|
||||||
|
/** Two settled clicks within this window read as a double click. */
|
||||||
|
const DOUBLE_CLICK_MS = 350;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pointer-capture node drag. Moves the node (and every co-selected node) by the
|
* Pointer-capture node drag. Moves the node (and every co-selected node) by the
|
||||||
* pointer delta converted to flow space (`delta / zoom`), optionally snapped to
|
* pointer delta converted to flow space (`delta / zoom`), optionally snapped to
|
||||||
@@ -57,6 +60,7 @@ export function useNodeDrag(
|
|||||||
let startX = 0;
|
let startX = 0;
|
||||||
let startY = 0;
|
let startY = 0;
|
||||||
let started = false;
|
let started = false;
|
||||||
|
let lastClickAt = 0;
|
||||||
let lastX = 0;
|
let lastX = 0;
|
||||||
let lastY = 0;
|
let lastY = 0;
|
||||||
let rafId: number | null = null;
|
let rafId: number | null = null;
|
||||||
@@ -150,6 +154,26 @@ export function useNodeDrag(
|
|||||||
if (started) {
|
if (started) {
|
||||||
flush();
|
flush();
|
||||||
ctx.commitNodeDrag();
|
ctx.commitNodeDrag();
|
||||||
|
lastClickAt = 0;
|
||||||
|
}
|
||||||
|
else if (snapshot.size > 0) {
|
||||||
|
// The pointer never crossed the drag threshold: this is a click. The
|
||||||
|
// pane cannot see it (propagation stopped on pointerdown), so the node
|
||||||
|
// is the only place that can report it — and pair two settled clicks
|
||||||
|
// into a double click.
|
||||||
|
const id = toValue(nodeId);
|
||||||
|
|
||||||
|
ctx.emitNodeClick(id, event);
|
||||||
|
|
||||||
|
const now = event.timeStamp;
|
||||||
|
|
||||||
|
if (now - lastClickAt <= DOUBLE_CLICK_MS) {
|
||||||
|
ctx.emitNodeDoubleClick(id, event);
|
||||||
|
lastClickAt = 0;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
lastClickAt = now;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pointerId = -1;
|
pointerId = -1;
|
||||||
started = false;
|
started = false;
|
||||||
|
|||||||
@@ -158,7 +158,9 @@ export function usePanZoom(
|
|||||||
// ── double-click zoom ──────────────────────────────────────────────────────
|
// ── double-click zoom ──────────────────────────────────────────────────────
|
||||||
useEventListener(target, 'dblclick', (event: MouseEvent) => {
|
useEventListener(target, 'dblclick', (event: MouseEvent) => {
|
||||||
if (!zoomOnDoubleClick || !ctx.interactive.value) return;
|
if (!zoomOnDoubleClick || !ctx.interactive.value) return;
|
||||||
if (event.target instanceof Element && event.target.closest('.nopan')) return;
|
// A double click on a node belongs to the node (nodeDoubleClick), not
|
||||||
|
// to the zoom gesture.
|
||||||
|
if (event.target instanceof Element && event.target.closest('.nopan, [data-flow-node]')) return;
|
||||||
const vp = current();
|
const vp = current();
|
||||||
const newZoom = clampZoom(vp.zoom * doubleClickZoomFactor, ctx.minZoom.value, ctx.maxZoom.value);
|
const newZoom = clampZoom(vp.zoom * doubleClickZoomFactor, ctx.minZoom.value, ctx.maxZoom.value);
|
||||||
if (newZoom === vp.zoom) return;
|
if (newZoom === vp.zoom) return;
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ export interface FlowContext {
|
|||||||
// ── change emission ──────────────────────────────────────────────────────
|
// ── change emission ──────────────────────────────────────────────────────
|
||||||
emitNodesChange: (changes: NodeChange[]) => void;
|
emitNodesChange: (changes: NodeChange[]) => void;
|
||||||
emitEdgesChange: (changes: EdgeChange[]) => void;
|
emitEdgesChange: (changes: EdgeChange[]) => void;
|
||||||
|
emitNodeClick: (id: string, event: PointerEvent) => void;
|
||||||
|
emitNodeDoubleClick: (id: string, event: PointerEvent) => void;
|
||||||
|
emitEdgeClick: (id: string, event: PointerEvent) => void;
|
||||||
|
emitPaneClick: (event: PointerEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const flow = useContextFactory<FlowContext>('FlowContext');
|
const flow = useContextFactory<FlowContext>('FlowContext');
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import type { RovingDirection } from '../../internal/utils/roving-focus';
|
|||||||
export type AccordionType = 'single' | 'multiple';
|
export type AccordionType = 'single' | 'multiple';
|
||||||
|
|
||||||
export interface AccordionRootProps extends PrimitiveProps {
|
export interface AccordionRootProps extends PrimitiveProps {
|
||||||
|
/** Controlled open value(s). Bind with `v-model`. */
|
||||||
|
modelValue?: string | string[];
|
||||||
|
|
||||||
/** Initial value(s) for uncontrolled mode. */
|
/** Initial value(s) for uncontrolled mode. */
|
||||||
defaultValue?: string | string[];
|
defaultValue?: string | string[];
|
||||||
|
|
||||||
@@ -51,6 +54,10 @@ export interface AccordionRootProps extends PrimitiveProps {
|
|||||||
/**
|
/**
|
||||||
* Emit contract for `AccordionRoot`. The payload narrows with `Type`: a single
|
* Emit contract for `AccordionRoot`. The payload narrows with `Type`: a single
|
||||||
* accordion emits `string | undefined`, a multiple accordion emits `string[]`.
|
* accordion emits `string | undefined`, a multiple accordion emits `string[]`.
|
||||||
|
*
|
||||||
|
* The event itself is declared by `defineModel`: passing a model key through
|
||||||
|
* `defineEmits` as well erases its payload type from the generated
|
||||||
|
* declarations, leaving consumers with `unknown`.
|
||||||
*/
|
*/
|
||||||
export interface AccordionRootEmits<Type extends AccordionType = AccordionType> {
|
export interface AccordionRootEmits<Type extends AccordionType = AccordionType> {
|
||||||
'update:modelValue': [value: (Type extends 'single' ? string : string[]) | undefined];
|
'update:modelValue': [value: (Type extends 'single' ? string : string[]) | undefined];
|
||||||
@@ -79,8 +86,6 @@ const {
|
|||||||
as = 'div',
|
as = 'div',
|
||||||
} = defineProps<AccordionRootProps>();
|
} = defineProps<AccordionRootProps>();
|
||||||
|
|
||||||
defineEmits<AccordionRootEmits>();
|
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
default?: (props: {
|
default?: (props: {
|
||||||
/** Current open value(s): a `string | undefined` in single mode, `string[]` in multiple. */
|
/** Current open value(s): a `string | undefined` in single mode, `string[]` in multiple. */
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ import type { TabsValue } from './context';
|
|||||||
* via `defaultValue`), orientation, keyboard roving focus across triggers, and
|
* via `defaultValue`), orientation, keyboard roving focus across triggers, and
|
||||||
* provides context to every `TabsList`, `TabsTrigger`, and `TabsContent`.
|
* provides context to every `TabsList`, `TabsTrigger`, and `TabsContent`.
|
||||||
*/
|
*/
|
||||||
export interface TabsRootProps extends PrimitiveProps {
|
export interface TabsRootProps<Value extends TabsValue = TabsValue> extends PrimitiveProps {
|
||||||
/** Controlled selected value. Bind with `v-model`. */
|
/** Controlled selected value. Bind with `v-model`. */
|
||||||
modelValue?: TabsValue;
|
modelValue?: Value;
|
||||||
/** Uncontrolled initial value. */
|
/** Uncontrolled initial value. */
|
||||||
defaultValue?: TabsValue;
|
defaultValue?: Value;
|
||||||
/** Orientation of the tab list. @default 'horizontal' */
|
/** Orientation of the tab list. @default 'horizontal' */
|
||||||
orientation?: 'horizontal' | 'vertical';
|
orientation?: 'horizontal' | 'vertical';
|
||||||
/**
|
/**
|
||||||
@@ -40,13 +40,14 @@ export interface TabsRootProps extends PrimitiveProps {
|
|||||||
unmountOnHide?: boolean;
|
unmountOnHide?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TabsRootEmits {
|
export interface TabsRootEmits<Value extends TabsValue = TabsValue> {
|
||||||
/** Fired when the selected value changes. */
|
/** Fired when the selected value changes. */
|
||||||
'update:modelValue': [value: TabsValue | undefined];
|
'update:modelValue': [value: Value];
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts" generic="Value extends TabsValue = TabsValue">
|
||||||
|
import type { Ref } from 'vue';
|
||||||
import { computed, ref, shallowRef, toRef } from 'vue';
|
import { computed, ref, shallowRef, toRef } from 'vue';
|
||||||
import { resolveNextIndex, rovingKeyToAction } from '../../internal/utils/roving-focus';
|
import { resolveNextIndex, rovingKeyToAction } from '../../internal/utils/roving-focus';
|
||||||
import { useCollectionProvider } from '../../utilities/collection';
|
import { useCollectionProvider } from '../../utilities/collection';
|
||||||
@@ -63,15 +64,16 @@ const {
|
|||||||
activationMode = 'automatic',
|
activationMode = 'automatic',
|
||||||
unmountOnHide = true,
|
unmountOnHide = true,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
|
modelValue,
|
||||||
as = 'div',
|
as = 'div',
|
||||||
} = defineProps<TabsRootProps>();
|
} = defineProps<TabsRootProps<Value>>();
|
||||||
|
|
||||||
defineEmits<TabsRootEmits>();
|
const emit = defineEmits<TabsRootEmits<Value>>();
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
default?: (props: {
|
default?: (props: {
|
||||||
/** Current selected value. */
|
/** Current selected value. */
|
||||||
value: TabsValue | undefined;
|
value: Value | undefined;
|
||||||
}) => unknown;
|
}) => unknown;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -79,16 +81,24 @@ const { forwardRef } = useForwardExpose();
|
|||||||
|
|
||||||
const direction = useDirection(() => dir);
|
const direction = useDirection(() => dir);
|
||||||
|
|
||||||
const localValue = ref<TabsValue | undefined>(defaultValue);
|
// `defineModel` would type `update:modelValue` as `TabsValue | undefined`,
|
||||||
|
// forcing every consumer's `v-model` target to accept `undefined` even though
|
||||||
|
// a tab is never deselected. The prop and the emit are declared separately so
|
||||||
|
// the emitted payload stays exactly `TabsValue` (see AGENTS §3.2.3).
|
||||||
|
const localValue = ref<Value | undefined>(defaultValue) as Ref<Value | undefined>;
|
||||||
|
|
||||||
const value = defineModel<TabsValue | undefined>({
|
const value = computed<Value | undefined>({
|
||||||
get: v => v ?? localValue.value,
|
get: () => modelValue ?? localValue.value,
|
||||||
set: (v) => {
|
set: (v) => {
|
||||||
localValue.value = v;
|
localValue.value = v;
|
||||||
return v;
|
if (v !== undefined) emit('update:modelValue', v);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The tab parts read and write plain `TabsValue`s through the context; the
|
||||||
|
// narrowed `Value` only exists to keep the consumer's `v-model` typed.
|
||||||
|
const contextValue = value as unknown as Ref<TabsValue | undefined>;
|
||||||
|
|
||||||
const baseId = useId(undefined, 'tabs');
|
const baseId = useId(undefined, 'tabs');
|
||||||
const tabsListElement = shallowRef<HTMLElement>();
|
const tabsListElement = shallowRef<HTMLElement>();
|
||||||
|
|
||||||
@@ -116,7 +126,7 @@ function unregisterContent(v: TabsValue): void {
|
|||||||
|
|
||||||
function select(v: TabsValue): void {
|
function select(v: TabsValue): void {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
value.value = v;
|
contextValue.value = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DOM-order tabs via Collection primitive — survives `v-for` reorders and
|
// DOM-order tabs via Collection primitive — survives `v-for` reorders and
|
||||||
@@ -161,7 +171,7 @@ function onTriggerKeyDown(event: KeyboardEvent, el: HTMLElement): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
provideTabsContext({
|
provideTabsContext({
|
||||||
value,
|
value: contextValue,
|
||||||
// Identity passthroughs via `toRef` — reactive without `computed`'s effect/cache.
|
// Identity passthroughs via `toRef` — reactive without `computed`'s effect/cache.
|
||||||
orientation: toRef(() => orientation),
|
orientation: toRef(() => orientation),
|
||||||
direction,
|
direction,
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ export interface CalendarRootProps extends PrimitiveProps {
|
|||||||
dateAdapter?: DateAdapter<Date>;
|
dateAdapter?: DateAdapter<Date>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit contract for `CalendarRoot`. The model events are declared by `defineModel`:
|
||||||
|
* passing a model key through `defineEmits` as well erases its payload type
|
||||||
|
* from the generated declarations, leaving consumers with `unknown`.
|
||||||
|
*/
|
||||||
export interface CalendarRootEmits {
|
export interface CalendarRootEmits {
|
||||||
'update:modelValue': [date: Date | Date[] | undefined];
|
'update:modelValue': [date: Date | Date[] | undefined];
|
||||||
'update:placeholder': [date: Date];
|
'update:placeholder': [date: Date];
|
||||||
@@ -106,8 +111,6 @@ const {
|
|||||||
dateAdapter,
|
dateAdapter,
|
||||||
} = defineProps<CalendarRootProps>();
|
} = defineProps<CalendarRootProps>();
|
||||||
|
|
||||||
defineEmits<CalendarRootEmits>();
|
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
default?: (props: {
|
default?: (props: {
|
||||||
date: Date;
|
date: Date;
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ export interface DatePickerRootProps extends PrimitiveProps,
|
|||||||
hourCycle?: HourCycle;
|
hourCycle?: HourCycle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit contract for `DatePickerRoot`. The model events are declared by `defineModel`:
|
||||||
|
* passing a model key through `defineEmits` as well erases its payload type
|
||||||
|
* from the generated declarations, leaving consumers with `unknown`.
|
||||||
|
*/
|
||||||
export interface DatePickerRootEmits {
|
export interface DatePickerRootEmits {
|
||||||
'update:modelValue': [date: Date | undefined];
|
'update:modelValue': [date: Date | undefined];
|
||||||
'update:placeholder': [date: Date];
|
'update:placeholder': [date: Date];
|
||||||
@@ -95,8 +100,6 @@ const {
|
|||||||
dateAdapter,
|
dateAdapter,
|
||||||
} = defineProps<DatePickerRootProps>();
|
} = defineProps<DatePickerRootProps>();
|
||||||
|
|
||||||
defineEmits<DatePickerRootEmits>();
|
|
||||||
|
|
||||||
const { forwardRef, currentElement: parentElement } = useForwardExpose();
|
const { forwardRef, currentElement: parentElement } = useForwardExpose();
|
||||||
|
|
||||||
// Resolve the effective date backend: per-instance prop wins over the global
|
// Resolve the effective date backend: per-instance prop wins over the global
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export interface ProgressRootProps extends PrimitiveProps {
|
|||||||
accessibleLabel?: string | ((value: number | null, max: number) => string | undefined);
|
accessibleLabel?: string | ((value: number | null, max: number) => string | undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit contract for `ProgressRoot`. The model events are declared by `defineModel`:
|
||||||
|
* passing a model key through `defineEmits` as well erases its payload type
|
||||||
|
* from the generated declarations, leaving consumers with `unknown`.
|
||||||
|
*/
|
||||||
export interface ProgressRootEmits {
|
export interface ProgressRootEmits {
|
||||||
/** Emitted when the value changes (after validation/clamping). */
|
/** Emitted when the value changes (after validation/clamping). */
|
||||||
'update:modelValue': [value: number | null];
|
'update:modelValue': [value: number | null];
|
||||||
@@ -59,8 +64,6 @@ const {
|
|||||||
as = 'div',
|
as = 'div',
|
||||||
} = defineProps<ProgressRootProps>();
|
} = defineProps<ProgressRootProps>();
|
||||||
|
|
||||||
defineEmits<ProgressRootEmits>();
|
|
||||||
|
|
||||||
const { forwardRef } = useForwardExpose();
|
const { forwardRef } = useForwardExpose();
|
||||||
|
|
||||||
const localValue = ref<number | null>(null);
|
const localValue = ref<number | null>(null);
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ export interface SwitchProps<T = boolean> extends PrimitiveProps {
|
|||||||
value?: string;
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit contract for `Switch`. The model events are declared by `defineModel`:
|
||||||
|
* passing a model key through `defineEmits` as well erases its payload type
|
||||||
|
* from the generated declarations, leaving consumers with `unknown`.
|
||||||
|
*/
|
||||||
export interface SwitchEmits<T = boolean> {
|
export interface SwitchEmits<T = boolean> {
|
||||||
/** Emitted whenever the value changes (also drives `v-model`). */
|
/** Emitted whenever the value changes (also drives `v-model`). */
|
||||||
'update:modelValue': [value: T];
|
'update:modelValue': [value: T];
|
||||||
@@ -71,8 +76,6 @@ const {
|
|||||||
as = 'button',
|
as = 'button',
|
||||||
} = defineProps<SwitchProps<T>>();
|
} = defineProps<SwitchProps<T>>();
|
||||||
|
|
||||||
defineEmits<SwitchEmits<T>>();
|
|
||||||
|
|
||||||
const { forwardRef, currentElement } = useForwardExpose();
|
const { forwardRef, currentElement } = useForwardExpose();
|
||||||
|
|
||||||
const local = ref<T>((defaultValue ?? falsy) as T) as Ref<T>;
|
const local = ref<T>((defaultValue ?? falsy) as T) as Ref<T>;
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import type { PrimitiveProps } from '../../internal/primitive';
|
|||||||
/** Canonical `data-state` value reflected on the host element. */
|
/** Canonical `data-state` value reflected on the host element. */
|
||||||
export type ToggleState = 'on' | 'off';
|
export type ToggleState = 'on' | 'off';
|
||||||
|
|
||||||
/** Events emitted by `Toggle`. */
|
/**
|
||||||
|
* Emit contract for `Toggle`. The model events are declared by `defineModel`:
|
||||||
|
* passing a model key through `defineEmits` as well erases its payload type
|
||||||
|
* from the generated declarations, leaving consumers with `unknown`.
|
||||||
|
*/
|
||||||
export interface ToggleEmits {
|
export interface ToggleEmits {
|
||||||
/** Fired when the pressed state changes. Backs `v-model:pressed`. */
|
/** Fired when the pressed state changes. Backs `v-model:pressed`. */
|
||||||
'update:pressed': [pressed: boolean];
|
'update:pressed': [pressed: boolean];
|
||||||
@@ -58,8 +62,6 @@ const {
|
|||||||
value = 'on',
|
value = 'on',
|
||||||
} = defineProps<ToggleProps>();
|
} = defineProps<ToggleProps>();
|
||||||
|
|
||||||
defineEmits<ToggleEmits>();
|
|
||||||
|
|
||||||
const { forwardRef, currentElement } = useForwardExpose();
|
const { forwardRef, currentElement } = useForwardExpose();
|
||||||
|
|
||||||
// A standalone Toggle nested inside a ToggleGroup must not also submit its own
|
// A standalone Toggle nested inside a ToggleGroup must not also submit its own
|
||||||
|
|||||||
@@ -4,7 +4,35 @@ import { renderSlotChild } from './Slot';
|
|||||||
|
|
||||||
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
|
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
|
||||||
|
|
||||||
export interface PrimitiveProps {
|
type Booleanish = boolean | 'true' | 'false';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global DOM attributes any part accepts and forwards, through `$attrs`, to the
|
||||||
|
* element it renders. They are deliberately kept out of the runtime props (the
|
||||||
|
* `@vue-ignore` marker on the heritage clause below stops the SFC compiler from
|
||||||
|
* lifting them out of `$attrs`), so this only teaches `strictTemplates` that
|
||||||
|
* they are valid — the runtime behaviour is unchanged.
|
||||||
|
*/
|
||||||
|
export interface PrimitiveAttributes {
|
||||||
|
id?: string;
|
||||||
|
role?: string;
|
||||||
|
title?: string;
|
||||||
|
tabindex?: number | string;
|
||||||
|
lang?: string;
|
||||||
|
dir?: string;
|
||||||
|
hidden?: Booleanish | 'until-found' | '';
|
||||||
|
inert?: Booleanish;
|
||||||
|
autofocus?: Booleanish;
|
||||||
|
draggable?: Booleanish;
|
||||||
|
spellcheck?: Booleanish;
|
||||||
|
translate?: 'yes' | 'no';
|
||||||
|
nonce?: string;
|
||||||
|
part?: string;
|
||||||
|
slot?: string;
|
||||||
|
[key: `data-${string}` | `aria-${string}`]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrimitiveProps extends /* @vue-ignore */ PrimitiveAttributes {
|
||||||
as?: keyof IntrinsicElementAttributes | Component;
|
as?: keyof IntrinsicElementAttributes | Component;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { Primitive, type PrimitiveProps } from './Primitive';
|
export { Primitive, type PrimitiveAttributes, type PrimitiveProps } from './Primitive';
|
||||||
export { Slot } from './Slot';
|
export { Slot } from './Slot';
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ export interface NavigationMenuRootProps extends PrimitiveProps {
|
|||||||
unmountOnHide?: boolean;
|
unmountOnHide?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit contract for `NavigationMenuRoot`. The model events are declared by `defineModel`:
|
||||||
|
* passing a model key through `defineEmits` as well erases its payload type
|
||||||
|
* from the generated declarations, leaving consumers with `unknown`.
|
||||||
|
*/
|
||||||
export interface NavigationMenuRootEmits {
|
export interface NavigationMenuRootEmits {
|
||||||
'update:modelValue': [value: string];
|
'update:modelValue': [value: string];
|
||||||
}
|
}
|
||||||
@@ -70,8 +75,6 @@ const {
|
|||||||
as = 'nav',
|
as = 'nav',
|
||||||
} = defineProps<NavigationMenuRootProps>();
|
} = defineProps<NavigationMenuRootProps>();
|
||||||
|
|
||||||
defineEmits<NavigationMenuRootEmits>();
|
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
default?: (props: { modelValue: string }) => unknown;
|
default?: (props: { modelValue: string }) => unknown;
|
||||||
}>();
|
}>();
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export interface NavigationMenuSubProps extends PrimitiveProps {
|
|||||||
orientation?: Orientation;
|
orientation?: Orientation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit contract for `NavigationMenuSub`. The model events are declared by `defineModel`:
|
||||||
|
* passing a model key through `defineEmits` as well erases its payload type
|
||||||
|
* from the generated declarations, leaving consumers with `unknown`.
|
||||||
|
*/
|
||||||
export interface NavigationMenuSubEmits {
|
export interface NavigationMenuSubEmits {
|
||||||
'update:modelValue': [value: string];
|
'update:modelValue': [value: string];
|
||||||
}
|
}
|
||||||
@@ -35,8 +40,6 @@ defineOptions({ inheritAttrs: false });
|
|||||||
|
|
||||||
const { defaultValue, orientation = 'horizontal', as = 'div' } = defineProps<NavigationMenuSubProps>();
|
const { defaultValue, orientation = 'horizontal', as = 'div' } = defineProps<NavigationMenuSubProps>();
|
||||||
|
|
||||||
defineEmits<NavigationMenuSubEmits>();
|
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
default?: (props: { modelValue: string }) => unknown;
|
default?: (props: { modelValue: string }) => unknown;
|
||||||
}>();
|
}>();
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ export interface ToolbarRootEmits {
|
|||||||
/** Backs `v-model:currentTabStopId`. */
|
/** Backs `v-model:currentTabStopId`. */
|
||||||
'update:currentTabStopId': [value: string | null | undefined];
|
'update:currentTabStopId': [value: string | null | undefined];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset `defineEmits` declares. `update:currentTabStopId` comes from
|
||||||
|
* `defineModel`; passing a model key through `defineEmits` as well erases its
|
||||||
|
* payload type from the generated declarations, leaving consumers with
|
||||||
|
* `unknown`.
|
||||||
|
*/
|
||||||
|
type ToolbarRootOwnEmits = Omit<ToolbarRootEmits, 'update:currentTabStopId'>;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -64,7 +72,7 @@ const {
|
|||||||
as = 'div',
|
as = 'div',
|
||||||
} = defineProps<ToolbarRootProps>();
|
} = defineProps<ToolbarRootProps>();
|
||||||
|
|
||||||
const emit = defineEmits<ToolbarRootEmits>();
|
const emit = defineEmits<ToolbarRootOwnEmits>();
|
||||||
|
|
||||||
const { forwardRef } = useForwardExpose();
|
const { forwardRef } = useForwardExpose();
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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,203 +299,300 @@ 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
element = element.parentNode as HTMLElement;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Measured once per gesture in onPress and reused every move — avoids a
|
function onPress(event: PointerEvent, captureTarget?: HTMLElement) {
|
||||||
// per-frame getBoundingClientRect (forced reflow) and document.querySelector.
|
// One gesture at a time; a second touch never steals an active drag. But a
|
||||||
let dragStartHeight = 0;
|
// gesture whose capture element left the DOM can never finish (its
|
||||||
let dragWrapper: HTMLElement | null = null;
|
// lostpointercapture fires at the document, past our listeners) — reclaim
|
||||||
|
// it instead of wedging every future drag.
|
||||||
|
if (gesture) {
|
||||||
|
if (gesture.captureTarget.isConnected)
|
||||||
|
return;
|
||||||
|
|
||||||
function onPress(event: PointerEvent) {
|
gesture = null;
|
||||||
|
isAllowedToDrag.value = false;
|
||||||
|
isDragging.value = false;
|
||||||
|
drawerRef.value?.classList.remove(DRAG_CLASS);
|
||||||
|
}
|
||||||
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;
|
|
||||||
const isDraggingInDirection = draggedDistance > 0;
|
|
||||||
|
|
||||||
// Don't allow dragging toward close past the first snap point when not dismissible.
|
// Lock onto an axis on the first significant movement. A gesture that
|
||||||
const noCloseSnapPointsPreCondition = snapPoints.value && !dismissible.value && !isDraggingInDirection;
|
// 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 (noCloseSnapPointsPreCondition && activeSnapPointIndex.value === 0)
|
if (absX < AXIS_LOCK_DISTANCE && absY < AXIS_LOCK_DISTANCE)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const absDraggedDistance = Math.abs(draggedDistance);
|
g.axisLocked = true;
|
||||||
const wrapper = dragWrapper;
|
|
||||||
|
|
||||||
// 1 means the closed position. Height cached at drag start (no reflow).
|
if ((absX > absY) === g.vertical) {
|
||||||
let percentageDragged = absDraggedDistance / (dragStartHeight || 1);
|
g.blocked = true;
|
||||||
const snapPointPercentageDragged = getSnapPointsPercentageDragged(absDraggedDistance, isDraggingInDirection);
|
|
||||||
|
|
||||||
if (snapPointPercentageDragged !== null)
|
|
||||||
percentageDragged = snapPointPercentageDragged;
|
|
||||||
|
|
||||||
if (noCloseSnapPointsPreCondition && percentageDragged >= 1)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Decide-to-drag gate + one-time gesture setup. Once allowed, stay allowed
|
|
||||||
// for the whole gesture, so the class add + transition writes fire ONCE,
|
|
||||||
// not on every move.
|
|
||||||
if (!isAllowedToDrag.value) {
|
|
||||||
if (!shouldDrag(event.target, isDraggingInDirection))
|
|
||||||
return;
|
|
||||||
isAllowedToDrag.value = true;
|
|
||||||
drawerRef.value.classList.add(DRAG_CLASS);
|
|
||||||
setStyle(drawerRef.value, STYLE_NO_TRANSITION);
|
|
||||||
setStyle(overlayRef.value, STYLE_NO_TRANSITION);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapPoints.value)
|
|
||||||
onDragSnapPoints({ draggedDistance });
|
|
||||||
|
|
||||||
// Rubber-band past the open position when there are no snap points.
|
|
||||||
if (isDraggingInDirection && !snapPoints.value) {
|
|
||||||
const dampenedDraggedDistance = dampenValue(draggedDistance);
|
|
||||||
|
|
||||||
const translateValue = Math.min(dampenedDraggedDistance * -1, 0) * directionMultiplier;
|
|
||||||
setStyle(drawerRef.value, {
|
|
||||||
transform: isVertical(direction.value)
|
|
||||||
? `translate3d(0, ${translateValue}px, 0)`
|
|
||||||
: `translate3d(${translateValue}px, 0, 0)`,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const opacityValue = 1 - percentageDragged;
|
g.velocity.add(g.vertical ? event.clientY : event.clientX, event.timeStamp);
|
||||||
|
|
||||||
if (shouldFade.value || (fadeFromIndex.value && activeSnapPointIndex.value === fadeFromIndex.value - 1)) {
|
const draggedDistance = (g.vertical ? g.startY - event.clientY : g.startX - event.clientX) * g.multiplier;
|
||||||
emitDrag(percentageDragged);
|
const isDraggingInDirection = draggedDistance > 0;
|
||||||
|
|
||||||
setStyle(overlayRef.value, { opacity: `${opacityValue}`, transition: 'none' }, true);
|
// 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.
|
||||||
|
const noCloseSnapPointsPreCondition = snapPoints.value && !dismissible.value && !isDraggingInDirection;
|
||||||
|
|
||||||
|
if (noCloseSnapPointsPreCondition && activeSnapPointIndex.value === 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const absDraggedDistance = Math.abs(draggedDistance);
|
||||||
|
|
||||||
|
// 1 means the closed position. Size cached at drag start (no reflow).
|
||||||
|
let percentageDragged = absDraggedDistance / (g.size || 1);
|
||||||
|
const snapPointPercentageDragged = getSnapPointsPercentageDragged(absDraggedDistance, isDraggingInDirection);
|
||||||
|
|
||||||
|
if (snapPointPercentageDragged !== null)
|
||||||
|
percentageDragged = snapPointPercentageDragged;
|
||||||
|
|
||||||
|
if (noCloseSnapPointsPreCondition && percentageDragged >= 1)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Decide-to-drag gate + one-time gesture setup. Once allowed, stay allowed
|
||||||
|
// for the whole gesture, so the class add + transition writes fire ONCE,
|
||||||
|
// not on every move.
|
||||||
|
if (!isAllowedToDrag.value) {
|
||||||
|
if (!shouldDrag(event.target, isDraggingInDirection, event.timeStamp))
|
||||||
|
return;
|
||||||
|
isAllowedToDrag.value = true;
|
||||||
|
drawerRef.value.classList.add(DRAG_CLASS);
|
||||||
|
setStyle(drawerRef.value, STYLE_NO_TRANSITION);
|
||||||
|
setStyle(overlayRef.value, STYLE_NO_TRANSITION);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapPoints.value) {
|
||||||
|
const applied = onDragSnapPoints({ draggedDistance });
|
||||||
|
|
||||||
|
if (applied !== null)
|
||||||
|
g.translate = applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rubber-band past the open position when there are no snap points.
|
||||||
|
if (isDraggingInDirection && !snapPoints.value) {
|
||||||
|
const dampenedDraggedDistance = dampenValue(draggedDistance);
|
||||||
|
const translateValue = Math.min(dampenedDraggedDistance * -1, 0) * g.multiplier;
|
||||||
|
|
||||||
|
writeTransform(drawerRef.value, translateAxis(g.vertical, translateValue));
|
||||||
|
g.translate = translateValue;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldFade.value || (fadeFromIndex.value && activeSnapPointIndex.value === fadeFromIndex.value - 1)) {
|
||||||
|
emitDrag(percentageDragged);
|
||||||
|
|
||||||
|
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 borderRadiusValue = 8 - percentageDragged * 8;
|
|
||||||
const translateValue = Math.max(0, 14 - percentageDragged * 14);
|
|
||||||
|
|
||||||
setStyle(
|
const scaleValue = Math.min(g.scale + percentageDragged * (1 - g.scale), 1);
|
||||||
wrapper,
|
const borderRadiusValue = 8 - percentageDragged * 8;
|
||||||
{
|
const translateValue = Math.max(0, 14 - percentageDragged * 14);
|
||||||
borderRadius: `${borderRadiusValue}px`,
|
const style = g.wrapper.style;
|
||||||
transform: isVertical(direction.value)
|
|
||||||
? `scale(${scaleValue}) translate3d(0, ${translateValue}px, 0)`
|
|
||||||
: `scale(${scaleValue}) translate3d(${translateValue}px, 0, 0)`,
|
|
||||||
transition: 'none',
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!snapPoints.value) {
|
style.borderRadius = `${borderRadiusValue}px`;
|
||||||
const translateValue = absDraggedDistance * directionMultiplier;
|
style.transform = g.vertical
|
||||||
|
? `scale(${scaleValue}) translate3d(0, ${translateValue}px, 0)`
|
||||||
|
: `scale(${scaleValue}) translate3d(${translateValue}px, 0, 0)`;
|
||||||
|
style.transition = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
setStyle(drawerRef.value, {
|
if (!snapPoints.value) {
|
||||||
transform: isVertical(direction.value)
|
const translateValue = absDraggedDistance * g.multiplier;
|
||||||
? `translate3d(0, ${translateValue}px, 0)`
|
|
||||||
: `translate3d(${translateValue}px, 0, 0)`,
|
writeTransform(drawerRef.value, translateAxis(g.vertical, translateValue));
|
||||||
});
|
g.translate = translateValue;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { Ref } from 'vue';
|
import type { Ref } from 'vue';
|
||||||
import { computed, nextTick, ref, watch } from 'vue';
|
import { computed, nextTick, watch } from 'vue';
|
||||||
import { setStyle } from '@robonen/platform/browsers';
|
import { setStyle } from '@robonen/platform/browsers';
|
||||||
import { useEventListener } from '@robonen/vue';
|
import { isVertical, translateAxis, writeTransform } from './helpers';
|
||||||
import { isVertical } from './helpers';
|
import { TRANSITIONS } from './constants';
|
||||||
import { TRANSITIONS, VELOCITY_THRESHOLD } from './constants';
|
import { computeSettleDuration } from './gesture';
|
||||||
|
import { findSnapPointIndex, projectSnapRelease, resolveSnapPointOffset } from './snapping';
|
||||||
import type { DrawerDirection } from './types';
|
import type { DrawerDirection } from './types';
|
||||||
|
|
||||||
interface UseSnapPointsProps {
|
interface UseSnapPointsProps {
|
||||||
@@ -14,16 +15,23 @@ interface UseSnapPointsProps {
|
|||||||
overlayRef: Ref<HTMLElement | undefined>;
|
overlayRef: Ref<HTMLElement | undefined>;
|
||||||
onSnapPointChange: (activeSnapPointIndex: number, snapPointsOffset: number[]) => void;
|
onSnapPointChange: (activeSnapPointIndex: number, snapPointsOffset: number[]) => void;
|
||||||
direction: Ref<DrawerDirection>;
|
direction: Ref<DrawerDirection>;
|
||||||
|
snapToSequentialPoints: Ref<boolean>;
|
||||||
|
/** Shared reactive window dimensions (from the engine's `useWindowSize`). */
|
||||||
|
windowWidth: Ref<number>;
|
||||||
|
windowHeight: Ref<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transition = (property: 'transform' | 'opacity') =>
|
const transition = (property: 'transform' | 'opacity', duration: number = TRANSITIONS.DURATION) =>
|
||||||
`${property} ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
|
`${property} ${duration}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
|
||||||
|
|
||||||
|
function readRootFontSize(): number {
|
||||||
|
return Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drag/release maths for drawers configured with snap points: resolves each
|
* Drag/release maths for drawers configured with snap points: resolves each
|
||||||
* snap point to a pixel offset, animates the drawer between them, and decides
|
* snap point to a pixel offset, animates the drawer between them, and settles
|
||||||
* which point to settle on (or whether to close) based on drag distance and
|
* on release by projecting the drag target along the fling velocity.
|
||||||
* velocity.
|
|
||||||
*/
|
*/
|
||||||
export function useSnapPoints({
|
export function useSnapPoints({
|
||||||
activeSnapPoint,
|
activeSnapPoint,
|
||||||
@@ -33,26 +41,64 @@ export function useSnapPoints({
|
|||||||
fadeFromIndex,
|
fadeFromIndex,
|
||||||
onSnapPointChange,
|
onSnapPointChange,
|
||||||
direction,
|
direction,
|
||||||
|
snapToSequentialPoints,
|
||||||
|
windowWidth,
|
||||||
|
windowHeight,
|
||||||
}: UseSnapPointsProps) {
|
}: UseSnapPointsProps) {
|
||||||
const windowDimensions = ref(globalThis.window !== undefined
|
// Direction resolved once per change instead of string-comparing per move.
|
||||||
? { innerWidth: window.innerWidth, innerHeight: window.innerHeight }
|
const verticalAxis = computed(() => isVertical(direction.value));
|
||||||
: undefined);
|
const dismissMultiplier = computed<1 | -1>(() =>
|
||||||
|
direction.value === 'bottom' || direction.value === 'right' ? 1 : -1,
|
||||||
|
);
|
||||||
|
|
||||||
function onResize() {
|
function windowSizeFor(dir: DrawerDirection): number {
|
||||||
const innerWidth = window.innerWidth;
|
return isVertical(dir) ? windowHeight.value : windowWidth.value;
|
||||||
const innerHeight = window.innerHeight;
|
|
||||||
const cur = windowDimensions.value;
|
|
||||||
// Skip the ref write (and the snapPointsOffset recompute it would trigger)
|
|
||||||
// when dimensions are unchanged — some resize events report identical sizes.
|
|
||||||
if (!cur || cur.innerWidth !== innerWidth || cur.innerHeight !== innerHeight)
|
|
||||||
windowDimensions.value = { innerWidth, innerHeight };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults to `defaultWindow` (SSR-safe) and auto-removes on scope dispose.
|
let warnedInvalid = false;
|
||||||
useEventListener('resize', onResize);
|
|
||||||
|
/**
|
||||||
|
* Inline-translate offsets, index-aligned with `snapPoints` (identity such as
|
||||||
|
* `fadeFromIndex` is preserved). Unresolvable points map to `NaN` and are
|
||||||
|
* excluded from every settle decision.
|
||||||
|
*/
|
||||||
|
const snapPointsOffset = computed<number[]>(() => {
|
||||||
|
const points = snapPoints.value;
|
||||||
|
|
||||||
|
if (!points)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
const windowSize = windowSizeFor(direction.value);
|
||||||
|
const rootFontSize = globalThis.document !== undefined ? readRootFontSize() : 16;
|
||||||
|
const offsets = points.map(point => resolveSnapPointOffset(point, direction.value, windowSize, rootFontSize));
|
||||||
|
|
||||||
|
if (!warnedInvalid && offsets.some(offset => !Number.isFinite(offset))) {
|
||||||
|
warnedInvalid = true;
|
||||||
|
console.warn(
|
||||||
|
'[Drawer] Unsupported snap point value. Use a fraction (0-1), a px number, or a px/rem string:',
|
||||||
|
points.filter((_, index) => !Number.isFinite(offsets[index])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return offsets;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeSnapPointIndex = computed<number | null>(() => {
|
||||||
|
const points = snapPoints.value;
|
||||||
|
|
||||||
|
if (!points)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const windowSize = windowSizeFor(direction.value);
|
||||||
|
const rootFontSize = globalThis.document !== undefined ? readRootFontSize() : 16;
|
||||||
|
|
||||||
|
// Identity first, then resolved-size equivalence (`0.5` vs `'360px'`) so a
|
||||||
|
// controlled active point in a different representation still matches.
|
||||||
|
return findSnapPointIndex(points, activeSnapPoint.value, windowSize, rootFontSize);
|
||||||
|
});
|
||||||
|
|
||||||
const isLastSnapPoint = computed(
|
const isLastSnapPoint = computed(
|
||||||
() => (snapPoints.value && activeSnapPoint.value === snapPoints.value[snapPoints.value.length - 1]) ?? null,
|
() => (snapPoints.value && activeSnapPointIndex.value === snapPoints.value.length - 1) ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const shouldFade = computed(
|
const shouldFade = computed(
|
||||||
@@ -65,58 +111,25 @@ export function useSnapPoints({
|
|||||||
|| !snapPoints.value,
|
|| !snapPoints.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeSnapPointIndex = computed(
|
|
||||||
() => snapPoints.value?.indexOf(activeSnapPoint.value) ?? null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const snapPointsOffset = computed(
|
|
||||||
() =>
|
|
||||||
snapPoints.value?.map((snapPoint) => {
|
|
||||||
const isPx = typeof snapPoint === 'string';
|
|
||||||
let snapPointAsNumber = 0;
|
|
||||||
|
|
||||||
if (isPx)
|
|
||||||
snapPointAsNumber = Number.parseInt(snapPoint, 10);
|
|
||||||
|
|
||||||
if (isVertical(direction.value)) {
|
|
||||||
const height = isPx
|
|
||||||
? snapPointAsNumber
|
|
||||||
: windowDimensions.value
|
|
||||||
? (snapPoint as number) * windowDimensions.value.innerHeight
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
if (windowDimensions.value)
|
|
||||||
return direction.value === 'bottom' ? windowDimensions.value.innerHeight - height : -windowDimensions.value.innerHeight + height;
|
|
||||||
|
|
||||||
return height;
|
|
||||||
}
|
|
||||||
|
|
||||||
const width = isPx
|
|
||||||
? snapPointAsNumber
|
|
||||||
: windowDimensions.value
|
|
||||||
? (snapPoint as number) * windowDimensions.value.innerWidth
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
if (windowDimensions.value)
|
|
||||||
return direction.value === 'right' ? windowDimensions.value.innerWidth - width : -windowDimensions.value.innerWidth + width;
|
|
||||||
|
|
||||||
return width;
|
|
||||||
}) ?? [],
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeSnapPointOffset = computed(() =>
|
const activeSnapPointOffset = computed(() =>
|
||||||
activeSnapPointIndex.value !== null ? snapPointsOffset.value?.[activeSnapPointIndex.value] : null,
|
activeSnapPointIndex.value !== null ? snapPointsOffset.value?.[activeSnapPointIndex.value] : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
function snapToPoint(dimension: number) {
|
function snapToPoint(dimension: number, options?: { velocity?: number; from?: number | null }) {
|
||||||
|
if (!Number.isFinite(dimension))
|
||||||
|
return;
|
||||||
|
|
||||||
const newSnapPointIndex = snapPointsOffset.value?.indexOf(dimension) ?? null;
|
const newSnapPointIndex = snapPointsOffset.value?.indexOf(dimension) ?? null;
|
||||||
|
const from = options?.from;
|
||||||
|
const remaining = typeof from === 'number' ? Math.abs(dimension - from) : Number.NaN;
|
||||||
|
const duration = computeSettleDuration(remaining, options?.velocity ?? 0);
|
||||||
|
|
||||||
// Wait for the element to be mounted before transforming it.
|
// Wait for the element to be mounted before transforming it.
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
onSnapPointChange(newSnapPointIndex, snapPointsOffset.value);
|
onSnapPointChange(newSnapPointIndex, snapPointsOffset.value);
|
||||||
setStyle(drawerRef.value, {
|
setStyle(drawerRef.value, {
|
||||||
transition: transition('transform'),
|
transition: transition('transform', duration),
|
||||||
transform: isVertical(direction.value) ? `translate3d(0, ${dimension}px, 0)` : `translate3d(${dimension}px, 0, 0)`,
|
transform: translateAxis(verticalAxis.value, dimension),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,22 +138,30 @@ export function useSnapPoints({
|
|||||||
&& newSnapPointIndex !== snapPointsOffset.value.length - 1
|
&& newSnapPointIndex !== snapPointsOffset.value.length - 1
|
||||||
&& newSnapPointIndex !== fadeFromIndex?.value
|
&& newSnapPointIndex !== fadeFromIndex?.value
|
||||||
) {
|
) {
|
||||||
setStyle(overlayRef.value, { transition: transition('opacity'), opacity: '0' });
|
setStyle(overlayRef.value, { transition: transition('opacity', duration), opacity: '0' });
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
setStyle(overlayRef.value, { transition: transition('opacity'), opacity: '1' });
|
setStyle(overlayRef.value, { transition: transition('opacity', duration), opacity: '1' });
|
||||||
}
|
}
|
||||||
|
|
||||||
activeSnapPoint.value = newSnapPointIndex !== null ? snapPoints.value?.[newSnapPointIndex] ?? null : null;
|
activeSnapPoint.value = newSnapPointIndex !== null ? snapPoints.value?.[newSnapPointIndex] ?? null : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Settle back onto the active snap point (used when a gesture is aborted). */
|
||||||
|
function restoreActiveSnapPoint() {
|
||||||
|
const offset = activeSnapPointOffset.value;
|
||||||
|
|
||||||
|
if (typeof offset === 'number' && Number.isFinite(offset))
|
||||||
|
snapToPoint(offset);
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[activeSnapPoint, snapPointsOffset, snapPoints],
|
[activeSnapPoint, snapPointsOffset, snapPoints],
|
||||||
() => {
|
() => {
|
||||||
if (activeSnapPoint.value) {
|
if (activeSnapPoint.value) {
|
||||||
const newIndex = snapPoints.value?.indexOf(activeSnapPoint.value) ?? -1;
|
const newIndex = activeSnapPointIndex.value ?? -1;
|
||||||
|
|
||||||
if (snapPointsOffset.value && newIndex !== -1 && typeof snapPointsOffset.value[newIndex] === 'number')
|
if (snapPointsOffset.value && newIndex !== -1 && Number.isFinite(snapPointsOffset.value[newIndex]))
|
||||||
snapToPoint(snapPointsOffset.value[newIndex]);
|
snapToPoint(snapPointsOffset.value[newIndex]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -152,89 +173,66 @@ export function useSnapPoints({
|
|||||||
closeDrawer,
|
closeDrawer,
|
||||||
velocity,
|
velocity,
|
||||||
dismissible,
|
dismissible,
|
||||||
|
drawerSize,
|
||||||
}: {
|
}: {
|
||||||
|
/** Drag distance since press, positive toward open/expand. */
|
||||||
draggedDistance: number;
|
draggedDistance: number;
|
||||||
closeDrawer: () => void;
|
closeDrawer: () => void;
|
||||||
|
/** Instantaneous release velocity, positive toward dismiss (px/ms). */
|
||||||
velocity: number;
|
velocity: number;
|
||||||
dismissible: boolean;
|
dismissible: boolean;
|
||||||
|
/** Drawer size (px) along the drag axis. */
|
||||||
|
drawerSize: number;
|
||||||
}) {
|
}) {
|
||||||
if (fadeFromIndex.value === undefined)
|
if (fadeFromIndex.value === undefined)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const currentPosition
|
const multiplier = dismissMultiplier.value;
|
||||||
= direction.value === 'bottom' || direction.value === 'right'
|
const offsets = snapPointsOffset.value.map(offset => offset * multiplier);
|
||||||
? (activeSnapPointOffset.value ?? 0) - draggedDistance
|
|
||||||
: (activeSnapPointOffset.value ?? 0) + draggedDistance;
|
|
||||||
const isOverlaySnapPoint = activeSnapPointIndex.value === fadeFromIndex.value - 1;
|
const isOverlaySnapPoint = activeSnapPointIndex.value === fadeFromIndex.value - 1;
|
||||||
const isFirst = activeSnapPointIndex.value === 0;
|
|
||||||
const hasDraggedUp = draggedDistance > 0;
|
|
||||||
|
|
||||||
if (isOverlaySnapPoint)
|
if (isOverlaySnapPoint)
|
||||||
setStyle(overlayRef.value, { transition: transition('opacity') });
|
setStyle(overlayRef.value, { transition: transition('opacity') });
|
||||||
|
|
||||||
if (velocity > 2 && !hasDraggedUp) {
|
const result = projectSnapRelease({
|
||||||
if (dismissible)
|
offsets,
|
||||||
closeDrawer();
|
activeIndex: activeSnapPointIndex.value,
|
||||||
else
|
draggedDistance,
|
||||||
snapToPoint(snapPointsOffset.value[0]); // snap to initial point
|
velocity,
|
||||||
return;
|
drawerSize,
|
||||||
}
|
dismissible,
|
||||||
|
sequential: snapToSequentialPoints.value,
|
||||||
if (velocity > 2 && hasDraggedUp && snapPointsOffset.value && snapPoints.value) {
|
|
||||||
snapToPoint(snapPointsOffset.value[snapPoints.value.length - 1]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settle on the snap point closest to where the drag ended.
|
|
||||||
const closestSnapPoint = snapPointsOffset.value?.reduce((prev, curr) => {
|
|
||||||
if (typeof prev !== 'number' || typeof curr !== 'number')
|
|
||||||
return prev;
|
|
||||||
|
|
||||||
return Math.abs(curr - currentPosition) < Math.abs(prev - currentPosition) ? curr : prev;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const dim = isVertical(direction.value) ? window.innerHeight : window.innerWidth;
|
if (result.type === 'close') {
|
||||||
if (velocity > VELOCITY_THRESHOLD && Math.abs(draggedDistance) < dim * 0.4) {
|
closeDrawer();
|
||||||
const dragDirection = hasDraggedUp ? 1 : -1; // 1 = up, -1 = down
|
|
||||||
|
|
||||||
// Ignore an upward flick while already on the last snap point.
|
|
||||||
if (dragDirection > 0 && isLastSnapPoint.value) {
|
|
||||||
snapToPoint(snapPointsOffset.value[(snapPoints.value?.length ?? 0) - 1]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFirst && dragDirection < 0 && dismissible)
|
|
||||||
closeDrawer();
|
|
||||||
|
|
||||||
if (activeSnapPointIndex.value === null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
snapToPoint(snapPointsOffset.value[activeSnapPointIndex.value + dragDirection]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
snapToPoint(closestSnapPoint);
|
const target = snapPointsOffset.value[result.index];
|
||||||
|
const from = (activeSnapPointOffset.value ?? 0) - draggedDistance * multiplier;
|
||||||
|
|
||||||
|
snapToPoint(target, { velocity, from });
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDrag({ draggedDistance }: { draggedDistance: number }) {
|
function onDrag({ draggedDistance }: { draggedDistance: number }): number | null {
|
||||||
if (activeSnapPointOffset.value === null)
|
const activeOffset = activeSnapPointOffset.value;
|
||||||
return;
|
|
||||||
|
|
||||||
const newValue
|
if (activeOffset === null || activeOffset === undefined || !Number.isFinite(activeOffset))
|
||||||
= direction.value === 'bottom' || direction.value === 'right'
|
return null;
|
||||||
? (activeSnapPointOffset.value ?? 0) - draggedDistance
|
|
||||||
: (activeSnapPointOffset.value ?? 0) + draggedDistance;
|
const positive = dismissMultiplier.value === 1;
|
||||||
|
const newValue = positive ? activeOffset - draggedDistance : activeOffset + draggedDistance;
|
||||||
|
const offsets = snapPointsOffset.value;
|
||||||
|
const lastOffset = offsets[offsets.length - 1];
|
||||||
|
|
||||||
// Don't drag past the last (largest) snap point.
|
// Don't drag past the last (largest) snap point.
|
||||||
if ((direction.value === 'bottom' || direction.value === 'right') && newValue < snapPointsOffset.value[snapPointsOffset.value.length - 1])
|
if (Number.isFinite(lastOffset) && (positive ? newValue < lastOffset : newValue > lastOffset))
|
||||||
return;
|
return null;
|
||||||
|
|
||||||
if ((direction.value === 'top' || direction.value === 'left') && newValue > snapPointsOffset.value[snapPointsOffset.value.length - 1])
|
writeTransform(drawerRef.value, translateAxis(verticalAxis.value, newValue));
|
||||||
return;
|
|
||||||
|
|
||||||
setStyle(drawerRef.value, {
|
return newValue;
|
||||||
transform: isVertical(direction.value) ? `translate3d(0, ${newValue}px, 0)` : `translate3d(${newValue}px, 0, 0)`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPercentageDragged(absDraggedDistance: number, isDraggingDown: boolean) {
|
function getPercentageDragged(absDraggedDistance: number, isDraggingDown: boolean) {
|
||||||
@@ -278,6 +276,7 @@ export function useSnapPoints({
|
|||||||
activeSnapPointIndex,
|
activeSnapPointIndex,
|
||||||
onRelease,
|
onRelease,
|
||||||
onDrag,
|
onDrag,
|
||||||
|
restoreActiveSnapPoint,
|
||||||
snapPointsOffset,
|
snapPointsOffset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ import { useSelectRootContext } from './context';
|
|||||||
import SelectContentImpl from './SelectContentImpl.vue';
|
import SelectContentImpl from './SelectContentImpl.vue';
|
||||||
import SelectProvider from './SelectProvider.vue';
|
import SelectProvider from './SelectProvider.vue';
|
||||||
|
|
||||||
|
// Neither branch below is a single element root (`Presence` wraps the panel,
|
||||||
|
// the closed branch is a `Teleport`), so Vue cannot inherit `class`/`style` or
|
||||||
|
// any other attribute automatically — they are forwarded onto the panel itself.
|
||||||
|
defineOptions({ inheritAttrs: false });
|
||||||
|
|
||||||
const props = defineProps<SelectContentProps>();
|
const props = defineProps<SelectContentProps>();
|
||||||
const emit = defineEmits<SelectContentEmits>();
|
const emit = defineEmits<SelectContentEmits>();
|
||||||
const rootCtx = useSelectRootContext();
|
const rootCtx = useSelectRootContext();
|
||||||
@@ -57,7 +62,7 @@ onMounted(() => {
|
|||||||
:present="present"
|
:present="present"
|
||||||
>
|
>
|
||||||
<SelectContentImpl
|
<SelectContentImpl
|
||||||
v-bind="props"
|
v-bind="{ ...props, ...$attrs }"
|
||||||
@close-auto-focus="emit('closeAutoFocus', $event)"
|
@close-auto-focus="emit('closeAutoFocus', $event)"
|
||||||
@escape-key-down="emit('escapeKeyDown', $event)"
|
@escape-key-down="emit('escapeKeyDown', $event)"
|
||||||
@pointer-down-outside="emit('pointerDownOutside', $event)"
|
@pointer-down-outside="emit('pointerDownOutside', $event)"
|
||||||
|
|||||||
@@ -63,8 +63,11 @@ const selectedItemTextRef = rootCtx.selectedItemTextRef;
|
|||||||
|
|
||||||
const firstValidItemFoundRef = ref(false);
|
const firstValidItemFoundRef = ref(false);
|
||||||
|
|
||||||
// Recompute the selected/first-valid item afresh for this open cycle.
|
// Recompute the selected/first-valid item afresh for this open cycle. The text
|
||||||
|
// node is reset alongside it: the item-aligned positioner reads the two as a
|
||||||
|
// pair, so a stale text node would pair with a fresh item and skew placement.
|
||||||
selectedItemRef.value = undefined;
|
selectedItemRef.value = undefined;
|
||||||
|
selectedItemTextRef.value = undefined;
|
||||||
|
|
||||||
// Resolve the actual listbox content element. The item-aligned strategy renders
|
// Resolve the actual listbox content element. The item-aligned strategy renders
|
||||||
// a positioning wrapper whose first child is the listbox; the popper strategy
|
// a positioning wrapper whose first child is the listbox; the popper strategy
|
||||||
|
|||||||
@@ -47,6 +47,46 @@ const shouldExpandOnScrollRef = ref(false);
|
|||||||
const shouldRepositionRef = ref(true);
|
const shouldRepositionRef = ref(true);
|
||||||
const contentZIndex = ref('');
|
const contentZIndex = ref('');
|
||||||
|
|
||||||
|
// When nothing is selected the content adopts the first valid item as the
|
||||||
|
// alignment anchor, but only that item is registered — its text node registers
|
||||||
|
// solely for the *selected* value. Recover it from the item's own label
|
||||||
|
// association instead of demanding a second registration, which would mean
|
||||||
|
// writing to the anchor refs from inside the item's own tracking effect.
|
||||||
|
function itemTextOf(item: HTMLElement | undefined): HTMLElement | undefined {
|
||||||
|
const id = item?.getAttribute('aria-labelledby');
|
||||||
|
return id ? item?.ownerDocument.getElementById(id) ?? undefined : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline styles the wrapper is positioned with. Written as one object and
|
||||||
|
* committed in a single pass: every geometry read below happens before the
|
||||||
|
* first write, so the browser performs one layout for the whole placement
|
||||||
|
* instead of one per interleaved read.
|
||||||
|
*
|
||||||
|
* Both edges of each axis are always present. A resize can flip the vertical
|
||||||
|
* branch, and leaving the previous edge behind would over-constrain the box.
|
||||||
|
*/
|
||||||
|
interface WrapperPlacement {
|
||||||
|
minWidth: string;
|
||||||
|
left: string;
|
||||||
|
right: string;
|
||||||
|
top: string;
|
||||||
|
bottom: string;
|
||||||
|
height: string;
|
||||||
|
minHeight: string;
|
||||||
|
maxHeight: string;
|
||||||
|
margin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_PLACEMENT: WrapperPlacement = {
|
||||||
|
minWidth: '', left: '', right: '', top: '', bottom: '',
|
||||||
|
height: '', minHeight: '', maxHeight: '', margin: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function commit(wrapper: HTMLElement, placement: Partial<WrapperPlacement>) {
|
||||||
|
Object.assign(wrapper.style, EMPTY_PLACEMENT, placement);
|
||||||
|
}
|
||||||
|
|
||||||
function position() {
|
function position() {
|
||||||
const trigger = rootCtx.triggerElement.value;
|
const trigger = rootCtx.triggerElement.value;
|
||||||
const valueNode = rootCtx.valueElement.value;
|
const valueNode = rootCtx.valueElement.value;
|
||||||
@@ -54,20 +94,61 @@ function position() {
|
|||||||
const content = contentElement.value;
|
const content = contentElement.value;
|
||||||
const viewport = contentCtx.viewportRef.value;
|
const viewport = contentCtx.viewportRef.value;
|
||||||
const selectedItem = contentCtx.selectedItemRef.value;
|
const selectedItem = contentCtx.selectedItemRef.value;
|
||||||
const selectedItemText = contentCtx.selectedItemTextRef.value;
|
const selectedItemText = contentCtx.selectedItemTextRef.value ?? itemTextOf(selectedItem);
|
||||||
|
|
||||||
if (!trigger || !valueNode || !wrapper || !content || !viewport || !selectedItem || !selectedItemText) {
|
if (!trigger || !wrapper || !content || !viewport) {
|
||||||
emit('placed');
|
emit('placed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerRect = trigger.getBoundingClientRect();
|
// Item-aligned placement centres the panel on the selected item, so without
|
||||||
|
// one there is nothing to align to — an empty option list, or items that have
|
||||||
|
// not registered yet. Drop the panel under the trigger instead of returning:
|
||||||
|
// the wrapper is `position: fixed`, so leaving it unplaced pins it to the
|
||||||
|
// viewport origin, where it reads as "the dropdown does not open".
|
||||||
|
if (!valueNode || !selectedItem || !selectedItemText) {
|
||||||
|
const rect = trigger.getBoundingClientRect();
|
||||||
|
const rightEdge = window.innerWidth - CONTENT_MARGIN;
|
||||||
|
commit(wrapper, {
|
||||||
|
minWidth: `${rect.width}px`,
|
||||||
|
left: `${clamp(rect.left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - rect.width))}px`,
|
||||||
|
top: `${rect.bottom}px`,
|
||||||
|
maxHeight: `${Math.max(0, window.innerHeight - rect.bottom - CONTENT_MARGIN)}px`,
|
||||||
|
});
|
||||||
|
emit('placed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Horizontal positioning ---
|
// --- Measure: every layout read lives here, before the first write ---
|
||||||
|
const triggerRect = trigger.getBoundingClientRect();
|
||||||
const contentRect = content.getBoundingClientRect();
|
const contentRect = content.getBoundingClientRect();
|
||||||
const valueNodeRect = valueNode.getBoundingClientRect();
|
const valueNodeRect = valueNode.getBoundingClientRect();
|
||||||
const itemTextRect = selectedItemText.getBoundingClientRect();
|
const itemTextRect = selectedItemText.getBoundingClientRect();
|
||||||
|
|
||||||
|
const items = Array.from(
|
||||||
|
viewport.querySelectorAll<HTMLElement>('[data-primitives-select-item]'),
|
||||||
|
);
|
||||||
|
const itemsHeight = viewport.scrollHeight;
|
||||||
|
const viewportOffsetTop = viewport.offsetTop;
|
||||||
|
const viewportOffsetHeight = viewport.offsetHeight;
|
||||||
|
const contentClientHeight = content.clientHeight;
|
||||||
|
const selectedItemHeight = selectedItem.offsetHeight;
|
||||||
|
const selectedItemOffsetTop = selectedItem.offsetTop;
|
||||||
|
|
||||||
|
const contentStyles = globalThis.getComputedStyle(content);
|
||||||
|
const contentBorderTopWidth = Number.parseInt(contentStyles.borderTopWidth, 10) || 0;
|
||||||
|
const contentPaddingTop = Number.parseInt(contentStyles.paddingTop, 10) || 0;
|
||||||
|
const contentBorderBottomWidth = Number.parseInt(contentStyles.borderBottomWidth, 10) || 0;
|
||||||
|
const contentPaddingBottom = Number.parseInt(contentStyles.paddingBottom, 10) || 0;
|
||||||
|
|
||||||
|
const viewportStyles = globalThis.getComputedStyle(viewport);
|
||||||
|
const viewportPaddingTop = Number.parseInt(viewportStyles.paddingTop, 10) || 0;
|
||||||
|
const viewportPaddingBottom = Number.parseInt(viewportStyles.paddingBottom, 10) || 0;
|
||||||
|
|
||||||
|
// --- Compute ---
|
||||||
|
const placement: Partial<WrapperPlacement> = {};
|
||||||
|
const availableHeight = window.innerHeight - CONTENT_MARGIN * 2;
|
||||||
|
|
||||||
if (rootCtx.dir.value !== 'rtl') {
|
if (rootCtx.dir.value !== 'rtl') {
|
||||||
const itemTextOffset = itemTextRect.left - contentRect.left;
|
const itemTextOffset = itemTextRect.left - contentRect.left;
|
||||||
const left = valueNodeRect.left - itemTextOffset;
|
const left = valueNodeRect.left - itemTextOffset;
|
||||||
@@ -75,10 +156,9 @@ function position() {
|
|||||||
const minContentWidth = triggerRect.width + leftDelta;
|
const minContentWidth = triggerRect.width + leftDelta;
|
||||||
const contentWidth = Math.max(minContentWidth, contentRect.width);
|
const contentWidth = Math.max(minContentWidth, contentRect.width);
|
||||||
const rightEdge = window.innerWidth - CONTENT_MARGIN;
|
const rightEdge = window.innerWidth - CONTENT_MARGIN;
|
||||||
const clampedLeft = clamp(left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - contentWidth));
|
|
||||||
|
|
||||||
wrapper.style.minWidth = `${minContentWidth}px`;
|
placement.minWidth = `${minContentWidth}px`;
|
||||||
wrapper.style.left = `${clampedLeft}px`;
|
placement.left = `${clamp(left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - contentWidth))}px`;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const itemTextOffset = contentRect.right - itemTextRect.right;
|
const itemTextOffset = contentRect.right - itemTextRect.right;
|
||||||
@@ -87,67 +167,52 @@ function position() {
|
|||||||
const minContentWidth = triggerRect.width + rightDelta;
|
const minContentWidth = triggerRect.width + rightDelta;
|
||||||
const contentWidth = Math.max(minContentWidth, contentRect.width);
|
const contentWidth = Math.max(minContentWidth, contentRect.width);
|
||||||
const leftEdge = window.innerWidth - CONTENT_MARGIN;
|
const leftEdge = window.innerWidth - CONTENT_MARGIN;
|
||||||
const clampedRight = clamp(right, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, leftEdge - contentWidth));
|
|
||||||
|
|
||||||
wrapper.style.minWidth = `${minContentWidth}px`;
|
placement.minWidth = `${minContentWidth}px`;
|
||||||
wrapper.style.right = `${clampedRight}px`;
|
placement.right = `${clamp(right, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, leftEdge - contentWidth))}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Vertical positioning ---
|
|
||||||
const items = Array.from(
|
|
||||||
viewport.querySelectorAll<HTMLElement>('[data-primitives-select-item]'),
|
|
||||||
);
|
|
||||||
const availableHeight = window.innerHeight - CONTENT_MARGIN * 2;
|
|
||||||
const itemsHeight = viewport.scrollHeight;
|
|
||||||
|
|
||||||
const contentStyles = globalThis.getComputedStyle(content);
|
|
||||||
const contentBorderTopWidth = Number.parseInt(contentStyles.borderTopWidth, 10) || 0;
|
|
||||||
const contentPaddingTop = Number.parseInt(contentStyles.paddingTop, 10) || 0;
|
|
||||||
const contentBorderBottomWidth = Number.parseInt(contentStyles.borderBottomWidth, 10) || 0;
|
|
||||||
const contentPaddingBottom = Number.parseInt(contentStyles.paddingBottom, 10) || 0;
|
|
||||||
const fullContentHeight = contentBorderTopWidth + contentPaddingTop + itemsHeight + contentPaddingBottom + contentBorderBottomWidth;
|
const fullContentHeight = contentBorderTopWidth + contentPaddingTop + itemsHeight + contentPaddingBottom + contentBorderBottomWidth;
|
||||||
const minContentHeight = Math.min(selectedItem.offsetHeight * 5, fullContentHeight);
|
|
||||||
|
|
||||||
const viewportStyles = globalThis.getComputedStyle(viewport);
|
|
||||||
const viewportPaddingTop = Number.parseInt(viewportStyles.paddingTop, 10) || 0;
|
|
||||||
const viewportPaddingBottom = Number.parseInt(viewportStyles.paddingBottom, 10) || 0;
|
|
||||||
|
|
||||||
const topEdgeToTriggerMiddle = triggerRect.top + triggerRect.height / 2 - CONTENT_MARGIN;
|
const topEdgeToTriggerMiddle = triggerRect.top + triggerRect.height / 2 - CONTENT_MARGIN;
|
||||||
const triggerMiddleToBottomEdge = availableHeight - topEdgeToTriggerMiddle;
|
const triggerMiddleToBottomEdge = availableHeight - topEdgeToTriggerMiddle;
|
||||||
|
|
||||||
const selectedItemHalfHeight = selectedItem.offsetHeight / 2;
|
const selectedItemHalfHeight = selectedItemHeight / 2;
|
||||||
const itemOffsetMiddle = selectedItem.offsetTop + selectedItemHalfHeight;
|
const itemOffsetMiddle = selectedItemOffsetTop + selectedItemHalfHeight;
|
||||||
const contentTopToItemMiddle = contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle;
|
const contentTopToItemMiddle = contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle;
|
||||||
const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle;
|
const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle;
|
||||||
|
|
||||||
const willAlignWithoutTopOverflow = contentTopToItemMiddle <= topEdgeToTriggerMiddle;
|
let scrollTop: number | undefined;
|
||||||
|
|
||||||
if (willAlignWithoutTopOverflow) {
|
if (contentTopToItemMiddle <= topEdgeToTriggerMiddle) {
|
||||||
const isLastItem = selectedItem === items.at(-1);
|
const isLastItem = selectedItem === items.at(-1);
|
||||||
wrapper.style.bottom = '0px';
|
const viewportOffsetBottom = contentClientHeight - viewportOffsetTop - viewportOffsetHeight;
|
||||||
const viewportOffsetBottom = content.clientHeight - viewport.offsetTop - viewport.offsetHeight;
|
|
||||||
const clampedTriggerMiddleToBottomEdge = Math.max(
|
const clampedTriggerMiddleToBottomEdge = Math.max(
|
||||||
triggerMiddleToBottomEdge,
|
triggerMiddleToBottomEdge,
|
||||||
selectedItemHalfHeight + (isLastItem ? viewportPaddingBottom : 0) + viewportOffsetBottom + contentBorderBottomWidth,
|
selectedItemHalfHeight + (isLastItem ? viewportPaddingBottom : 0) + viewportOffsetBottom + contentBorderBottomWidth,
|
||||||
);
|
);
|
||||||
const height = contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge;
|
|
||||||
wrapper.style.height = `${height}px`;
|
placement.bottom = '0px';
|
||||||
|
placement.height = `${contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge}px`;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const isFirstItem = selectedItem === items[0];
|
const isFirstItem = selectedItem === items[0];
|
||||||
wrapper.style.top = '0px';
|
|
||||||
const clampedTopEdgeToTriggerMiddle = Math.max(
|
const clampedTopEdgeToTriggerMiddle = Math.max(
|
||||||
topEdgeToTriggerMiddle,
|
topEdgeToTriggerMiddle,
|
||||||
contentBorderTopWidth + viewport.offsetTop + (isFirstItem ? viewportPaddingTop : 0) + selectedItemHalfHeight,
|
contentBorderTopWidth + viewportOffsetTop + (isFirstItem ? viewportPaddingTop : 0) + selectedItemHalfHeight,
|
||||||
);
|
);
|
||||||
const height = clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom;
|
|
||||||
wrapper.style.height = `${height}px`;
|
placement.top = '0px';
|
||||||
viewport.scrollTop = contentTopToItemMiddle - topEdgeToTriggerMiddle + viewport.offsetTop;
|
placement.height = `${clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom}px`;
|
||||||
|
scrollTop = contentTopToItemMiddle - topEdgeToTriggerMiddle + viewportOffsetTop;
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapper.style.margin = `${CONTENT_MARGIN}px 0`;
|
placement.margin = `${CONTENT_MARGIN}px 0`;
|
||||||
wrapper.style.minHeight = `${minContentHeight}px`;
|
placement.minHeight = `${Math.min(selectedItemHeight * 5, fullContentHeight)}px`;
|
||||||
wrapper.style.maxHeight = `${availableHeight}px`;
|
placement.maxHeight = `${availableHeight}px`;
|
||||||
|
|
||||||
|
// --- Commit ---
|
||||||
|
commit(wrapper, placement);
|
||||||
|
if (scrollTop !== undefined) viewport.scrollTop = scrollTop;
|
||||||
|
|
||||||
emit('placed');
|
emit('placed');
|
||||||
requestAnimationFrame(() => (shouldExpandOnScrollRef.value = true));
|
requestAnimationFrame(() => (shouldExpandOnScrollRef.value = true));
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
import type { Direction } from '../../utilities/config-provider';
|
import type { Direction } from '../../utilities/config-provider';
|
||||||
import type { AcceptableValue } from './utils';
|
import type { AcceptableValue } from './utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape of the select's model value: an array of `T` in multiple mode, a bare
|
||||||
|
* `T` otherwise. Keeps `v-model` narrow on both sides of the binding.
|
||||||
|
*/
|
||||||
|
export type SelectModelValue<T extends AcceptableValue, Multiple extends boolean> = Multiple extends true ? T[] : T;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom, fully stylable replacement for the native `<select>` element: a
|
* A custom, fully stylable replacement for the native `<select>` element: a
|
||||||
* trigger button that opens a floating listbox of options, with full keyboard
|
* trigger button that opens a floating listbox of options, with full keyboard
|
||||||
@@ -16,7 +22,9 @@ import type { AcceptableValue } from './utils';
|
|||||||
* (compared via `by`). Compose it from a `SelectTrigger` (with
|
* (compared via `by`). Compose it from a `SelectTrigger` (with
|
||||||
* `SelectValue`/`SelectIcon`) plus a portalled `SelectContent` of `SelectItem`s.
|
* `SelectValue`/`SelectIcon`) plus a portalled `SelectContent` of `SelectItem`s.
|
||||||
*/
|
*/
|
||||||
export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> {
|
export interface SelectRootProps<T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false> {
|
||||||
|
/** Controlled value. Bind with `v-model`. */
|
||||||
|
modelValue?: SelectModelValue<T, Multiple>;
|
||||||
/** Reading direction. Falls back to ConfigProvider. */
|
/** Reading direction. Falls back to ConfigProvider. */
|
||||||
dir?: Direction;
|
dir?: Direction;
|
||||||
/** Disable the whole select. */
|
/** Disable the whole select. */
|
||||||
@@ -26,11 +34,11 @@ export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> {
|
|||||||
/** Native input name for form submission. */
|
/** Native input name for form submission. */
|
||||||
name?: string;
|
name?: string;
|
||||||
/** Uncontrolled default value. */
|
/** Uncontrolled default value. */
|
||||||
defaultValue?: T | T[];
|
defaultValue?: SelectModelValue<T, Multiple>;
|
||||||
/** Uncontrolled default open state. */
|
/** Uncontrolled default open state. */
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
/** Allow selecting multiple options; the model becomes an array. */
|
/** Allow selecting multiple options; the model becomes an array. */
|
||||||
multiple?: boolean;
|
multiple?: Multiple;
|
||||||
/**
|
/**
|
||||||
* Compare object values by a property key or a custom comparator. Omitted →
|
* Compare object values by a property key or a custom comparator. Omitted →
|
||||||
* `===` for primitives / structural deep-equality for objects.
|
* `===` for primitives / structural deep-equality for objects.
|
||||||
@@ -40,13 +48,20 @@ export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> {
|
|||||||
autocomplete?: string;
|
autocomplete?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectRootEmits<T extends AcceptableValue = AcceptableValue> {
|
export interface SelectRootEmits<T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false> {
|
||||||
'update:modelValue': [value: T | T[] | undefined];
|
'update:modelValue': [value: SelectModelValue<T, Multiple>];
|
||||||
'update:open': [open: boolean];
|
'update:open': [open: boolean];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset `defineEmits` declares. `update:open` comes from `defineModel`;
|
||||||
|
* passing a model key through `defineEmits` as well erases its payload type
|
||||||
|
* from the generated declarations, leaving consumers with `unknown`.
|
||||||
|
*/
|
||||||
|
type SelectRootOwnEmits<T extends AcceptableValue, Multiple extends boolean> = Omit<SelectRootEmits<T, Multiple>, 'update:open'>;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
|
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false">
|
||||||
import type { Ref } from 'vue';
|
import type { Ref } from 'vue';
|
||||||
import { computed, ref, shallowRef, toRef, watch } from 'vue';
|
import { computed, ref, shallowRef, toRef, watch } from 'vue';
|
||||||
|
|
||||||
@@ -60,6 +75,7 @@ import { compare, shouldShowPlaceholder } from './utils';
|
|||||||
defineOptions({ inheritAttrs: false });
|
defineOptions({ inheritAttrs: false });
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
modelValue,
|
||||||
dir,
|
dir,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
required = false,
|
required = false,
|
||||||
@@ -69,11 +85,13 @@ const {
|
|||||||
multiple = false,
|
multiple = false,
|
||||||
by,
|
by,
|
||||||
autocomplete,
|
autocomplete,
|
||||||
} = defineProps<SelectRootProps<T>>();
|
} = defineProps<SelectRootProps<T, Multiple>>();
|
||||||
|
|
||||||
|
const emit = defineEmits<SelectRootOwnEmits<T, Multiple>>();
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
default?: (props: {
|
default?: (props: {
|
||||||
modelValue: T | T[] | undefined;
|
modelValue: SelectModelValue<T, Multiple> | undefined;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
}) => unknown;
|
}) => unknown;
|
||||||
}>();
|
}>();
|
||||||
@@ -88,16 +106,26 @@ const open = defineModel<boolean>('open', {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const localValue = ref<T | T[] | undefined>(defaultValue ?? (multiple ? ([] as T[]) : undefined)) as Ref<T | T[] | undefined>;
|
type ModelValue = SelectModelValue<T, Multiple>;
|
||||||
const value = defineModel<T | T[] | undefined>('modelValue', {
|
|
||||||
default: undefined,
|
// `defineModel` would type `update:modelValue` as `ModelValue | undefined`,
|
||||||
get: v => (v ?? localValue.value),
|
// forcing every consumer's `v-model` target to accept `undefined` even though
|
||||||
|
// a selection is never cleared. The prop and the emit are declared separately
|
||||||
|
// so the emitted payload stays exactly `ModelValue` (see AGENTS §3.2.3).
|
||||||
|
const localValue = ref(defaultValue ?? (multiple ? [] : undefined)) as Ref<ModelValue | undefined>;
|
||||||
|
const value = computed<ModelValue | undefined>({
|
||||||
|
get: () => modelValue ?? localValue.value,
|
||||||
set: (v) => {
|
set: (v) => {
|
||||||
localValue.value = v;
|
localValue.value = v;
|
||||||
return v;
|
emit('update:modelValue', v as ModelValue);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The public model type is conditional on `Multiple`, which TypeScript cannot
|
||||||
|
// narrow inside the component; the internal logic reads and writes the union
|
||||||
|
// through this widened alias instead.
|
||||||
|
const model = value as unknown as Ref<T | T[] | undefined>;
|
||||||
|
|
||||||
const contentId = useId(undefined, 'select-content');
|
const contentId = useId(undefined, 'select-content');
|
||||||
const dirRef = toRef(() => dir);
|
const dirRef = toRef(() => dir);
|
||||||
const disabledRef = toRef(() => disabled);
|
const disabledRef = toRef(() => disabled);
|
||||||
@@ -119,7 +147,7 @@ const displayValue = ref<string | undefined>(undefined);
|
|||||||
const rawOptions = new Set<SelectOption>();
|
const rawOptions = new Set<SelectOption>();
|
||||||
const optionsSet = shallowRef(new Set<SelectOption>());
|
const optionsSet = shallowRef(new Set<SelectOption>());
|
||||||
|
|
||||||
const isEmptyModelValue = computed(() => shouldShowPlaceholder(value.value));
|
const isEmptyModelValue = computed(() => shouldShowPlaceholder(model.value));
|
||||||
|
|
||||||
function getOptionFrom(source: Iterable<SelectOption>, v: AcceptableValue): SelectOption | undefined {
|
function getOptionFrom(source: Iterable<SelectOption>, v: AcceptableValue): SelectOption | undefined {
|
||||||
for (const option of source) {
|
for (const option of source) {
|
||||||
@@ -143,8 +171,8 @@ function onOptionRemove(option: SelectOption) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist a single-value label for the legacy `displayValue` slot path.
|
// Persist a single-value label for the legacy `displayValue` slot path.
|
||||||
watch([optionsSet, value], () => {
|
watch([optionsSet, model], () => {
|
||||||
const current = value.value;
|
const current = model.value;
|
||||||
if (current === undefined || Array.isArray(current)) return;
|
if (current === undefined || Array.isArray(current)) return;
|
||||||
const text = getOptionFrom(optionsSet.value, current)?.textContent;
|
const text = getOptionFrom(optionsSet.value, current)?.textContent;
|
||||||
if (text !== undefined) displayValue.value = text;
|
if (text !== undefined) displayValue.value = text;
|
||||||
@@ -152,21 +180,21 @@ watch([optionsSet, value], () => {
|
|||||||
|
|
||||||
function handleValueChange(newValue: AcceptableValue) {
|
function handleValueChange(newValue: AcceptableValue) {
|
||||||
if (multiple) {
|
if (multiple) {
|
||||||
const array = Array.isArray(value.value) ? [...value.value] : [];
|
const array = Array.isArray(model.value) ? [...model.value] : [];
|
||||||
const index = array.findIndex(v => compare(v as T, newValue as T, by as never));
|
const index = array.findIndex(v => compare(v as T, newValue as T, by as never));
|
||||||
if (index === -1) array.push(newValue as T);
|
if (index === -1) array.push(newValue as T);
|
||||||
else array.splice(index, 1);
|
else array.splice(index, 1);
|
||||||
value.value = [...array] as T[];
|
model.value = [...array] as T[];
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
value.value = newValue as T;
|
model.value = newValue as T;
|
||||||
displayValue.value = getOptionFrom(rawOptions, newValue)?.textContent;
|
displayValue.value = getOptionFrom(rawOptions, newValue)?.textContent;
|
||||||
open.value = false;
|
open.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSelectedValue(itemValue: AcceptableValue): boolean {
|
function isSelectedValue(itemValue: AcceptableValue): boolean {
|
||||||
const current = value.value;
|
const current = model.value;
|
||||||
if (current === undefined) return false;
|
if (current === undefined) return false;
|
||||||
if (Array.isArray(current)) {
|
if (Array.isArray(current)) {
|
||||||
for (const v of current) {
|
for (const v of current) {
|
||||||
@@ -197,7 +225,7 @@ const isFormControl = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
provideSelectRootContext({
|
provideSelectRootContext({
|
||||||
value,
|
value: model,
|
||||||
onValueChange: handleValueChange,
|
onValueChange: handleValueChange,
|
||||||
open,
|
open,
|
||||||
onOpenChange: (v) => { open.value = v; },
|
onOpenChange: (v) => { open.value = v; },
|
||||||
@@ -237,7 +265,7 @@ provideSelectRootContext({
|
|||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:multiple="multiple"
|
:multiple="multiple"
|
||||||
:options="nativeOptions"
|
:options="nativeOptions"
|
||||||
:value="value"
|
:value="model"
|
||||||
@change="handleValueChange"
|
@change="handleValueChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -245,7 +273,7 @@ provideSelectRootContext({
|
|||||||
v-else-if="name"
|
v-else-if="name"
|
||||||
type="hidden"
|
type="hidden"
|
||||||
:name="name"
|
:name="name"
|
||||||
:value="Array.isArray(value) ? '' : (value ?? '')"
|
:value="Array.isArray(model) ? '' : (model ?? '')"
|
||||||
:required="required"
|
:required="required"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:autocomplete="autocomplete"
|
:autocomplete="autocomplete"
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ export interface SelectViewportProps extends PrimitiveProps {
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, toRef, watchPostEffect } from 'vue';
|
import { ref, toRef, watchPostEffect } from 'vue';
|
||||||
|
|
||||||
import { useForwardExpose } from '@robonen/vue';
|
import { useForwardExpose, useStyleTag } from '@robonen/vue';
|
||||||
import { useNonce } from '../../utilities/config-provider';
|
import { useNonce } from '../../utilities/config-provider';
|
||||||
import { Primitive } from '../../internal/primitive';
|
import { Primitive } from '../../internal/primitive';
|
||||||
import { useSelectContentContext, useSelectItemAlignedPositionContext } from './context';
|
import { useSelectContentContext, useSelectItemAlignedPositionContext } from './context';
|
||||||
import { CONTENT_MARGIN } from './utils';
|
import { CONTENT_MARGIN, VIEWPORT_SCROLLBAR_CSS } from './utils';
|
||||||
|
|
||||||
const { as = 'div', nonce: propNonce } = defineProps<SelectViewportProps>();
|
const { as = 'div', nonce: propNonce } = defineProps<SelectViewportProps>();
|
||||||
|
|
||||||
@@ -32,6 +32,11 @@ const { forwardRef, currentElement } = useForwardExpose();
|
|||||||
const contentCtx = useSelectContentContext();
|
const contentCtx = useSelectContentContext();
|
||||||
const nonce = useNonce(toRef(() => propNonce));
|
const nonce = useNonce(toRef(() => propNonce));
|
||||||
|
|
||||||
|
// Injected into `<head>` (one reference-counted tag per document) rather than
|
||||||
|
// rendered as a sibling `<style>`: a second root node would turn this component
|
||||||
|
// into a fragment, and Vue cannot inherit a consumer's `class` onto a fragment.
|
||||||
|
useStyleTag(VIEWPORT_SCROLLBAR_CSS, { id: 'primitives-select-viewport', nonce: nonce.value });
|
||||||
|
|
||||||
const alignedCtx = contentCtx.position === 'item-aligned'
|
const alignedCtx = contentCtx.position === 'item-aligned'
|
||||||
? useSelectItemAlignedPositionContext(null as never)
|
? useSelectItemAlignedPositionContext(null as never)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -82,8 +87,4 @@ function handleScroll(event: Event) {
|
|||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</Primitive>
|
</Primitive>
|
||||||
<Primitive as="style" :nonce="nonce">
|
|
||||||
[data-primitives-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}
|
|
||||||
[data-primitives-select-viewport]::-webkit-scrollbar{display:none;}
|
|
||||||
</Primitive>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -385,3 +385,133 @@ describe('Select — native form submission', () => {
|
|||||||
w.unmount();
|
w.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Select — attribute forwarding on the panel', () => {
|
||||||
|
function mountStyled() {
|
||||||
|
return track(mount(
|
||||||
|
defineComponent({
|
||||||
|
setup() {
|
||||||
|
return () => h(
|
||||||
|
SelectRoot,
|
||||||
|
{ defaultOpen: true },
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
h(SelectTrigger, { id: 'styled-trigger', 'aria-label': 'Fruit' }, {
|
||||||
|
default: () => h(SelectValue, { placeholder: 'Pick one' }),
|
||||||
|
}),
|
||||||
|
h(SelectPortal, null, {
|
||||||
|
default: () => h(SelectContent, { class: 'panel', 'data-panel': 'yes' }, {
|
||||||
|
default: () => h(SelectViewport, { class: 'viewport' }, {
|
||||||
|
default: () => h(SelectItem, { value: 'apple' }, {
|
||||||
|
default: () => h(SelectItemText, null, { default: () => 'Apple' }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ attachTo: document.body },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('forwards class and data attributes from SelectContent to the panel element', async () => {
|
||||||
|
const w = mountStyled();
|
||||||
|
await flush();
|
||||||
|
const panel = document.querySelector('[data-primitives-select-content]') as HTMLElement | null;
|
||||||
|
expect(panel).toBeTruthy();
|
||||||
|
expect(panel!.classList.contains('panel')).toBe(true);
|
||||||
|
expect(panel!.getAttribute('data-panel')).toBe('yes');
|
||||||
|
w.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards class from SelectViewport to the viewport element', async () => {
|
||||||
|
const w = mountStyled();
|
||||||
|
await flush();
|
||||||
|
const viewport = document.querySelector('[data-primitives-select-viewport]') as HTMLElement | null;
|
||||||
|
expect(viewport).toBeTruthy();
|
||||||
|
expect(viewport!.classList.contains('viewport')).toBe(true);
|
||||||
|
w.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the trigger a single root that accepts native attributes', async () => {
|
||||||
|
const w = mountStyled();
|
||||||
|
await flush();
|
||||||
|
const trigger = getTrigger();
|
||||||
|
expect(trigger.id).toBe('styled-trigger');
|
||||||
|
expect(trigger.getAttribute('aria-label')).toBe('Fruit');
|
||||||
|
w.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('injects the scrollbar-hiding stylesheet into head instead of a sibling style node', async () => {
|
||||||
|
const w = mountStyled();
|
||||||
|
await flush();
|
||||||
|
const injected = document.head.querySelector('#primitives-select-viewport');
|
||||||
|
expect(injected).toBeTruthy();
|
||||||
|
expect(injected!.textContent).toContain('[data-primitives-select-viewport]');
|
||||||
|
const panel = document.querySelector('[data-primitives-select-content]') as HTMLElement;
|
||||||
|
expect(panel.querySelector('style')).toBeNull();
|
||||||
|
w.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Select — panel placement without a selection', () => {
|
||||||
|
function mountUnmatched(options: Opt[]) {
|
||||||
|
return track(mount(
|
||||||
|
defineComponent({
|
||||||
|
setup() {
|
||||||
|
// A model value that matches no option — a stale id, a deleted user,
|
||||||
|
// a directory that has not loaded yet.
|
||||||
|
return () => h(
|
||||||
|
SelectRoot,
|
||||||
|
{ defaultOpen: true, modelValue: 'gone' as never },
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
h(SelectTrigger, null, { default: () => h(SelectValue, { placeholder: 'Pick one' }) }),
|
||||||
|
h(SelectPortal, null, {
|
||||||
|
default: () => h(SelectContent, null, {
|
||||||
|
default: () => h(SelectViewport, null, {
|
||||||
|
default: () => options.map(opt =>
|
||||||
|
h(SelectItem, { key: String(opt.value), value: opt.value as never }, {
|
||||||
|
default: () => h(SelectItemText, null, { default: () => opt.label }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ attachTo: document.body },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('aligns on the first valid item when the model matches nothing', async () => {
|
||||||
|
const w = mountUnmatched([{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }]);
|
||||||
|
await flush();
|
||||||
|
const wrapper = document.querySelector('[data-primitives-select-content-wrapper]') as HTMLElement | null;
|
||||||
|
expect(wrapper).toBeTruthy();
|
||||||
|
// Item-aligned placement sets all three; bailing out leaves them empty and
|
||||||
|
// the fixed wrapper pinned to the viewport origin.
|
||||||
|
expect(wrapper!.style.minWidth).not.toBe('');
|
||||||
|
expect(wrapper!.style.height).not.toBe('');
|
||||||
|
expect(wrapper!.style.left || wrapper!.style.right).not.toBe('');
|
||||||
|
w.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places the panel instead of leaving it pinned to the viewport origin', async () => {
|
||||||
|
const w = mountUnmatched([]);
|
||||||
|
await flush();
|
||||||
|
const wrapper = document.querySelector('[data-primitives-select-content-wrapper]') as HTMLElement | null;
|
||||||
|
expect(wrapper).toBeTruthy();
|
||||||
|
// With no items at all there is nothing to align to; the fallback still has
|
||||||
|
// to give the wrapper explicit coordinates.
|
||||||
|
expect(wrapper!.style.top).not.toBe('');
|
||||||
|
expect(wrapper!.style.left).not.toBe('');
|
||||||
|
w.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,13 +4,6 @@ import type { AcceptableValue } from './utils';
|
|||||||
|
|
||||||
import { useContextFactory } from '@robonen/vue';
|
import { useContextFactory } from '@robonen/vue';
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Kept for backward compatibility. The select now accepts any
|
|
||||||
* {@link AcceptableValue} (string/number/boolean/object). `SelectValue` remains
|
|
||||||
* a string alias so existing `string`-typed consumers keep compiling.
|
|
||||||
*/
|
|
||||||
export type SelectValue = string;
|
|
||||||
|
|
||||||
export interface SelectOption {
|
export interface SelectOption {
|
||||||
value: AcceptableValue;
|
value: AcceptableValue;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export {
|
|||||||
} from './context';
|
} from './context';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
SelectValue,
|
|
||||||
SelectOption,
|
SelectOption,
|
||||||
SelectRootContext,
|
SelectRootContext,
|
||||||
SelectContentContext,
|
SelectContentContext,
|
||||||
@@ -38,7 +37,7 @@ export type {
|
|||||||
SelectItemContext,
|
SelectItemContext,
|
||||||
} from './context';
|
} from './context';
|
||||||
export type { AcceptableValue as SelectAcceptableValue } from './utils';
|
export type { AcceptableValue as SelectAcceptableValue } from './utils';
|
||||||
export type { SelectRootProps, SelectRootEmits } from './SelectRoot.vue';
|
export type { SelectModelValue, SelectRootProps, SelectRootEmits } from './SelectRoot.vue';
|
||||||
export type { SelectTriggerProps } from './SelectTrigger.vue';
|
export type { SelectTriggerProps } from './SelectTrigger.vue';
|
||||||
export type { SelectValueProps } from './SelectValue.vue';
|
export type { SelectValueProps } from './SelectValue.vue';
|
||||||
export type { SelectIconProps } from './SelectIcon.vue';
|
export type { SelectIconProps } from './SelectIcon.vue';
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export const OPEN_KEYS = [' ', 'Enter', 'ArrowUp', 'ArrowDown'];
|
|||||||
export const SELECTION_KEYS = [' ', 'Enter'];
|
export const SELECTION_KEYS = [' ', 'Enter'];
|
||||||
export const CONTENT_MARGIN = 10;
|
export const CONTENT_MARGIN = 10;
|
||||||
|
|
||||||
|
/** Hides the viewport's scrollbar across engines while keeping it scrollable. */
|
||||||
|
export const VIEWPORT_SCROLLBAR_CSS
|
||||||
|
= '[data-primitives-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}'
|
||||||
|
+ '[data-primitives-select-viewport]::-webkit-scrollbar{display:none;}';
|
||||||
|
|
||||||
export function getOpenState(open: boolean): 'open' | 'closed' {
|
export function getOpenState(open: boolean): 'open' | 'closed' {
|
||||||
return open ? 'open' : 'closed';
|
return open ? 'open' : 'closed';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ export interface RovingFocusGroupEmits {
|
|||||||
'update:currentTabStopId': [value: string | null | undefined];
|
'update:currentTabStopId': [value: string | null | undefined];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset `defineEmits` declares. `update:currentTabStopId` comes from
|
||||||
|
* `defineModel`; passing a model key through `defineEmits` as well erases its
|
||||||
|
* payload type from the generated declarations, leaving consumers with
|
||||||
|
* `unknown`.
|
||||||
|
*/
|
||||||
|
type RovingFocusGroupOwnEmits = Omit<RovingFocusGroupEmits, 'update:currentTabStopId'>;
|
||||||
|
|
||||||
export interface RovingFocusGroupContext {
|
export interface RovingFocusGroupContext {
|
||||||
orientation: Ref<Orientation | undefined>;
|
orientation: Ref<Orientation | undefined>;
|
||||||
dir: Ref<Direction>;
|
dir: Ref<Direction>;
|
||||||
@@ -77,7 +85,7 @@ const {
|
|||||||
as = 'div',
|
as = 'div',
|
||||||
} = defineProps<RovingFocusGroupProps>();
|
} = defineProps<RovingFocusGroupProps>();
|
||||||
|
|
||||||
const emit = defineEmits<RovingFocusGroupEmits>();
|
const emit = defineEmits<RovingFocusGroupOwnEmits>();
|
||||||
|
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
// `dir` falls back to the provider's configured direction when not given as prop.
|
// `dir` falls back to the provider's configured direction when not given as prop.
|
||||||
|
|||||||
@@ -19,12 +19,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@robonen/eslint": "workspace:*",
|
"@robonen/eslint": "workspace:*",
|
||||||
"@robonen/tsconfig": "workspace:*",
|
"@robonen/tsconfig": "workspace:*",
|
||||||
"@storybook/addon-a11y": "^10.4.6",
|
"@storybook/addon-a11y": "^10.5.5",
|
||||||
"@storybook/addon-docs": "^10.4.6",
|
"@storybook/addon-docs": "^10.5.5",
|
||||||
"@storybook/vue3-vite": "^10.4.6",
|
"@storybook/vue3-vite": "^10.5.5",
|
||||||
"@vitejs/plugin-vue": "^6.0.7",
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"storybook": "^10.4.6",
|
"storybook": "^10.5.5",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@robonen/vue",
|
"name": "@robonen/vue",
|
||||||
"version": "0.0.14",
|
"version": "0.2.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"description": "Collection of powerful tools for Vue",
|
"description": "Collection of powerful tools for Vue",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"url": "git+https://github.com/robonen/tools.git",
|
"url": "git+https://github.com/robonen/tools.git",
|
||||||
"directory": "vue/toolkit"
|
"directory": "vue/toolkit"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.7.0",
|
"packageManager": "pnpm@11.18.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.16.0"
|
"node": ">=24.16.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,45 +2,84 @@
|
|||||||
import { computed, ref, shallowRef } from 'vue';
|
import { computed, ref, shallowRef } from 'vue';
|
||||||
import { useVirtualList } from './index';
|
import { useVirtualList } from './index';
|
||||||
|
|
||||||
// 10,000 rows — only the visible window (plus overscan) is ever in the DOM.
|
interface Message {
|
||||||
const total = 10000;
|
id: number;
|
||||||
const items = shallowRef(
|
author: string;
|
||||||
Array.from({ length: total }, (_, i) => ({
|
text: string;
|
||||||
id: i,
|
expanded: boolean;
|
||||||
label: `Row #${(i + 1).toString().padStart(5, '0')}`,
|
}
|
||||||
hue: (i * 37) % 360,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
const itemHeight = 44;
|
const WORDS = 'virtual scrolling keeps the DOM small while the list pretends to be infinite and every row is free to size itself'.split(' ');
|
||||||
|
|
||||||
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(items, {
|
let nextId = 0;
|
||||||
itemHeight,
|
function makeMessage(): Message {
|
||||||
|
const id = nextId++;
|
||||||
|
const length = 4 + (id * 31) % 60; // deterministic variable length
|
||||||
|
const text = Array.from({ length }, (_, i) => WORDS[(id + i) % WORDS.length]).join(' ');
|
||||||
|
return { id, author: `user-${id % 7}`, text, expanded: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMessages(count: number): Message[] {
|
||||||
|
return Array.from({ length: count }, makeMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = shallowRef<Message[]>(makeMessages(10000));
|
||||||
|
|
||||||
|
const CHARS_PER_LINE = 58; // calibrated against the docs demo card width
|
||||||
|
|
||||||
|
const { list, containerProps, wrapperProps, scrollTo, isScrolling } = useVirtualList(messages, {
|
||||||
|
// Rows are genuinely variable (1–2 clamped lines collapsed, full text
|
||||||
|
// expanded), so the estimate is data-driven. It only has to be close, not
|
||||||
|
// exact — measured sizes replace it per row and are cached by key. What
|
||||||
|
// hurts is *systematic* error: a big overestimate makes every revealed row
|
||||||
|
// shrink on measure, and anchoring then fights the scroll.
|
||||||
|
estimateSize: message => 37 + Math.min(2, Math.ceil(message.text.length / CHARS_PER_LINE)) * 20,
|
||||||
|
getItemKey: message => message.id, // measurements survive prepend/reorder
|
||||||
|
followOutput: true, // pinned to the newest message when the user is at the end
|
||||||
overscan: 6,
|
overscan: 6,
|
||||||
|
gap: 6,
|
||||||
|
paddingStart: 8,
|
||||||
|
paddingEnd: 8,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function prepend() {
|
||||||
|
// Immutable update: getItemKey keeps measurements attached to the right
|
||||||
|
// messages and scroll anchoring keeps the viewport visually still.
|
||||||
|
messages.value = [...makeMessages(20), ...messages.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function append() {
|
||||||
|
// With followOutput the view stays glued to the end if the user is there.
|
||||||
|
messages.value = [...messages.value, ...makeMessages(5)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(message: Message) {
|
||||||
|
messages.value = messages.value.map(current =>
|
||||||
|
current === message ? { ...current, expanded: !current.expanded } : current,
|
||||||
|
);
|
||||||
|
// No manual remeasure: the row's ResizeObserver sees the new height
|
||||||
|
// before paint and the layout shifts without flicker.
|
||||||
|
}
|
||||||
|
|
||||||
const jumpTo = ref(5000);
|
const jumpTo = ref(5000);
|
||||||
|
|
||||||
function go() {
|
function go() {
|
||||||
const index = Math.min(Math.max(jumpTo.value || 0, 0), total - 1);
|
scrollTo(jumpTo.value || 0, { align: 'center' });
|
||||||
scrollTo(index, { behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleRange = computed(() => {
|
const visibleRange = computed(() => {
|
||||||
if (list.value.length === 0)
|
if (list.value.length === 0)
|
||||||
return '—';
|
return '—';
|
||||||
const first = list.value[0]!.index;
|
return `${list.value[0]!.index}–${list.value[list.value.length - 1]!.index}`;
|
||||||
const last = list.value[list.value.length - 1]!.index;
|
|
||||||
return `${first}–${last}`;
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="demo-stack max-w-sm">
|
<div class="demo-stack max-w-sm">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="demo-label">Virtual list</span>
|
<span class="demo-label">Dynamic virtual list</span>
|
||||||
<span class="demo-badge">
|
<span class="demo-badge">
|
||||||
{{ total.toLocaleString() }} rows
|
{{ messages.length.toLocaleString() }} rows
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -49,25 +88,32 @@ const visibleRange = computed(() => {
|
|||||||
class="demo-card h-64"
|
class="demo-card h-64"
|
||||||
>
|
>
|
||||||
<div v-bind="wrapperProps">
|
<div v-bind="wrapperProps">
|
||||||
<div
|
<article
|
||||||
v-for="{ data, index } in list"
|
v-for="item in list"
|
||||||
:key="index"
|
:key="item.key"
|
||||||
class="flex items-center gap-3 border-b border-border px-3"
|
v-bind="item.props"
|
||||||
:style="{ height: `${itemHeight}px` }"
|
class="cursor-pointer border-b border-border px-3 py-2"
|
||||||
|
@click="toggle(item.data)"
|
||||||
>
|
>
|
||||||
<span
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
class="size-6 shrink-0 rounded-md border border-border"
|
<span class="font-mono text-xs text-fg-subtle">{{ item.data.author }}</span>
|
||||||
:style="{ backgroundColor: `hsl(${data.hue} 65% 55%)` }"
|
<span class="text-xs text-fg-subtle tabular-nums">#{{ item.index }}</span>
|
||||||
/>
|
</div>
|
||||||
<span class="flex-1 truncate font-mono text-sm text-fg tabular-nums">{{ data.label }}</span>
|
<!-- natural height: collapsed rows clamp to two lines (still variable),
|
||||||
<span class="text-xs text-fg-subtle">idx {{ index }}</span>
|
expanded rows grow to the full text -->
|
||||||
</div>
|
<p
|
||||||
|
class="mt-1 text-sm text-fg"
|
||||||
|
:class="item.data.expanded ? '' : 'line-clamp-2'"
|
||||||
|
>
|
||||||
|
{{ item.data.text }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-lg border border-border bg-bg-inset p-3 font-mono text-sm text-fg tabular-nums flex items-center justify-between">
|
<div class="rounded-lg border border-border bg-bg-inset p-3 font-mono text-sm text-fg tabular-nums flex items-center justify-between">
|
||||||
<span class="text-fg-muted">rendered</span>
|
<span class="text-fg-muted">rendered</span>
|
||||||
<span>{{ list.length }} nodes · idx {{ visibleRange }}</span>
|
<span>{{ list.length }} nodes · idx {{ visibleRange }}<span v-if="isScrolling"> · scrolling</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-end gap-2">
|
<div class="flex items-end gap-2">
|
||||||
@@ -77,7 +123,7 @@ const visibleRange = computed(() => {
|
|||||||
v-model.number="jumpTo"
|
v-model.number="jumpTo"
|
||||||
type="number"
|
type="number"
|
||||||
:min="0"
|
:min="0"
|
||||||
:max="total - 1"
|
:max="messages.length - 1"
|
||||||
class="demo-input"
|
class="demo-input"
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
@@ -88,6 +134,20 @@ const visibleRange = computed(() => {
|
|||||||
>
|
>
|
||||||
Jump
|
Jump
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border bg-bg px-3 py-2 text-sm font-medium text-fg transition hover:bg-bg-inset active:scale-[0.98] cursor-pointer"
|
||||||
|
@click="prepend"
|
||||||
|
>
|
||||||
|
Prepend
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-border bg-bg px-3 py-2 text-sm font-medium text-fg transition hover:bg-bg-inset active:scale-[0.98] cursor-pointer"
|
||||||
|
@click="append"
|
||||||
|
>
|
||||||
|
Append
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,35 +1,54 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { effectScope, nextTick, ref } from 'vue';
|
import { effectScope, nextTick, shallowRef } from 'vue';
|
||||||
import { useVirtualList } from '.';
|
import { useVirtualList } from '.';
|
||||||
|
|
||||||
|
type ObserverRecord = InstanceType<typeof StubResizeObserver>;
|
||||||
|
|
||||||
|
const observers: ObserverRecord[] = [];
|
||||||
|
|
||||||
class StubResizeObserver {
|
class StubResizeObserver {
|
||||||
|
callback: ResizeObserverCallback;
|
||||||
observe = vi.fn();
|
observe = vi.fn();
|
||||||
disconnect = vi.fn();
|
|
||||||
unobserve = vi.fn();
|
unobserve = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
|
||||||
|
constructor(callback: ResizeObserverCallback) {
|
||||||
|
this.callback = callback;
|
||||||
|
observers.push(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeContainer(overrides: Partial<{
|
function makeContainer(overrides: Partial<{
|
||||||
clientWidth: number;
|
clientWidth: number;
|
||||||
clientHeight: number;
|
clientHeight: number;
|
||||||
scrollWidth: number;
|
|
||||||
scrollHeight: number;
|
|
||||||
}> = {}) {
|
}> = {}) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
Object.defineProperties(el, {
|
Object.defineProperties(el, {
|
||||||
clientWidth: { value: overrides.clientWidth ?? 100, configurable: true },
|
clientWidth: { value: overrides.clientWidth ?? 100, configurable: true },
|
||||||
clientHeight: { value: overrides.clientHeight ?? 100, configurable: true },
|
clientHeight: { value: overrides.clientHeight ?? 100, configurable: true },
|
||||||
scrollWidth: { value: overrides.scrollWidth ?? 10000, configurable: true },
|
|
||||||
scrollHeight: { value: overrides.scrollHeight ?? 10000, configurable: true },
|
|
||||||
});
|
});
|
||||||
el.scrollTop = 0;
|
el.scrollTop = 0;
|
||||||
el.scrollLeft = 0;
|
el.scrollLeft = 0;
|
||||||
el.scrollTo = vi.fn((opts: ScrollToOptions) => {
|
el.scrollTo = vi.fn((opts: ScrollToOptions) => {
|
||||||
if (typeof opts.top === 'number') el.scrollTop = opts.top;
|
if (typeof opts.top === 'number')
|
||||||
if (typeof opts.left === 'number') el.scrollLeft = opts.left;
|
el.scrollTop = opts.top;
|
||||||
|
if (typeof opts.left === 'number')
|
||||||
|
el.scrollLeft = opts.left;
|
||||||
}) as unknown as typeof el.scrollTo;
|
}) as unknown as typeof el.scrollTo;
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeRow(index: number): HTMLElement {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.dataset.index = String(index);
|
||||||
|
document.body.appendChild(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeEntry(target: Element, blockSize: number, inlineSize = 50): ResizeObserverEntry {
|
||||||
|
return { target, borderBoxSize: [{ blockSize, inlineSize }] } as unknown as ResizeObserverEntry;
|
||||||
|
}
|
||||||
|
|
||||||
function withScope<T>(fn: () => T): { result: T; scope: ReturnType<typeof effectScope> } {
|
function withScope<T>(fn: () => T): { result: T; scope: ReturnType<typeof effectScope> } {
|
||||||
const scope = effectScope();
|
const scope = effectScope();
|
||||||
let result!: T;
|
let result!: T;
|
||||||
@@ -41,181 +60,318 @@ function withScope<T>(fn: () => T): { result: T; scope: ReturnType<typeof effect
|
|||||||
|
|
||||||
describe(useVirtualList, () => {
|
describe(useVirtualList, () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
observers.length = 0;
|
||||||
vi.stubGlobal('ResizeObserver', StubResizeObserver);
|
vi.stubGlobal('ResizeObserver', StubResizeObserver);
|
||||||
});
|
});
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
it('renders an empty window before the container mounts (SSR-safe)', () => {
|
const items = Array.from({ length: 1000 }, (_, i) => ({ id: i }));
|
||||||
const data = Array.from({ length: 1000 }, (_, i) => i);
|
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20 }));
|
|
||||||
|
|
||||||
|
it('renders the initial window from estimates and initialContainerSize', () => {
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
|
estimateSize: 40,
|
||||||
|
initialContainerSize: 200,
|
||||||
|
overscan: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// top=0 → start 0; bottom=200 → lowerBound=5 (5*40 ≤ 200) → end 6 → +overscan
|
||||||
|
expect(result.range.value).toEqual({ start: 0, end: 7 });
|
||||||
|
expect(result.list.value).toHaveLength(7);
|
||||||
|
expect(result.list.value[0]!.start).toBe(0);
|
||||||
|
expect(result.list.value[3]!.start).toBe(120);
|
||||||
|
expect(result.totalSize.value).toBe(1000 * 40);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies paddingStart and gap to offsets and totalSize', () => {
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(() => items.slice(0, 10), {
|
||||||
|
estimateSize: 40,
|
||||||
|
gap: 8,
|
||||||
|
paddingStart: 12,
|
||||||
|
paddingEnd: 20,
|
||||||
|
initialContainerSize: 100,
|
||||||
|
overscan: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.list.value[0]!.start).toBe(12);
|
||||||
|
expect(result.list.value[1]!.start).toBe(12 + 40 + 8);
|
||||||
|
expect(result.totalSize.value).toBe(12 + 10 * 40 + 9 * 8 + 20);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes item and index to the estimate function', () => {
|
||||||
|
const estimateSize = vi.fn((item: { id: number }, _index: number) => 10 + item.id);
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(() => items.slice(0, 3), {
|
||||||
|
estimateSize,
|
||||||
|
initialContainerSize: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(estimateSize).toHaveBeenCalledWith(items[0], 0);
|
||||||
|
expect(result.totalSize.value).toBe(10 + 11 + 12);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebuilds when the source ref is replaced', async () => {
|
||||||
|
const source = shallowRef(items.slice(0, 10));
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(source, {
|
||||||
|
estimateSize: 40,
|
||||||
|
initialContainerSize: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.totalSize.value).toBe(400);
|
||||||
|
|
||||||
|
source.value = items.slice(0, 3);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(result.totalSize.value).toBe(120);
|
||||||
|
expect(result.range.value.end).toBeLessThanOrEqual(3);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the range when the source shrinks to empty', async () => {
|
||||||
|
const source = shallowRef(items.slice(0, 10));
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(source, {
|
||||||
|
estimateSize: 40,
|
||||||
|
initialContainerSize: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
source.value = [];
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(result.range.value).toEqual({ start: 0, end: 0 });
|
||||||
expect(result.list.value).toEqual([]);
|
expect(result.list.value).toEqual([]);
|
||||||
expect(result.containerProps.ref.value).toBeNull();
|
expect(result.totalSize.value).toBe(0);
|
||||||
expect(result.containerProps.style).toEqual({ overflowY: 'auto' });
|
|
||||||
scope.stop();
|
scope.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes the documented return shape', () => {
|
it('getOffsetForIndex honors align and clamps to content bounds', () => {
|
||||||
const { result, scope } = withScope(() => useVirtualList([1, 2, 3], { itemHeight: 20 }));
|
const { result, scope } = withScope(() => useVirtualList(() => items.slice(0, 100), {
|
||||||
|
estimateSize: 40,
|
||||||
|
initialContainerSize: 200,
|
||||||
|
}));
|
||||||
|
|
||||||
expect(result).toHaveProperty('list');
|
expect(result.getOffsetForIndex(0, 'start')).toBe(0);
|
||||||
expect(result).toHaveProperty('scrollTo');
|
expect(result.getOffsetForIndex(50, 'start')).toBe(2000);
|
||||||
expect(result).toHaveProperty('containerProps');
|
expect(result.getOffsetForIndex(50, 'center')).toBe(2000 - (200 - 40) / 2);
|
||||||
expect(result).toHaveProperty('wrapperProps');
|
expect(result.getOffsetForIndex(50, 'end')).toBe(2000 - 200 + 40);
|
||||||
expect(typeof result.scrollTo).toBe('function');
|
// clamp: the last item can't be aligned past max scroll
|
||||||
expect(typeof result.containerProps.onScroll).toBe('function');
|
expect(result.getOffsetForIndex(99, 'start')).toBe(100 * 40 - 200);
|
||||||
|
// 'auto' on a visible item → keep the current offset
|
||||||
|
expect(result.getOffsetForIndex(1, 'auto')).toBe(0);
|
||||||
scope.stop();
|
scope.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('slices the visible window plus overscan (vertical, fixed height)', async () => {
|
it('resolves auto-align with nearest-edge semantics for oversized items', () => {
|
||||||
const data = Array.from({ length: 1000 }, (_, i) => i);
|
// item 50 is taller than the 200px viewport and lies below it
|
||||||
|
const below = withScope(() => useVirtualList(() => items.slice(0, 100), {
|
||||||
|
estimateSize: (_item, index) => index === 50 ? 500 : 40,
|
||||||
|
initialContainerSize: 200,
|
||||||
|
}));
|
||||||
|
// nearest: approaching an oversized item from above aligns its start
|
||||||
|
expect(below.result.getOffsetForIndex(50, 'auto')).toBe(2000);
|
||||||
|
below.scope.stop();
|
||||||
|
|
||||||
|
// item 0 is taller than the viewport and already covers it → no-op
|
||||||
|
const covering = withScope(() => useVirtualList(() => items.slice(0, 100), {
|
||||||
|
estimateSize: (_item, index) => index === 0 ? 500 : 40,
|
||||||
|
initialContainerSize: 200,
|
||||||
|
}));
|
||||||
|
expect(covering.result.getOffsetForIndex(0, 'auto')).toBe(0);
|
||||||
|
covering.scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrollTo re-syncs a same-tick source replacement synchronously', () => {
|
||||||
|
const source = shallowRef(items.slice(0, 10));
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(source, {
|
||||||
|
estimateSize: 40,
|
||||||
|
initialContainerSize: 200,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.totalSize.value).toBe(400);
|
||||||
|
|
||||||
|
source.value = items.slice(0, 100);
|
||||||
|
// no nextTick: the canonical "append then scroll to newest" gesture
|
||||||
|
result.scrollTo(99);
|
||||||
|
|
||||||
|
expect(result.totalSize.value).toBe(4000);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrollTo and remeasure are safe no-ops without a scroll element', () => {
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(() => items.slice(0, 10), {
|
||||||
|
estimateSize: 40,
|
||||||
|
initialContainerSize: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
result.scrollTo(5);
|
||||||
|
result.scrollToOffset(100);
|
||||||
|
result.remeasure();
|
||||||
|
result.remeasure(2);
|
||||||
|
result.updateLayout();
|
||||||
|
}).not.toThrow();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('slices the window with correct data and indices once the container mounts', async () => {
|
||||||
const el = makeContainer({ clientHeight: 100 });
|
const el = makeContainer({ clientHeight: 100 });
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 2 }));
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
|
estimateSize: 20,
|
||||||
|
overscan: 2,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// pre-mount: a small fallback window instead of an empty flash
|
||||||
|
expect(result.list.value.length).toBeGreaterThan(0);
|
||||||
|
expect(result.list.value.length).toBeLessThanOrEqual(1 + 2);
|
||||||
|
expect(result.containerProps.style).toMatchObject({ overflowY: 'auto', overflowAnchor: 'none' });
|
||||||
|
|
||||||
result.containerProps.ref.value = el;
|
result.containerProps.ref.value = el;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
// offset(0) = 0, capacity = ceil(100/20) = 5, overscan 2 -> start 0, end 7.
|
// capacity = 100/20 = 5 → end 6, +overscan 2 → 8 rows
|
||||||
expect(result.list.value[0]).toEqual({ data: 0, index: 0 });
|
expect(result.list.value[0]).toMatchObject({ data: items[0], index: 0, start: 0, size: 20 });
|
||||||
expect(result.list.value).toHaveLength(7);
|
expect(result.list.value).toHaveLength(8);
|
||||||
expect(result.list.value.at(-1)).toEqual({ data: 6, index: 6 });
|
expect(result.list.value[0]!.props['data-index']).toBe(0);
|
||||||
|
expect(result.list.value[0]!.props.style.transform).toBe('translateY(0px)');
|
||||||
scope.stop();
|
scope.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recomputes the window on scroll with correct original indices', async () => {
|
it('recomputes the window on scroll with correct original indices', async () => {
|
||||||
const data = Array.from({ length: 1000 }, (_, i) => i);
|
|
||||||
const el = makeContainer({ clientHeight: 100 });
|
const el = makeContainer({ clientHeight: 100 });
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 2 }));
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
|
estimateSize: 20,
|
||||||
|
overscan: 2,
|
||||||
|
}));
|
||||||
|
|
||||||
result.containerProps.ref.value = el;
|
result.containerProps.ref.value = el;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
el.scrollTop = 400; // offset = floor(400/20) = 20
|
el.scrollTop = 400; // first visible = 400/20 = 20
|
||||||
el.dispatchEvent(new Event('scroll'));
|
el.dispatchEvent(new Event('scroll'));
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
// start = 20 - 2 = 18, end = 20 + 5 + 2 = 27
|
expect(result.list.value[0]!.index).toBe(18); // 20 - overscan
|
||||||
expect(result.list.value[0]).toEqual({ data: 18, index: 18 });
|
expect(result.isScrolling.value).toBeTruthy();
|
||||||
expect(result.list.value.at(-1)).toEqual({ data: 26, index: 26 });
|
|
||||||
scope.stop();
|
scope.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('computes total height and offset spacers via wrapperProps', async () => {
|
it('scrollTo writes the scroll offset for the requested alignment', async () => {
|
||||||
const data = Array.from({ length: 50 }, (_, i) => i);
|
|
||||||
const el = makeContainer({ clientHeight: 100 });
|
const el = makeContainer({ clientHeight: 100 });
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 0 }));
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
|
estimateSize: 20,
|
||||||
|
overscan: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
result.containerProps.ref.value = el;
|
result.containerProps.ref.value = el;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
// total height = 50 * 20 = 1000; at top, marginTop = 0
|
result.scrollTo(30, { align: 'start' });
|
||||||
expect(result.wrapperProps.value.style.height).toBe('1000px');
|
|
||||||
expect(result.wrapperProps.value.style.marginTop).toBe('0px');
|
|
||||||
expect(result.wrapperProps.value.style.width).toBe('100%');
|
|
||||||
|
|
||||||
el.scrollTop = 200; // offset = 10, start = 10
|
|
||||||
el.dispatchEvent(new Event('scroll'));
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
// marginTop = distance(10) = 200px; remaining height = 1000 - 200 = 800px
|
|
||||||
expect(result.wrapperProps.value.style.marginTop).toBe('200px');
|
|
||||||
expect(result.wrapperProps.value.style.height).toBe('800px');
|
|
||||||
scope.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports horizontal layout with itemWidth', async () => {
|
|
||||||
const data = Array.from({ length: 1000 }, (_, i) => i);
|
|
||||||
const el = makeContainer({ clientWidth: 100 });
|
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemWidth: 25, overscan: 1 }));
|
|
||||||
|
|
||||||
expect(result.containerProps.style).toEqual({ overflowX: 'auto' });
|
|
||||||
|
|
||||||
result.containerProps.ref.value = el;
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
// capacity = ceil(100/25) = 4, overscan 1 -> start 0, end 5
|
|
||||||
expect(result.list.value).toHaveLength(5);
|
|
||||||
expect(result.wrapperProps.value.style.display).toBe('flex');
|
|
||||||
expect(result.wrapperProps.value.style.height).toBe('100%');
|
|
||||||
expect(result.wrapperProps.value.style.marginLeft).toBe('0px');
|
|
||||||
scope.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports variable item heights via a getter (prefix-sum metrics)', async () => {
|
|
||||||
const data = Array.from({ length: 100 }, (_, i) => i);
|
|
||||||
const el = makeContainer({ clientHeight: 100 });
|
|
||||||
// even indices: 40px, odd: 10px
|
|
||||||
const itemHeight = (i: number) => (i % 2 === 0 ? 40 : 10);
|
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight, overscan: 0 }));
|
|
||||||
|
|
||||||
result.containerProps.ref.value = el;
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
expect(result.list.value[0]).toEqual({ data: 0, index: 0 });
|
|
||||||
|
|
||||||
// total = 50 * 40 + 50 * 10 = 2500
|
|
||||||
expect(result.wrapperProps.value.style.height).toBe('2500px');
|
|
||||||
|
|
||||||
// distance to index 4 = sizes[0..3] = 40+10+40+10 = 100
|
|
||||||
el.scrollTop = 100;
|
|
||||||
el.dispatchEvent(new Event('scroll'));
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
expect(result.list.value[0]).toEqual({ data: 4, index: 4 });
|
|
||||||
expect(result.wrapperProps.value.style.marginTop).toBe('100px');
|
|
||||||
scope.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('scrollTo moves the container and re-slices', async () => {
|
|
||||||
const data = Array.from({ length: 1000 }, (_, i) => i);
|
|
||||||
const el = makeContainer({ clientHeight: 100 });
|
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 0 }));
|
|
||||||
|
|
||||||
result.containerProps.ref.value = el;
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
result.scrollTo(30);
|
|
||||||
// distance(30) = 30 * 20 = 600; block 'start' keeps offset 0
|
|
||||||
expect(el.scrollTop).toBe(600);
|
expect(el.scrollTop).toBe(600);
|
||||||
expect(result.list.value[0]).toEqual({ data: 30, index: 30 });
|
|
||||||
|
result.scrollTo(30, { align: 'center' });
|
||||||
|
expect(el.scrollTop).toBe(600 - (100 - 20) / 2);
|
||||||
scope.stop();
|
scope.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('scrollTo is a no-op when the container is not mounted', () => {
|
it('applies measured sizes delivered by the ResizeObserver', async () => {
|
||||||
const { result, scope } = withScope(() => useVirtualList([1, 2, 3], { itemHeight: 20 }));
|
|
||||||
expect(() => result.scrollTo(2)).not.toThrow();
|
|
||||||
scope.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reacts to a changing source ref', async () => {
|
|
||||||
const data = ref(Array.from({ length: 10 }, (_, i) => i));
|
|
||||||
const el = makeContainer({ clientHeight: 100 });
|
const el = makeContainer({ clientHeight: 100 });
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 0 }));
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
|
estimateSize: 20,
|
||||||
|
overscan: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
result.containerProps.ref.value = el;
|
result.containerProps.ref.value = el;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
// total = 10 * 20 = 200
|
const observer = observers[0]!;
|
||||||
expect(result.wrapperProps.value.style.height).toBe('200px');
|
const row = makeRow(0);
|
||||||
|
result.measureElement(row);
|
||||||
|
expect(observer.observe).toHaveBeenCalledWith(row, { box: 'border-box' });
|
||||||
|
|
||||||
data.value = Array.from({ length: 100 }, (_, i) => i);
|
observer.callback([resizeEntry(row, 90)], observer as unknown as ResizeObserver);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
// total = 100 * 20 = 2000
|
// row 0: 20 → 90, total grows by 70
|
||||||
expect(result.wrapperProps.value.style.height).toBe('2000px');
|
expect(result.totalSize.value).toBe(1000 * 20 + 70);
|
||||||
|
expect(result.list.value[0]!.size).toBe(90);
|
||||||
|
expect(result.list.value[1]!.start).toBe(90);
|
||||||
scope.stop();
|
scope.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clamps the window to the source bounds', async () => {
|
it('compensates the scroll offset when an item above the viewport grows', async () => {
|
||||||
const data = Array.from({ length: 3 }, (_, i) => i);
|
const el = makeContainer({ clientHeight: 100 });
|
||||||
const el = makeContainer({ clientHeight: 1000 });
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
const { result, scope } = withScope(() => useVirtualList(data, { itemHeight: 20, overscan: 5 }));
|
estimateSize: 20,
|
||||||
|
overscan: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
result.containerProps.ref.value = el;
|
result.containerProps.ref.value = el;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect(result.list.value).toHaveLength(3);
|
el.scrollTop = 400;
|
||||||
expect(result.list.value.at(-1)).toEqual({ data: 2, index: 2 });
|
el.dispatchEvent(new Event('scroll'));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const observer = observers[0]!;
|
||||||
|
const row = makeRow(0); // starts at 0, above the viewport top (400)
|
||||||
|
result.measureElement(row);
|
||||||
|
observer.callback([resizeEntry(row, 100)], observer as unknown as ResizeObserver);
|
||||||
|
await nextTick(); // deferred compensation write lands post-patch
|
||||||
|
|
||||||
|
// growth of 80 above the viewport → scrollTop compensated to 480
|
||||||
|
expect(el.scrollTop).toBe(480);
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures component instances through $el', () => {
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
|
estimateSize: 20,
|
||||||
|
initialContainerSize: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const row = makeRow(3);
|
||||||
|
result.measureElement({ $el: row });
|
||||||
|
|
||||||
|
expect(observers[0]!.observe).toHaveBeenCalledWith(row, { box: 'border-box' });
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns once when the ref target cannot be measured', () => {
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
|
estimateSize: 20,
|
||||||
|
initialContainerSize: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
result.measureElement({ $el: document.createTextNode('fragment anchor') });
|
||||||
|
result.measureElement({ $el: null });
|
||||||
|
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
warn.mockRestore();
|
||||||
|
scope.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports horizontal layout', async () => {
|
||||||
|
const el = makeContainer({ clientWidth: 100 });
|
||||||
|
const { result, scope } = withScope(() => useVirtualList(() => items, {
|
||||||
|
estimateSize: 25,
|
||||||
|
axis: 'x',
|
||||||
|
overscan: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result.containerProps.style).toMatchObject({ overflowX: 'auto' });
|
||||||
|
|
||||||
|
result.containerProps.ref.value = el;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// capacity = 100/25 = 4 → end 5, +overscan 1 → 6 rows
|
||||||
|
expect(result.list.value).toHaveLength(6);
|
||||||
|
expect(result.list.value[1]!.props.style.transform).toBe('translateX(25px)');
|
||||||
|
expect(result.wrapperProps.value.style.width).toBe(`${1000 * 25}px`);
|
||||||
|
expect(result.wrapperProps.value.style.height).toBe('100%');
|
||||||
scope.stop();
|
scope.stop();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { computed } from 'vue';
|
|||||||
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
|
||||||
import { unrefElement } from '@/composables/component/unrefElement';
|
import { unrefElement } from '@/composables/component/unrefElement';
|
||||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
|
||||||
const DEFAULT_DELAY = 500;
|
const DEFAULT_DELAY = 500;
|
||||||
const DEFAULT_THRESHOLD = 10;
|
const DEFAULT_THRESHOLD = 10;
|
||||||
@@ -220,6 +221,11 @@ export function onLongPress(
|
|||||||
useEventListener(elementRef, ['pointerup', 'pointerleave'], onRelease, listenerOptions),
|
useEventListener(elementRef, ['pointerup', 'pointerleave'], onRelease, listenerOptions),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// The listeners above self-dispose with the scope, but a delay timer armed
|
||||||
|
// by a press that never released would outlive the component and fire the
|
||||||
|
// handler against a dead scope.
|
||||||
|
tryOnScopeDispose(clear);
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
clear();
|
clear();
|
||||||
cleanups.forEach(stop => stop());
|
cleanups.forEach(stop => stop());
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { createApp, inject, onUnmounted, reactive, ref } from 'vue';
|
||||||
|
import type { InjectionKey } from 'vue';
|
||||||
|
import { runWithApp } from './index';
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsKey: InjectionKey<Settings> = Symbol('DemoSettings');
|
||||||
|
|
||||||
|
// Imagine this is your main.ts: the app provides DI values and is registered
|
||||||
|
// once with `app.use(activeAppPlugin)`. The demo keeps a standalone app and
|
||||||
|
// passes it explicitly so it does not touch the docs application.
|
||||||
|
const app = createApp({ render: () => null });
|
||||||
|
const settings = reactive<Settings>({ volume: 50 });
|
||||||
|
app.provide(SettingsKey, settings);
|
||||||
|
|
||||||
|
onUnmounted(() => app.unmount());
|
||||||
|
|
||||||
|
// A plain module-level function — no setup, no injection context. With
|
||||||
|
// `runWithApp` it can still resolve `inject()` against the app.
|
||||||
|
function readVolumeFromOutside() {
|
||||||
|
return runWithApp(() => inject(SettingsKey)!.volume, app);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = ref<number>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="demo-stack max-w-sm">
|
||||||
|
<div class="demo-card p-4 flex flex-col gap-3">
|
||||||
|
<span class="demo-label">App-provided state</span>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-3 text-sm text-fg">
|
||||||
|
<span class="text-xs text-fg-muted w-14">Volume</span>
|
||||||
|
<input
|
||||||
|
v-model.number="settings.volume"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
class="flex-1 accent-accent cursor-pointer"
|
||||||
|
>
|
||||||
|
<span class="font-mono text-xs tabular-nums text-fg-muted w-8 text-right">{{ settings.volume }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-card p-4 flex flex-col gap-3">
|
||||||
|
<span class="demo-label">Plain function, outside any component</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex 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] cursor-pointer"
|
||||||
|
@click="snapshot = readVolumeFromOutside()"
|
||||||
|
>
|
||||||
|
runWithApp(() => inject(SettingsKey))
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="font-mono text-xs tabular-nums text-fg-muted">
|
||||||
|
{{ snapshot === undefined ? 'not read yet' : `injected volume: ${snapshot}` }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-fg-subtle">
|
||||||
|
The function reading the value has no injection context of its own —
|
||||||
|
<span class="font-mono text-fg-muted">runWithApp</span> wraps it in
|
||||||
|
<span class="font-mono text-fg-muted">app.runWithContext</span> so
|
||||||
|
<span class="font-mono text-fg-muted">inject()</span> resolves app-level provides.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createApp, defineComponent, h, inject, provide } from 'vue';
|
||||||
|
import type { App, InjectionKey } from 'vue';
|
||||||
|
import { activeAppPlugin, getActiveApp, injectWithApp, runWithApp, setActiveApp } from '.';
|
||||||
|
import { VueToolsError } from '@/utils';
|
||||||
|
|
||||||
|
const key: InjectionKey<string> = Symbol('TestKey');
|
||||||
|
|
||||||
|
function makeApp(setup?: () => void) {
|
||||||
|
return createApp(defineComponent({
|
||||||
|
setup() {
|
||||||
|
setup?.();
|
||||||
|
return () => h('div');
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountApp(app: App) {
|
||||||
|
app.mount(document.createElement('div'));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActiveApp(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(setActiveApp, () => {
|
||||||
|
it('registers the app and returns it for chaining', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
|
||||||
|
expect(getActiveApp()).toBeUndefined();
|
||||||
|
expect(setActiveApp(app)).toBe(app);
|
||||||
|
expect(getActiveApp()).toBe(app);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the registration with undefined', () => {
|
||||||
|
setActiveApp(makeApp());
|
||||||
|
setActiveApp(undefined);
|
||||||
|
|
||||||
|
expect(getActiveApp()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(getActiveApp, () => {
|
||||||
|
it('prefers the current instance app over the registered one', () => {
|
||||||
|
const other = makeApp();
|
||||||
|
setActiveApp(other);
|
||||||
|
|
||||||
|
let captured: App | undefined;
|
||||||
|
const app = mountApp(makeApp(() => {
|
||||||
|
captured = getActiveApp();
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(captured).toBe(app);
|
||||||
|
expect(captured).not.toBe(other);
|
||||||
|
|
||||||
|
app.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(runWithApp, () => {
|
||||||
|
it('resolves app-level provides through the active app', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app.provide(key, 'from app');
|
||||||
|
setActiveApp(app);
|
||||||
|
|
||||||
|
expect(runWithApp(() => inject(key))).toBe('from app');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses an explicitly passed app over the active one', () => {
|
||||||
|
const active = makeApp();
|
||||||
|
active.provide(key, 'active');
|
||||||
|
setActiveApp(active);
|
||||||
|
|
||||||
|
const explicit = makeApp();
|
||||||
|
explicit.provide(key, 'explicit');
|
||||||
|
|
||||||
|
expect(runWithApp(() => inject(key), explicit)).toBe('explicit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the function result', () => {
|
||||||
|
setActiveApp(makeApp());
|
||||||
|
|
||||||
|
expect(runWithApp(() => 42)).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when no app is available', () => {
|
||||||
|
expect(() => runWithApp(() => inject(key))).toThrow(VueToolsError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(injectWithApp, () => {
|
||||||
|
it('resolves app-level provides outside of setup', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app.provide(key, 'from app');
|
||||||
|
setActiveApp(app);
|
||||||
|
|
||||||
|
expect(injectWithApp(key)).toBe('from app');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behaves like inject inside setup, component provides win', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app.provide(key, 'app level');
|
||||||
|
setActiveApp(app);
|
||||||
|
|
||||||
|
let fromParent: string | undefined;
|
||||||
|
const Child = defineComponent({
|
||||||
|
setup() {
|
||||||
|
fromParent = injectWithApp(key);
|
||||||
|
return () => h('div');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const host = createApp(defineComponent({
|
||||||
|
setup() {
|
||||||
|
provide(key, 'component level');
|
||||||
|
return () => h(Child);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
host.provide(key, 'host app level');
|
||||||
|
mountApp(host);
|
||||||
|
|
||||||
|
expect(fromParent).toBe('component level');
|
||||||
|
|
||||||
|
host.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default value when the key is not provided', () => {
|
||||||
|
setActiveApp(makeApp());
|
||||||
|
|
||||||
|
expect(injectWithApp(key, 'fallback')).toBe('fallback');
|
||||||
|
expect(injectWithApp(key, () => 'factory', true)).toBe('factory');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the default value when no app is available at all', () => {
|
||||||
|
expect(injectWithApp(key, 'fallback')).toBe('fallback');
|
||||||
|
expect(injectWithApp(key, () => 'factory', true)).toBe('factory');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call a function default unless treated as factory', () => {
|
||||||
|
const fn = vi.fn(() => 'value');
|
||||||
|
const injected = injectWithApp<() => string>(Symbol('FnKey'), fn);
|
||||||
|
|
||||||
|
expect(injected).toBe(fn);
|
||||||
|
expect(fn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when there is no context, no app and no default', () => {
|
||||||
|
expect(() => injectWithApp(key)).toThrow(VueToolsError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(activeAppPlugin, () => {
|
||||||
|
it('registers the app on install', () => {
|
||||||
|
const app = makeApp().use(activeAppPlugin);
|
||||||
|
|
||||||
|
expect(getActiveApp()).toBe(app);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the registration when the app unmounts', () => {
|
||||||
|
const app = mountApp(makeApp().use(activeAppPlugin));
|
||||||
|
|
||||||
|
app.unmount();
|
||||||
|
|
||||||
|
expect(getActiveApp()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the registration when a stale app unmounts', () => {
|
||||||
|
const first = mountApp(makeApp().use(activeAppPlugin));
|
||||||
|
const second = makeApp().use(activeAppPlugin);
|
||||||
|
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
expect(getActiveApp()).toBe(second);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { getCurrentInstance, hasInjectionContext, inject } from 'vue';
|
||||||
|
import type { App, InjectionKey, Plugin } from 'vue';
|
||||||
|
import { VueToolsError } from '@/utils';
|
||||||
|
|
||||||
|
type InjectDefaults<Value> = [defaultValue?: Value | (() => Value), treatDefaultAsFactory?: boolean];
|
||||||
|
|
||||||
|
let activeApp: App | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name setActiveApp
|
||||||
|
* @category State
|
||||||
|
* @description Registers the Vue app instance used by `getActiveApp`, `runWithApp` and `injectWithApp`
|
||||||
|
* outside of component context. Pass `undefined` to clear the registration.
|
||||||
|
*
|
||||||
|
* The registration is module-global (one slot per JS realm). On the client this is exactly
|
||||||
|
* what you want; on the server create one app per request and prefer passing the app
|
||||||
|
* explicitly to `runWithApp` instead of relying on the global slot, otherwise concurrent
|
||||||
|
* requests may observe each other's app.
|
||||||
|
*
|
||||||
|
* @param {App | undefined} app The app to register, or `undefined` to clear
|
||||||
|
* @returns {App | undefined} The same app, for chaining
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // main.ts
|
||||||
|
* const app = createApp(App);
|
||||||
|
* setActiveApp(app);
|
||||||
|
*
|
||||||
|
* @since 0.1.0
|
||||||
|
*/
|
||||||
|
export function setActiveApp(app: App | undefined) {
|
||||||
|
activeApp = app;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name getActiveApp
|
||||||
|
* @category State
|
||||||
|
* @description Returns the closest Vue app instance: the current component's app when called
|
||||||
|
* during setup (or anywhere `getCurrentInstance` works), otherwise the app registered via
|
||||||
|
* `setActiveApp` / `activeAppPlugin`.
|
||||||
|
*
|
||||||
|
* @returns {App | undefined} The resolved app, or `undefined` when none is available
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const app = getActiveApp();
|
||||||
|
* app?.config.globalProperties;
|
||||||
|
*
|
||||||
|
* @since 0.1.0
|
||||||
|
*/
|
||||||
|
export function getActiveApp(): App | undefined {
|
||||||
|
return getCurrentInstance()?.appContext.app ?? activeApp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name runWithApp
|
||||||
|
* @category State
|
||||||
|
* @description Runs a function inside `app.runWithContext`, so `inject` (and everything built
|
||||||
|
* on it) resolves app-level provides even outside of component setup — in router guards,
|
||||||
|
* store actions, event handlers or timers.
|
||||||
|
*
|
||||||
|
* The app defaults to `getActiveApp()`; pass one explicitly to target a specific app
|
||||||
|
* (recommended for SSR, where apps are created per request).
|
||||||
|
*
|
||||||
|
* @param {Function} fn The function to run with the app as injection context
|
||||||
|
* @param {App} [app] The app to use instead of the active one
|
||||||
|
* @returns The return value of `fn`
|
||||||
|
* @throws {VueToolsError} when no app is registered and none is passed
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* router.beforeEach(() => {
|
||||||
|
* const auth = runWithApp(() => inject(AuthKey));
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* @since 0.1.0
|
||||||
|
*/
|
||||||
|
export function runWithApp<Result>(fn: () => Result, app: App | undefined = getActiveApp()): Result {
|
||||||
|
if (!app)
|
||||||
|
throw new VueToolsError('runWithApp: no active Vue app, install activeAppPlugin or call setActiveApp first');
|
||||||
|
|
||||||
|
return app.runWithContext(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name injectWithApp
|
||||||
|
* @category State
|
||||||
|
* @description Drop-in replacement for `inject` that also works outside of component setup.
|
||||||
|
* Inside an injection context it behaves exactly like `inject` (component-level provides
|
||||||
|
* win); outside it resolves app-level provides through the active app. When no app is
|
||||||
|
* available it falls back to the provided default value, or throws if there is none.
|
||||||
|
*
|
||||||
|
* @param {InjectionKey | string} key The injection key
|
||||||
|
* @param {any} [defaultValue] The value (or factory) to fall back to when the key is not provided
|
||||||
|
* @param {boolean} [treatDefaultAsFactory] Call `defaultValue` as a factory, like `inject`
|
||||||
|
* @returns The injected value
|
||||||
|
* @throws {VueToolsError} when called with no injection context, no active app and no default value
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const theme = injectWithApp(ThemeKey, 'light');
|
||||||
|
*
|
||||||
|
* @since 0.1.0
|
||||||
|
*/
|
||||||
|
export function injectWithApp<Value>(key: InjectionKey<Value> | string): Value | undefined;
|
||||||
|
export function injectWithApp<Value>(key: InjectionKey<Value> | string, defaultValue: Value, treatDefaultAsFactory?: false): Value;
|
||||||
|
export function injectWithApp<Value>(key: InjectionKey<Value> | string, defaultValue: Value | (() => Value), treatDefaultAsFactory: true): Value;
|
||||||
|
export function injectWithApp<Value>(key: InjectionKey<Value> | string, ...defaults: InjectDefaults<Value>): Value | undefined {
|
||||||
|
// spread `defaults` as-is: `inject` distinguishes a missing default from an
|
||||||
|
// explicit `undefined` one via `arguments.length`
|
||||||
|
const doInject = () => (inject as (...args: [typeof key, ...InjectDefaults<Value>]) => Value | undefined)(key, ...defaults);
|
||||||
|
|
||||||
|
if (hasInjectionContext())
|
||||||
|
return doInject();
|
||||||
|
|
||||||
|
const app = getActiveApp();
|
||||||
|
|
||||||
|
if (app)
|
||||||
|
return app.runWithContext(doInject);
|
||||||
|
|
||||||
|
if (defaults.length > 0) {
|
||||||
|
const [defaultValue, treatDefaultAsFactory] = defaults;
|
||||||
|
|
||||||
|
return treatDefaultAsFactory && typeof defaultValue === 'function'
|
||||||
|
? (defaultValue as () => Value)()
|
||||||
|
: defaultValue as Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new VueToolsError('injectWithApp: no injection context and no active Vue app, install activeAppPlugin or call setActiveApp first');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name activeAppPlugin
|
||||||
|
* @category State
|
||||||
|
* @description Vue plugin that registers the app as the active one and clears the
|
||||||
|
* registration when the app unmounts (unless another app took over in the meantime).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // main.ts
|
||||||
|
* createApp(App).use(activeAppPlugin).mount('#app');
|
||||||
|
*
|
||||||
|
* @since 0.1.0
|
||||||
|
*/
|
||||||
|
export const activeAppPlugin: Plugin = {
|
||||||
|
install(app) {
|
||||||
|
setActiveApp(app);
|
||||||
|
|
||||||
|
app.onUnmount(() => {
|
||||||
|
if (activeApp === app)
|
||||||
|
setActiveApp(undefined);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user