1 Commits

Author SHA1 Message Date
Renovate Bot 4d25592cad chore(deps): update dependency @floating-ui/vue to v2
renovate/artifacts Artifact file update failure
CI / Discover packages (pull_request) Failing after 2m33s
CI / ${{ matrix.package }} (pull_request) Has been skipped
CI / CI (pull_request) Failing after 5s
2026-06-15 21:53:43 +00:00
266 changed files with 6421 additions and 9790 deletions
+40 -33
View File
@@ -13,47 +13,54 @@ env:
NODE_VERSION: 24.x NODE_VERSION: 24.x
jobs: jobs:
# One job per package — build (with its workspace deps), lint and test run in # Enumerate the workspace packages so the matrix below fans out one job per
# parallel across packages. fail-fast: false so every package is reported. # package (kept dynamic so new packages are picked up automatically).
# discover:
# The list is static: Gitea's act_runner does not expand a dynamic matrix name: Discover packages
# built from a previous job's outputs (the strategy is evaluated before the
# producing job runs), so `matrix.package` came out empty. When you add a
# workspace package, add a line here.
check:
name: ${{ matrix.package }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: outputs:
contents: read packages: ${{ steps.list.outputs.packages }}
strategy:
fail-fast: false
matrix:
package:
- "@robonen/eslint"
- "@robonen/tsconfig"
- "@robonen/tsdown"
- "@robonen/crdt"
- "@robonen/encoding"
- "@robonen/fetch"
- "@robonen/platform"
- "@robonen/stdlib"
- "@robonen/docs"
- "@robonen/renovate"
- "@robonen/primitives"
- "@robonen/primitives-playground"
- "@robonen/stories"
- "@robonen/vue"
- "@robonen/writekit"
- "@robonen/writekit-playground"
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v6 uses: pnpm/action-setup@v6
with: with:
run_install: false run_install: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: List workspace packages
id: list
run: echo "packages=$(pnpm -r ls --depth -1 --json | jq -c '[.[] | select(.name != "tools") | .name]')" >> "$GITHUB_OUTPUT"
# One job per package — build (with its workspace deps), lint and test run in
# parallel across packages. fail-fast: false so every package is reported.
check:
name: ${{ matrix.package }}
needs: discover
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
package: ${{ fromJSON(needs.discover.outputs.packages) }}
steps:
- uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- uses: actions/setup-node@v6
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: pnpm cache: pnpm
+5 -43
View File
@@ -5,12 +5,6 @@ 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
@@ -19,7 +13,7 @@ jobs:
name: Check version changes and publish name: Check version changes and publish
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -28,41 +22,11 @@ jobs:
with: with:
run_install: false run_install: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: pnpm cache: pnpm
# No `registry-url:` on purpose. It writes an npmrc holding the literal registry-url: 'https://registry.npmjs.org'
# 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
@@ -74,6 +38,8 @@ 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/*")
@@ -113,7 +79,3 @@ 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"
+10 -11
View File
@@ -17,12 +17,11 @@
"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.18.0", "packageManager": "pnpm@10.34.1",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
@@ -48,15 +47,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.24", "@vitest/eslint-plugin": "^1.6.19",
"eslint-plugin-import-x": "^4.17.1", "eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-n": "^18.2.2", "eslint-plugin-n": "^18.1.0",
"eslint-plugin-regexp": "^3.1.1", "eslint-plugin-regexp": "^3.1.0",
"eslint-plugin-unicorn": "^72.0.0", "eslint-plugin-unicorn": "^65.0.1",
"eslint-plugin-vue": "^10.10.0", "eslint-plugin-vue": "^10.9.2",
"globals": "^17.8.0", "globals": "^17.6.0",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"typescript-eslint": "^8.65.0", "typescript-eslint": "^8.61.0",
"vue-eslint-parser": "^10.4.1" "vue-eslint-parser": "^10.4.1"
}, },
"devDependencies": { "devDependencies": {
@@ -67,7 +66,7 @@
"tsdown": "catalog:" "tsdown": "catalog:"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": ">=10.8.1" "eslint": ">=9.39.4"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
+1 -1
View File
@@ -15,7 +15,7 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "packages/tsconfig" "directory": "packages/tsconfig"
}, },
"packageManager": "pnpm@11.18.0", "packageManager": "pnpm@10.34.1",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
+1 -1
View File
@@ -15,7 +15,7 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "configs/tsdown" "directory": "configs/tsdown"
}, },
"packageManager": "pnpm@11.18.0", "packageManager": "pnpm@10.34.1",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
+1 -2
View File
@@ -17,12 +17,11 @@
"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.18.0", "packageManager": "pnpm@10.34.1",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
+1 -2
View File
@@ -13,12 +13,11 @@
"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.18.0", "packageManager": "pnpm@10.34.1",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
+1 -1
View File
@@ -15,7 +15,7 @@ const ASCII_ZERO = 0x30;
* luhn('4111 1111 1111 1111'); // true * luhn('4111 1111 1111 1111'); // true
* luhn('4111 1111 1111 1112'); // false * luhn('4111 1111 1111 1112'); // false
* *
* @since 0.0.1 * @since 0.0.2
*/ */
export function luhn(value: string): boolean { export function luhn(value: string): boolean {
const digits = value.replaceAll(NON_DIGIT, ''); const digits = value.replaceAll(NON_DIGIT, '');
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/fetch", "name": "@robonen/fetch",
"version": "0.0.2", "version": "0.0.1",
"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,12 +15,11 @@
"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.18.0", "packageManager": "pnpm@10.34.1",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
+3 -3
View File
@@ -128,7 +128,7 @@ import type { FetchExecuteMiddleware, FetchHook, FetchHooks, FetchOptions, Fetch
* }); * });
* await billing('/invoices', { method: 'POST', body: { amount: 100 } }); * await billing('/invoices', { method: 'POST', body: { amount: 100 } });
* *
* @since 0.0.1 * @since 0.1.0
*/ */
export function definePlugin< export function definePlugin<
const Name extends string, const Name extends string,
@@ -228,7 +228,7 @@ function applyDefaults(
* Ordering: plugin defaults (in declaration order) → user defaults (user wins). * Ordering: plugin defaults (in declaration order) → user defaults (user wins).
* Headers are merged independently through a single Headers instance. * Headers are merged independently through a single Headers instance.
* *
* @since 0.0.1 * @since 0.1.0
*/ */
export function composePlugins( export function composePlugins(
plugins: readonly FetchPlugin[] | undefined, plugins: readonly FetchPlugin[] | undefined,
@@ -331,7 +331,7 @@ function composeExecute(middlewares: readonly FetchExecuteMiddleware[]): FetchEx
* @description Runs all instance-level (plugin) hooks for a single phase, then the * @description Runs all instance-level (plugin) hooks for a single phase, then the
* optional user per-request hook(s). Avoids allocating an intermediate array per call. * optional user per-request hook(s). Avoids allocating an intermediate array per call.
* *
* @since 0.0.1 * @since 0.1.0
*/ */
export async function runHookPhase<C>( export async function runHookPhase<C>(
instance: ReadonlyArray<FetchHook<C>> | undefined, instance: ReadonlyArray<FetchHook<C>> | undefined,
+1 -1
View File
@@ -44,7 +44,7 @@ function shouldRetryStatus(options: ResolvedFetchOptions, status: number): boole
* *
* Auto-registered by `createFetch`; disable per-request via `retry: false`. * Auto-registered by `createFetch`; disable per-request via `retry: false`.
* *
* @since 0.0.1 * @since 0.1.0
*/ */
export function retryPlugin() { export function retryPlugin() {
return definePlugin({ return definePlugin({
+1 -1
View File
@@ -20,7 +20,7 @@ const baseSignals = new WeakMap<object, AbortSignal | undefined>();
* *
* Auto-registered by `createFetch`; no-op when `timeout` is unset. * Auto-registered by `createFetch`; no-op when `timeout` is unset.
* *
* @since 0.0.1 * @since 0.1.0
*/ */
export function timeoutPlugin() { export function timeoutPlugin() {
return definePlugin({ return definePlugin({
-40
View File
@@ -1,40 +0,0 @@
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 });
});
});
+2 -8
View File
@@ -79,14 +79,8 @@ 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 */
* Request body. `BodyInit` values (string, Blob, FormData, streams, …) are body?: RequestInit['body'] | Record<string, unknown> | unknown[] | null;
* sent as-is; any other object or array is JSON-serialized. Typed as `object`
* rather than `Record<string, unknown>` so a named `interface` assigns
* directly — interfaces carry no implicit index signature and would otherwise
* force every caller to cast the body.
*/
body?: RequestInit['body'] | object | null;
/** Suppress throwing on 4xx/5xx responses */ /** Suppress throwing on 4xx/5xx responses */
ignoreResponseError?: boolean; ignoreResponseError?: boolean;
/** URL query parameters serialized and appended to the request URL */ /** URL query parameters serialized and appended to the request URL */
-6
View File
@@ -3,11 +3,5 @@ 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',
},
}, },
}); });
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/platform", "name": "@robonen/platform",
"version": "0.0.5", "version": "0.0.4",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Platform dependent utilities for javascript development", "description": "Platform dependent utilities for javascript development",
"keywords": [ "keywords": [
@@ -18,12 +18,11 @@
"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.18.0", "packageManager": "pnpm@10.34.1",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
+2 -2
View File
@@ -5,7 +5,7 @@
* @category Multi * @category Multi
* @description Global object that works in any environment * @description Global object that works in any environment
* *
* @since 0.0.2 * @since 0.0.1
*/ */
export const _global export const _global
= typeof globalThis !== 'undefined' = typeof globalThis !== 'undefined'
@@ -23,6 +23,6 @@ export const _global
* @category Multi * @category Multi
* @description Check if the current environment is the client * @description Check if the current environment is the client
* *
* @since 0.0.2 * @since 0.0.1
*/ */
export const isClient = typeof window !== 'undefined' && typeof document !== 'undefined'; export const isClient = typeof window !== 'undefined' && typeof document !== 'undefined';
+3 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/stdlib", "name": "@robonen/stdlib",
"version": "0.0.12", "version": "0.0.9",
"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,12 +18,11 @@
"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.18.0", "packageManager": "pnpm@10.34.1",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
@@ -52,6 +51,6 @@
"@robonen/tsdown": "workspace:*", "@robonen/tsdown": "workspace:*",
"eslint": "catalog:", "eslint": "catalog:",
"tsdown": "catalog:", "tsdown": "catalog:",
"typescript": "catalog:" "typescript": "^6.0.3"
} }
} }
@@ -21,28 +21,4 @@ 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,7 +418,6 @@ 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');
@@ -598,7 +597,6 @@ 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,12 +57,8 @@ 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];
@@ -1,91 +0,0 @@
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);
});
});
@@ -1,95 +0,0 @@
/**
* @name FenwickTree
* @category Data Structures
* @description Fenwick (Binary Indexed) tree over an array of non-negative
* numbers: O(log n) prefix sums, point updates, and monotonic lower-bound
* search, plus O(n) bulk rebuild. `lowerBound` assumes all values are
* non-negative (the prefix function must be non-decreasing)
*
* @example
* const tree = new FenwickTree(5);
* tree.build([10, 20, 30, 40, 50]);
* tree.prefix(3); // 60
* tree.update(1, 5); // value at index 1 becomes 25
* tree.lowerBound(65); // 3 — largest c with prefix(c) <= 65
*
* @since 0.0.11
*/
export class FenwickTree {
readonly size: number;
private readonly tree: Float64Array;
private readonly highBit: number;
constructor(size: number) {
this.size = size;
this.tree = new Float64Array(size + 1);
this.highBit = size > 0 ? 1 << (31 - Math.clz32(size)) : 0;
}
/**
* Bulk (re)initialization from raw values, O(n)
*
* @param {ArrayLike<number>} values The values to load, `values.length` must equal `size`
*/
build(values: ArrayLike<number>): void {
const { tree, size } = this;
tree.fill(0);
for (let i = 1; i <= size; i++) {
tree[i]! += values[i - 1]!;
const parent = i + (i & -i);
if (parent <= size)
tree[parent]! += tree[i]!;
}
}
/**
* Add `delta` to the value at `index`, O(log n)
*
* @param {number} index Zero-based index of the value to change
* @param {number} delta Amount to add (may be negative)
*/
update(index: number, delta: number): void {
for (let i = index + 1; i <= this.size; i += i & -i)
this.tree[i]! += delta;
}
/**
* Sum of the first `count` values, O(log n)
*
* @param {number} count How many leading values to sum
* @returns {number} The prefix sum
*/
prefix(count: number): number {
let sum = 0;
for (let i = count; i > 0; i -= i & -i)
sum += this.tree[i]!;
return sum;
}
/**
* Largest `c` in `[0, size]` with `prefix(c) + c * stride <= target`, O(log n).
* `stride` models a constant per-item addition (e.g. a layout gap) without
* storing it in the tree
*
* @param {number} target The offset to search for
* @param {number} stride Constant added per item, defaults to `0`
* @returns {number} The largest count whose strided prefix does not exceed `target`
*/
lowerBound(target: number, stride = 0): number {
if (target < 0)
return 0;
let pos = 0;
let sum = 0;
for (let step = this.highBit; step > 0; step >>= 1) {
const next = pos + step;
if (next <= this.size) {
const candidate = sum + this.tree[next]!;
if (candidate + next * stride <= target) {
pos = next;
sum = candidate;
}
}
}
return pos;
}
}
-1
View File
@@ -1,7 +1,6 @@
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';
+7 -7
View File
@@ -17,22 +17,22 @@
"extract": "jiti ./modules/extractor/extract.ts" "extract": "jiti ./modules/extractor/extract.ts"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0", "@modelcontextprotocol/sdk": "^1.29.0",
"marked": "^18.0.7", "marked": "^18.0.5",
"shiki": "^4.3.1", "shiki": "^4.2.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/fonts": "^0.14.0", "@nuxt/fonts": "^0.14.0",
"@nuxt/kit": "^4.5.1", "@nuxt/kit": "^4.4.8",
"@robonen/eslint": "workspace:*", "@robonen/eslint": "workspace:*",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.0",
"eslint": "catalog:", "eslint": "catalog:",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"nuxt": "catalog:", "nuxt": "catalog:",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.0",
"ts-morph": "^28.0.0", "ts-morph": "^28.0.0",
"vue": "catalog:", "vue": "catalog:",
"vue-router": "^5.2.0" "vue-router": "^5.1.0"
} }
} }
+2 -2
View File
@@ -16,7 +16,7 @@
"url": "git+https://github.com/robonen/tools.git", "url": "git+https://github.com/robonen/tools.git",
"directory": "packages/renovate" "directory": "packages/renovate"
}, },
"packageManager": "pnpm@11.18.0", "packageManager": "pnpm@10.34.1",
"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": "^44.2.1" "renovate": "^43.216.1"
} }
} }
+3 -3
View File
@@ -15,20 +15,20 @@
"type": "git", "type": "git",
"url": "git+https://github.com/robonen/tools.git" "url": "git+https://github.com/robonen/tools.git"
}, },
"packageManager": "pnpm@11.18.0", "packageManager": "pnpm@10.34.1",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@types/node": "^26.1.2", "@types/node": "^25.9.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": "catalog:", "typescript": "^6.0.3",
"vitest": "catalog:" "vitest": "catalog:"
}, },
"scripts": { "scripts": {
+5185 -4668
View File
File diff suppressed because it is too large Load Diff
+10 -37
View File
@@ -6,47 +6,20 @@ packages:
- vue/*/playground - vue/*/playground
- docs - docs
allowBuilds:
'@parcel/watcher': true
core-js-pure: true
dtrace-provider: true
esbuild: true
re2: true
unrs-resolver: true
catalog: catalog:
'@stylistic/eslint-plugin': ^5.10.0 '@stylistic/eslint-plugin': ^5.10.0
'@vitest/browser': ^4.1.10 '@vitest/browser': ^4.1.8
'@vitest/coverage-v8': ^4.1.10 '@vitest/coverage-v8': ^4.1.8
'@vitest/ui': ^4.1.10 '@vitest/ui': ^4.1.8
'@vue/shared': ^3.5.40 '@vue/shared': ^3.5.35
'@vue/test-utils': ^2.4.11 '@vue/test-utils': ^2.4.11
eslint: ^10.8.0 eslint: ^10.4.1
jsdom: ^30.0.1 jsdom: ^29.1.1
nuxt: ^4.5.1 nuxt: ^4.4.8
tsdown: ^0.22.14 tsdown: ^0.22.2
typescript: npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2 vitest: ^4.1.8
vitest: ^4.1.10 vue: ^3.5.35
vue: ^3.5.40
# TypeScript 7.0 dropped the classic JS compiler API (`require('typescript')`
# exports only `version`/`versionMajorMinor`), which breaks every Volar/tsc-API
# consumer we depend on: `@vue/compiler-sfc` type resolution (needs `ts.sys`),
# `rolldown-plugin-dts`'s Vue language (needs `ts.ScriptKind`) and `vue-tsc`.
# `typescript-native-bridge` keeps that API surface while type-checking on the
# native tsgo 7.0.2 engine, so we get the new compiler without the breakage.
# Overridden (not just cataloged) because the consumers above resolve
# `typescript` as a transitive peer, not as one of our direct dependencies.
# The version must be pinned exactly — caret ranges never match prereleases.
overrides:
typescript: npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2
ignoredBuiltDependencies: ignoredBuiltDependencies:
- '@parcel/watcher' - '@parcel/watcher'
- esbuild - esbuild
minimumReleaseAgeExclude:
- ast-kit@3.0.0
- renovate@43.228.0
- rolldown-plugin-dts@0.26.0
- tsdown@0.22.3
+1 -1
View File
@@ -2,6 +2,6 @@
"$schema": "https://jsr.io/schema/config-file.v1.json", "$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@robonen/primitives", "name": "@robonen/primitives",
"license": "Apache-2.0", "license": "Apache-2.0",
"version": "0.0.2", "version": "0.0.1",
"exports": "./src/index.ts" "exports": "./src/index.ts"
} }
+9 -13
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/primitives", "name": "@robonen/primitives",
"version": "0.0.2", "version": "0.0.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Collection of UI primitives", "description": "Collection of UI primitives",
"keywords": [ "keywords": [
@@ -15,12 +15,11 @@
"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.18.0", "packageManager": "pnpm@10.34.1",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
@@ -59,26 +58,23 @@
"@robonen/tsconfig": "workspace:*", "@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*", "@robonen/tsdown": "workspace:*",
"@vitest/browser": "catalog:", "@vitest/browser": "catalog:",
"@vitest/browser-playwright": "^4.1.10", "@vitest/browser-playwright": "^4.1.8",
"@vue/test-utils": "catalog:", "@vue/test-utils": "catalog:",
"axe-core": "^4.12.1", "axe-core": "^4.12.0",
"eslint": "catalog:", "eslint": "catalog:",
"playwright": "^1.62.0", "playwright": "^1.60.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-tsc": "^3.3.4"
"vue-tsc": "^3.3.8"
}, },
"dependencies": { "dependencies": {
"@floating-ui/vue": "^2.0.1", "@floating-ui/vue": "^2.0.0",
"@robonen/encoding": "workspace:*", "@robonen/encoding": "workspace:*",
"@robonen/platform": "workspace:*", "@robonen/platform": "workspace:*",
"@robonen/stdlib": "workspace:*", "@robonen/stdlib": "workspace:*",
"@robonen/vue": "workspace:*", "@robonen/vue": "workspace:*",
"@vue/shared": "catalog:" "@vue/shared": "catalog:",
}, "vue": "catalog:"
"peerDependencies": {
"vue": "^3.5"
} }
} }
+6 -6
View File
@@ -13,14 +13,14 @@
"dependencies": { "dependencies": {
"@robonen/primitives": "workspace:*", "@robonen/primitives": "workspace:*",
"vue": "catalog:", "vue": "catalog:",
"vue-router": "^5.2.0" "vue-router": "^5.1.0"
}, },
"devDependencies": { "devDependencies": {
"@robonen/tsconfig": "workspace:*", "@robonen/tsconfig": "workspace:*",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.0",
"@vitejs/plugin-vue": "^6.0.8", "@vitejs/plugin-vue": "^6.0.7",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.0",
"vite": "^8.1.5", "vite": "^8.0.16",
"vue-tsc": "^3.3.8" "vue-tsc": "^3.3.4"
} }
} }
@@ -12,14 +12,8 @@ import {
CalendarHeadCell, CalendarHeadCell,
CalendarRoot, CalendarRoot,
} from '../index'; } from '../index';
import { nativeDateAdapter } from '../../../utilities/config-provider';
import { findFirstFocusableDate, getLocaleWeekStartsOn, toIsoDate } from '../utils'; import { findFirstFocusableDate, getLocaleWeekStartsOn, toIsoDate } from '../utils';
// A date adapter whose "today" sits far outside any month exercised below, so
// the roving-tabindex fallback never anchors on the real system date (which
// would otherwise make date-sensitive expectations flaky).
const fixedTodayAdapter = { ...nativeDateAdapter, now: () => new Date(2020, 0, 1) };
function mountCalendar( function mountCalendar(
props: Record<string, unknown> = {}, props: Record<string, unknown> = {},
options: Record<string, unknown> = {}, options: Record<string, unknown> = {},
@@ -209,7 +203,6 @@ describe('Calendar — roving fallback tabindex', () => {
const w = mountCalendar({ const w = mountCalendar({
defaultPlaceholder: new Date(2026, 5, 1), defaultPlaceholder: new Date(2026, 5, 1),
isDateDisabled: (d: Date) => d.getMonth() === 5 && d.getDate() < 16, isDateDisabled: (d: Date) => d.getMonth() === 5 && d.getDate() < 16,
dateAdapter: fixedTodayAdapter,
}); });
const focusable = w.findAll('[data-primitives-calendar-cell-trigger][tabindex="0"]'); const focusable = w.findAll('[data-primitives-calendar-cell-trigger][tabindex="0"]');
expect(focusable).toHaveLength(1); expect(focusable).toHaveLength(1);
@@ -149,27 +149,19 @@ describe('scroll-area — ref forwarding', () => {
describe('scroll-area — glimpse type', () => { describe('scroll-area — glimpse type', () => {
it('accepts type="glimpse" and reveals scrollbars on pointer enter', async () => { it('accepts type="glimpse" and reveals scrollbars on pointer enter', async () => {
const w = track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body })); track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body }));
await waitFrames(); await waitFrames();
const root = w.element as HTMLElement; const root = document.querySelector('[dir]') as HTMLElement;
root.dispatchEvent(new PointerEvent('pointerenter')); root.dispatchEvent(new PointerEvent('pointerenter'));
await waitFrames(); await waitFrames();
// Scope to this component's root: browser-mode suites share one document, expect(document.querySelectorAll('[data-state="visible"]').length).toBeGreaterThan(0);
// so a global query can also count scrollbars mounted by other suites.
expect(root.querySelectorAll('[data-state="visible"]').length).toBeGreaterThan(0);
}); });
it('glimpse stays hidden when the pointer is away', async () => { it('glimpse stays hidden before any interaction', async () => {
const w = track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body })); track(mount(makeApp({ type: 'glimpse', scrollHideDelay: 5000 }), { attachTo: document.body }));
const root = w.element as HTMLElement;
// Browser mode uses a real cursor: the area mounts at the top-left, so a
// leftover pointer from a previous suite can land on it and fire a stray
// `pointerenter` (revealing the glimpse). Let that settle, then assert the
// deterministic "pointer not over the area" state via `pointerleave`.
await waitFrames(); await waitFrames();
root.dispatchEvent(new PointerEvent('pointerleave')); // No pointer enter / scroll => no visible scrollbar yet.
await waitFrames(); expect(document.querySelectorAll('[data-state="visible"]').length).toBe(0);
expect(root.querySelectorAll('[data-state="visible"]').length).toBe(0);
}); });
}); });
@@ -31,7 +31,7 @@ function isInClosedPopover(el: Element): boolean {
* *
* @param {MaybeComputedElementRef} target Element whose siblings should be aria-hidden * @param {MaybeComputedElementRef} target Element whose siblings should be aria-hidden
* *
* @since 0.0.1 * @since 0.0.14
*/ */
export function useHideOthers(target: MaybeComputedElementRef): void { export function useHideOthers(target: MaybeComputedElementRef): void {
if (!defaultWindow) return; if (!defaultWindow) return;
@@ -1,25 +0,0 @@
<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,7 +14,6 @@ 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';
@@ -31,9 +30,6 @@ const {
onPress, onPress,
onDrag, onDrag,
onRelease, onRelease,
onCancel,
armReason,
isAllowedToDrag,
modal, modal,
dismissible, dismissible,
keyboardIsOpen, keyboardIsOpen,
@@ -53,12 +49,10 @@ useScaleBackground();
const delayedSnapPoints = ref(false); const delayedSnapPoints = ref(false);
const snapPointHeight = computed(() => { const snapPointHeight = computed(() => {
const offset = snapPointsOffset.value?.[0]; if (snapPointsOffset.value && snapPointsOffset.value.length > 0)
return `${snapPointsOffset.value[0]}px`;
if (typeof offset === 'number' && Number.isFinite(offset)) return '0';
return `${offset}px`;
return '0px';
}); });
function handlePointerDownOutside(event: Event) { function handlePointerDownOutside(event: Event) {
@@ -72,21 +66,13 @@ 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) {
@@ -102,9 +88,8 @@ function handlePointerMove(event: PointerEvent) {
} }
watchEffect(() => { watchEffect(() => {
// `flush: 'pre'` effects run during SSR, where rAF doesn't exist. if (hasSnapPoints.value) {
if (hasSnapPoints.value && isClient) { globalThis.requestAnimationFrame(() => {
requestAnimationFrame(() => {
delayedSnapPoints.value = true; delayedSnapPoints.value = true;
}); });
} }
@@ -118,13 +103,10 @@ 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,8 +11,7 @@ export type { DrawerHandleProps } from './controls';
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import { onScopeDispose, useTemplateRef, watch, watchPostEffect } from 'vue'; import { ref, useTemplateRef, 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>();
@@ -20,7 +19,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, onCancel, handleRef, handleOnly, isOpen, snapPoints, activeSnapPoint, isDragging, isAllowedToDrag, dismissible, closeDrawer } const { onPress, onDrag, handleRef, handleOnly, isOpen, snapPoints, activeSnapPoint, isDragging, 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
@@ -32,67 +31,33 @@ watchPostEffect(() => {
handleRef.value = handleElement.value; handleRef.value = handleElement.value;
}); });
let cycleTimer: ReturnType<typeof setTimeout> | undefined; const closeTimeoutId = ref<number | null>(null);
const shouldCancelInteraction = ref(false);
// Tap-to-cycle as an explicit machine: a tap schedules the cycle after the function handleStartCycle() {
// double-tap window, a long hold suppresses it, and a second press inside the // Ignore the second tap of a double-tap.
// window cancels the pending cycle so a double-tap cycles once, never twice. if (shouldCancelInteraction.value) {
const tap = useStateMachine({ handleCancelInteraction();
initial: 'idle', return;
states: { }
idle: { on: { PRESS: 'pressed', TAP: 'tapPending' } },
pressed: { on: { LONG_PRESS: 'suppressed', DRAG: 'suppressed', TAP: 'tapPending', CANCEL: 'idle' } },
suppressed: { on: { TAP: 'idle', PRESS: 'pressed', CANCEL: 'idle' } },
tapPending: {
entry: () => {
cycleTimer = setTimeout(fireCycleElapsed, DOUBLE_TAP_TIMEOUT);
},
exit: () => clearTimeout(cycleTimer),
on: {
ELAPSED: { target: 'idle', action: cycleSnapPoints },
PRESS: 'pressed',
// A long-press timer armed before the release can still outrace the
// pending cycle treat it as suppression, like the release-time flag
// check of the pre-machine code did.
LONG_PRESS: 'suppressed',
DRAG: 'suppressed',
CANCEL: 'idle',
},
},
},
});
// The exit hook covers every transition; this covers unmount mid-window. globalThis.setTimeout(() => {
onScopeDispose(() => clearTimeout(cycleTimer)); handleCycleSnapPoints();
}, 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');
} }
// A long hold suppresses the tap-to-cycle. `distanceThreshold: false` keeps the function handleCycleSnapPoints() {
// original semantics: the hold counts even while the pointer drags the drawer.
onLongPress(handleElement, () => {
tap.send('LONG_PRESS');
}, { delay: LONG_HANDLE_PRESS_TIMEOUT, distanceThreshold: false });
function cycleSnapPoints() {
// Don't treat an accidental tap during a resize as a cycle. // Don't treat an accidental tap during a resize as a cycle.
if (isDragging.value || preventCycle) if (isDragging.value || preventCycle || shouldCancelInteraction.value) {
handleCancelInteraction();
return; return;
}
handleCancelInteraction();
if (!snapPoints.value || snapPoints.value.length === 0) { if (!snapPoints.value || snapPoints.value.length === 0) {
if (dismissible.value) if (!dismissible.value)
closeDrawer('handle-press'); closeDrawer();
return; return;
} }
@@ -100,7 +65,7 @@ function cycleSnapPoints() {
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('handle-press'); closeDrawer();
return; return;
} }
@@ -113,38 +78,30 @@ function cycleSnapPoints() {
activeSnapPoint.value = snapPoints.value[nextSnapPointIndex]; activeSnapPoint.value = snapPoints.value[nextSnapPointIndex];
} }
function handleClick() { function handleStartInteraction() {
tap.send('TAP'); closeTimeoutId.value = globalThis.setTimeout(() => {
// 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, handleElement.value ?? undefined); onPress(event);
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>
@@ -153,9 +110,8 @@ function handleLostPointerCapture(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="handleClick" @click="handleStartCycle"
@pointercancel="handlePointerCancel" @pointercancel="handleCancelInteraction"
@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, isAllowedToDrag } = injectDrawerRootContext(); const { overlayRef, hasSnapPoints, isOpen, shouldFade } = injectDrawerRootContext();
const { forwardRef, currentElement } = useForwardExpose(); const { forwardRef, currentElement } = useForwardExpose();
watch(currentElement, (el) => { watch(currentElement, (el) => {
@@ -31,7 +31,6 @@ 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,13 +18,12 @@ 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 { useEventListener, useStyleTag } from '@robonen/vue'; import { 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, registerDrawerCssProperties } from './style'; import { DRAWER_STYLES, DRAWER_STYLE_ID } from './style';
defineOptions({ inheritAttrs: false }); defineOptions({ inheritAttrs: false });
@@ -46,7 +45,6 @@ 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>();
@@ -54,9 +52,6 @@ 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
@@ -69,6 +64,14 @@ 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,
); );
@@ -88,7 +91,7 @@ const emitHandlers = {
emitClose: () => emit('close'), emitClose: () => emit('close'),
}; };
const { modal, drawerRef, pendingReason, notifySettled, hasSnapPoints } = provideDrawerRootContext( const { modal } = provideDrawerRootContext(
useDrawer({ useDrawer({
...emitHandlers, ...emitHandlers,
...toRefs(props), ...toRefs(props),
@@ -98,68 +101,6 @@ const { modal, drawerRef, pendingReason, notifySettled, hasSnapPoints } = provid
}), }),
); );
// `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,7 +9,6 @@
<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>();
@@ -32,10 +31,10 @@ function onRelease(open: boolean) {
emit('release', open); emit('release', open);
} }
function onOpenChange(open: boolean, details?: DrawerOpenChangeDetails) { function onOpenChange(open: boolean) {
if (open) if (open)
onNestedOpenChange(open); onNestedOpenChange(open);
emit('update:open', open, details); emit('update:open', open);
} }
</script> </script>
@@ -1,25 +0,0 @@
<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,7 +2,6 @@ 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,
@@ -14,7 +13,6 @@ 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>> = [];
@@ -22,7 +20,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(DRAWER_STYLE_ID)?.remove(); document.getElementById('robonen-drawer')?.remove();
}); });
function track<T extends VueWrapper<any>>(w: T): T { function track<T extends VueWrapper<any>>(w: T): T {
@@ -37,16 +35,6 @@ 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);
} }
@@ -63,52 +51,14 @@ 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;
contentStyle?: Record<string, string>; onUpdateOpen?: (v: boolean) => void;
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;
} }
@@ -125,12 +75,7 @@ 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,
}, },
{ {
@@ -139,13 +84,12 @@ function mountDrawer(options: MountOptions = {}) {
h(DrawerPortal, null, { h(DrawerPortal, null, {
default: () => [ default: () => [
h(DrawerOverlay, { 'data-testid': 'overlay' }), h(DrawerOverlay, { 'data-testid': 'overlay' }),
h(DrawerContent, { style: { height: '200px', width: '200px', ...options.contentStyle } }, { h(DrawerContent, null, {
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,
], ],
}), }),
], ],
@@ -169,7 +113,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(`#${DRAWER_STYLE_ID}`); const tags = document.querySelectorAll('#robonen-drawer');
expect(tags.length).toBe(1); expect(tags.length).toBe(1);
expect(tags[0]!.textContent).toContain('@keyframes slideFromBottom'); expect(tags[0]!.textContent).toContain('@keyframes slideFromBottom');
}); });
@@ -208,13 +152,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 with a trigger-press reason (controlled)', async () => { it('emits update:open when the trigger is clicked (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, { reason: 'trigger-press' }); expect(onUpdateOpen).toHaveBeenCalledWith(true);
}); });
it('emits close exactly once when dismissed via DrawerClose', async () => { it('emits close exactly once when dismissed via DrawerClose', async () => {
@@ -231,7 +175,6 @@ 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() {
@@ -239,10 +182,7 @@ describe('Drawer / open state', () => {
DrawerRoot, DrawerRoot,
{ {
open: state.value, open: state.value,
'onUpdate:open': (v: boolean, details?: unknown) => { 'onUpdate:open': (v: boolean) => { state.value = v; },
state.value = v;
onUpdateOpen(v, details);
},
onClose, onClose,
}, },
{ {
@@ -263,8 +203,6 @@ 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);
}); });
}); });
@@ -281,497 +219,3 @@ 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)`);
});
});
});
@@ -1,74 +0,0 @@
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);
});
});
@@ -1,224 +0,0 @@
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);
});
});
@@ -1,192 +0,0 @@
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,33 +24,3 @@ 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;
+10 -38
View File
@@ -1,24 +1,13 @@
import type { Ref, ShallowRef } from 'vue'; import type { Ref } 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, DrawerOpenChangeReason, DrawerPhase } from './types'; import type { DrawerDirection } 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. */
@@ -31,11 +20,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>;
/** `event.timeStamp` of the active drag's start (ms, `performance.now()` clock). */ /** Timestamp the active drag started, for velocity calculations. */
dragStartTime: Ref<number | null>; dragStartTime: Ref<Date | 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, px numbers, or px/rem strings). */ /** Configured snap points (fractions of the screen or px 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>;
@@ -49,35 +38,18 @@ 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 (`NaN` for invalid points). */ /** Pixel offset of each snap point along the drag axis. */
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. */
* Begin a drag gesture. `captureTarget` is the element that receives pointer onPress: (event: PointerEvent) => void;
* 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. */
* Abort the active drag without a user release (`pointercancel`, lost closeDrawer: () => void;
* 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. */
+235 -474
View File
@@ -2,31 +2,21 @@ 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 { useStateMachine, useTextSelection, useWindowSize } from '@robonen/vue'; import { dampenValue, getDrawerWrapper, isVertical } from './helpers';
import { dampenValue, getDrawerWrapper, getScaleFactor, isVertical, translate3d, translateAxis, writeTransform } from './helpers'; import { BORDER_RADIUS, DRAG_CLASS, NESTED_DISPLACEMENT, TRANSITIONS, VELOCITY_THRESHOLD, WINDOW_TOP_OFFSET } from './constants';
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, DrawerOpenChangeDetails, DrawerOpenChangeReason } from './types'; import type { DrawerDirection } 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 {
/** /**
* Snap points ordered from least to most visible: fractions (01) of the * Fractions (01) of the screen each snap point occupies, ordered from least
* screen, raw pixel numbers (> 1), or `'Npx'`/`'Nrem'` strings e.g. * to most visible e.g. `[0.2, 0.5, 0.8]`. Px strings (e.g. `'200px'`) are
* `[0.2, '148px', 0.8]`. * also accepted and ignore screen height.
*/ */
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. */
@@ -92,12 +82,6 @@ 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 {
@@ -117,7 +101,6 @@ 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 {
@@ -127,8 +110,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. `details.reason` says what flipped it. */ /** Two-way binding for the open state. */
(e: 'update:open', open: boolean, details?: DrawerOpenChangeDetails): void; (e: 'update:open', open: boolean): 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. */
@@ -146,47 +129,6 @@ 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;
} }
@@ -215,19 +157,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 isAllowedToDrag = ref(false); const justReleased = ref(false);
const dragStartTime = ref<number | null>(null);
const overlayRef = shallowRef<HTMLElement | undefined>(undefined); const overlayRef = shallowRef<HTMLElement | undefined>(undefined);
// Timestamps on the `performance.now()` clock (same origin as event.timeStamp). const openTime = ref<Date | null>(null);
let openTime: number | null = null; const dragStartTime = ref<Date | null>(null);
let lastTimeDragPrevented: number | null = null; const dragEndTime = ref<Date | null>(null);
const lastTimeDragPrevented = ref<Date | null>(null);
const isAllowedToDrag = ref(false);
const nestedOpenChangeTimer = ref<number | null>(null); const nestedOpenChangeTimer = ref<number | null>(null);
@@ -243,31 +185,11 @@ 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({
@@ -278,16 +200,13 @@ 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 = performance.now(); openTime.value = new Date();
} }
usePositionFixed({ usePositionFixed({
@@ -299,300 +218,203 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
preventScrollRestoration, preventScrollRestoration,
}); });
// The drawer's lifecycle as explicit phases. `OPEN`/`CLOSE` are driven by the function getScale() {
// shared `open` ref below; `SETTLE` arrives from DrawerRoot when the enter/exit return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth;
// 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();
const element = el as HTMLElement; if (element.hasAttribute('data-drawer-no-drag') || element.closest('[data-drawer-no-drag]'))
if (element.closest?.('[data-drawer-no-drag]'))
return false; return false;
// Allow scrolling during the open animation. if (direction.value === 'right' || direction.value === 'left')
if (openTime !== null && now - openTime < 500)
return false;
// Partially hidden (a snap point below fully open, or a mid-animation
// grab) — the drawer is always draggable.
const swipeAmount = g.translate;
if (g.multiplier === 1 ? swipeAmount > 0 : swipeAmount < 0)
return true; return true;
// Don't drag when text is selected (reactive — no per-move getSelection). // Allow scrolling during the open animation.
if (selectedText.value.length > 0) if (openTime.value && date.getTime() - openTime.value.getTime() < 500)
return false;
if (swipeAmount !== null) {
if (direction.value === 'bottom' ? swipeAmount > 0 : swipeAmount < 0)
return true;
}
// Don't drag when text is selected.
if (highlightedText && highlightedText.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 !== null lastTimeDragPrevented.value
&& now - lastTimeDragPrevented < scrollLockTimeout.value && date.getTime() - lastTimeDragPrevented.value.getTime() < scrollLockTimeout.value
&& swipeAmount === 0 && swipeAmount === 0
) { ) {
lastTimeDragPrevented = now; lastTimeDragPrevented.value = date;
return false; return false;
} }
if (isDraggingInDirection) { if (isDraggingInDirection) {
lastTimeDragPrevented = now; lastTimeDragPrevented.value = date;
// Dragging in the open direction → allow scrolling instead. // Dragging in the open direction → allow scrolling instead.
return false; return false;
} }
// A scroll container under the pointer owns the gesture unless it already // Walk up the tree; if a scrollable ancestor isn't at the top, scroll it instead of dragging.
// sits at the edge the dismiss direction pulls away from. while (element) {
if (g.scroller && !isAtScrollEdge(g.scroller, direction.value)) { if (element.scrollHeight > element.clientHeight) {
lastTimeDragPrevented = now; if (element.scrollTop !== 0) {
return false; lastTimeDragPrevented.value = new Date();
return false;
}
if (element.getAttribute('role') === 'dialog')
return true;
}
element = element.parentNode as HTMLElement;
} }
return true; return true;
} }
function onPress(event: PointerEvent, captureTarget?: HTMLElement) { // Measured once per gesture in onPress and reused every move — avoids a
// One gesture at a time; a second touch never steals an active drag. But a // per-frame getBoundingClientRect (forced reflow) and document.querySelector.
// gesture whose capture element left the DOM can never finish (its let dragStartHeight = 0;
// lostpointercapture fires at the document, past our listeners) — reclaim let dragWrapper: HTMLElement | null = null;
// it instead of wedging every future drag.
if (gesture) {
if (gesture.captureTarget.isConnected)
return;
gesture = null; function onPress(event: PointerEvent) {
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 (event.button > 0) if (drawerRef.value && !drawerRef.value.contains(event.target as Node))
return; return;
const el = drawerRef.value;
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; isDragging.value = true;
dragStartTime.value = event.timeStamp; dragStartTime.value = new Date();
pointerStart.value = vertical ? event.clientY : event.clientX; dragStartHeight = drawerRef.value?.getBoundingClientRect().height || 0;
dragWrapper = getDrawerWrapper();
gesture = { (event.target as HTMLElement).setPointerCapture(event.pointerId);
pointerId: event.pointerId, pointerStart.value = isVertical(direction.value) ? event.clientY : event.clientX;
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) {
const g = gesture; if (!drawerRef.value)
if (!g || event.pointerId !== g.pointerId || !isDragging.value || g.blocked || !drawerRef.value)
return; return;
const dx = event.clientX - g.startX; if (isDragging.value) {
const dy = event.clientY - g.startY; const directionMultiplier = direction.value === 'bottom' || direction.value === 'right' ? 1 : -1;
const draggedDistance
= (pointerStart.value - (isVertical(direction.value) ? event.clientY : event.clientX)) * directionMultiplier;
const isDraggingInDirection = draggedDistance > 0;
// Lock onto an axis on the first significant movement. A gesture that // Don't allow dragging toward close past the first snap point when not dismissible.
// locks onto the cross axis is a scroll/pan — never a drawer drag. const noCloseSnapPointsPreCondition = snapPoints.value && !dismissible.value && !isDraggingInDirection;
if (!g.axisLocked) {
const absX = Math.abs(dx);
const absY = Math.abs(dy);
if (absX < AXIS_LOCK_DISTANCE && absY < AXIS_LOCK_DISTANCE) if (noCloseSnapPointsPreCondition && activeSnapPointIndex.value === 0)
return; return;
g.axisLocked = true; const absDraggedDistance = Math.abs(draggedDistance);
const wrapper = dragWrapper;
if ((absX > absY) === g.vertical) { // 1 means the closed position. Height cached at drag start (no reflow).
g.blocked = true; let percentageDragged = absDraggedDistance / (dragStartHeight || 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))
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;
} }
}
g.velocity.add(g.vertical ? event.clientY : event.clientX, event.timeStamp); const opacityValue = 1 - percentageDragged;
const draggedDistance = (g.vertical ? g.startY - event.clientY : g.startX - event.clientX) * g.multiplier; if (shouldFade.value || (fadeFromIndex.value && activeSnapPointIndex.value === fadeFromIndex.value - 1)) {
const isDraggingInDirection = draggedDistance > 0; emitDrag(percentageDragged);
// Dismiss-positive displacement feeds the "changed my mind" detector. setStyle(overlayRef.value, { opacity: `${opacityValue}`, transition: 'none' }, true);
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 (g.wrapper && overlayRef.value && shouldScaleBackground.value && percentageDragged !== g.lastWrapperProgress) { if (wrapper && overlayRef.value && shouldScaleBackground.value) {
g.lastWrapperProgress = percentageDragged; const scaleValue = Math.min(getScale() + percentageDragged * (1 - getScale()), 1);
const borderRadiusValue = 8 - percentageDragged * 8;
const translateValue = Math.max(0, 14 - percentageDragged * 14);
const scaleValue = Math.min(g.scale + percentageDragged * (1 - g.scale), 1); setStyle(
const borderRadiusValue = 8 - percentageDragged * 8; wrapper,
const translateValue = Math.max(0, 14 - percentageDragged * 14); {
const style = g.wrapper.style; borderRadius: `${borderRadiusValue}px`,
transform: isVertical(direction.value)
? `scale(${scaleValue}) translate3d(0, ${translateValue}px, 0)`
: `scale(${scaleValue}) translate3d(${translateValue}px, 0, 0)`,
transition: 'none',
},
true,
);
}
style.borderRadius = `${borderRadiusValue}px`; if (!snapPoints.value) {
style.transform = g.vertical const translateValue = absDraggedDistance * directionMultiplier;
? `scale(${scaleValue}) translate3d(0, ${translateValue}px, 0)`
: `scale(${scaleValue}) translate3d(${translateValue}px, 0, 0)`;
style.transition = 'none';
}
if (!snapPoints.value) { setStyle(drawerRef.value, {
const translateValue = absDraggedDistance * g.multiplier; transform: isVertical(direction.value)
? `translate3d(0, ${translateValue}px, 0)`
writeTransform(drawerRef.value, translateAxis(g.vertical, translateValue)); : `translate3d(${translateValue}px, 0, 0)`,
g.translate = translateValue; });
}
} }
} }
function resetDrawer(duration: number = TRANSITIONS.DURATION, currentSwipeAmount?: number | null) { function resetDrawer() {
if (!drawerRef.value) if (!drawerRef.value)
return; return;
const wrapper = getDrawerWrapper(); const wrapper = getDrawerWrapper();
const swipeAmount = currentSwipeAmount const currentSwipeAmount = getTranslate(drawerRef.value, isVertical(direction.value) ? 'y' : 'x');
?? 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 ${duration}s ${ease}`, transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
}); });
setStyle(overlayRef.value, { setStyle(overlayRef.value, {
transition: `opacity ${duration}s ${ease}`, transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
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 && swipeAmount && swipeAmount > 0 && open.value) { if (shouldScaleBackground.value && currentSwipeAmount && currentSwipeAmount > 0 && open.value) {
setStyle( setStyle(
wrapper, wrapper,
{ {
@@ -600,11 +422,11 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
overflow: 'hidden', overflow: 'hidden',
...(isVertical(direction.value) ...(isVertical(direction.value)
? { ? {
transform: `scale(${getScaleFactor(windowWidth.value)}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)`, transform: `scale(${getScale()}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)`,
transformOrigin: 'top', transformOrigin: 'top',
} }
: { : {
transform: `scale(${getScaleFactor(windowWidth.value)}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)`, transform: `scale(${getScale()}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)`,
transformOrigin: 'left', transformOrigin: 'left',
}), }),
transitionProperty: 'transform, border-radius', transitionProperty: 'transform, border-radius',
@@ -620,136 +442,13 @@ 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(reason?: DrawerOpenChangeReason) { function closeDrawer() {
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.
@@ -763,28 +462,99 @@ 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; everything else rides the lifecycle // DrawerRoot's own watch on the same ref.
// machine's entry hooks.
watch(open, (o) => { watch(open, (o) => {
if (o) { if (o) {
lifecycle.send('OPEN'); openTime.value = new Date();
hasBeenOpened.value = true;
} }
else { else {
emitClose(); emitClose();
lifecycle.send('CLOSE'); globalThis.setTimeout(() => {
if (snapPoints.value)
activeSnapPoint.value = snapPoints.value[0];
}, TRANSITIONS.DURATION * 1000);
} }
}); });
function onNestedOpenChange(o: boolean) { function onNestedOpenChange(o: boolean) {
const scale = o ? (windowWidth.value - NESTED_DISPLACEMENT) / windowWidth.value : 1; const scale = o ? (window.innerWidth - NESTED_DISPLACEMENT) / window.innerWidth : 1;
const y = o ? -NESTED_DISPLACEMENT : 0; const y = o ? -NESTED_DISPLACEMENT : 0;
if (nestedOpenChangeTimer.value) if (nestedOpenChangeTimer.value)
clearTimeout(nestedOpenChangeTimer.value); globalThis.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(',')})`,
@@ -792,11 +562,13 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
}); });
if (!o && drawerRef.value) { if (!o && drawerRef.value) {
nestedOpenChangeTimer.value = setTimeout(() => { nestedOpenChangeTimer.value = globalThis.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: translate3d(direction.value, translateValue ?? 0), transform: isVertical(direction.value)
? `translate3d(0, ${translateValue}px, 0)`
: `translate3d(${translateValue}px, 0, 0)`,
}); });
}, 500); }, 500);
} }
@@ -806,25 +578,21 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
if (percentageDragged < 0) if (percentageDragged < 0)
return; return;
const el = drawerRef.value; const initialDim = isVertical(direction.value) ? window.innerHeight : window.innerWidth;
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;
// Per-frame path (driven by the child's drag) — direct writes, no setStyle. setStyle(drawerRef.value, {
el.style.transform = isVertical(direction.value) 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)`,
el.style.transition = 'none'; transition: 'none',
});
} }
function onNestedRelease(o: boolean) { function onNestedRelease(o: boolean) {
const dim = isVertical(direction.value) ? windowHeight.value : windowWidth.value; const dim = isVertical(direction.value) ? window.innerHeight : window.innerWidth;
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;
@@ -841,10 +609,6 @@ 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,
@@ -869,10 +633,7 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
onPress, onPress,
onDrag, onDrag,
onRelease, onRelease,
onCancel,
closeDrawer, closeDrawer,
armReason,
pendingReason,
onNestedDrag, onNestedDrag,
onNestedRelease, onNestedRelease,
onNestedOpenChange, onNestedOpenChange,
@@ -1,178 +0,0 @@
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,5 +1,4 @@
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
@@ -26,39 +25,3 @@ export function dampenValue(v: number): number {
export function getDrawerWrapper(): HTMLElement | null { export function getDrawerWrapper(): HTMLElement | null {
return document.querySelector<HTMLElement>('[data-drawer-wrapper]'); return document.querySelector<HTMLElement>('[data-drawer-wrapper]');
} }
/**
* The background-scale factor for a given window width (the stacked-card look
* leaves {@link WINDOW_TOP_OFFSET}px of the page peeking out).
*/
export function getScaleFactor(windowWidth: number): number {
return (windowWidth - WINDOW_TOP_OFFSET) / windowWidth;
}
/**
* A GPU-friendly translate along an axis, from a pre-resolved axis flag the
* drag hot path variant: no direction-string comparisons per frame.
*/
export function translateAxis(vertical: boolean, value: number): string {
return vertical
? `translate3d(0, ${value}px, 0)`
: `translate3d(${value}px, 0, 0)`;
}
/**
* {@link translateAxis} keyed by direction, for cold paths that hold the
* direction string rather than a gesture snapshot.
*/
export function translate3d(direction: DrawerDirection, value: number): string {
return translateAxis(isVertical(direction), value);
}
/**
* Per-frame single-property transform write for the drag hot path. Unlike
* `setStyle` this allocates nothing (no patch object, no `Object.entries`, no
* restore snapshot) restoration is handled wholesale on release.
*/
export function writeTransform(element: HTMLElement | undefined | null, value: string): void {
if (element)
element.style.transform = value;
}
+5 -5
View File
@@ -3,15 +3,11 @@ 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 { DrawerTriggerProps } from './DrawerTrigger.vue'; export type { DrawerDirection, SnapPoint } from './types';
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';
@@ -19,13 +15,17 @@ 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';
@@ -1,184 +0,0 @@
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 };
}
+1 -29
View File
@@ -8,35 +8,7 @@
* 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 = 'drawer'; export const DRAWER_STYLE_ID = 'robonen-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] {
+5 -20
View File
@@ -4,25 +4,10 @@
export type DrawerDirection = 'top' | 'bottom' | 'left' | 'right'; export type DrawerDirection = 'top' | 'bottom' | 'left' | 'right';
/** /**
* Lifecycle phase of the drawer. `opening`/`closing` last for the duration of * A resolved snap point: the original `fraction` (01 of the screen, or a raw
* the enter/exit animation; the settle signal (animation end or its fallback * px value) paired with its computed pixel `height`.
* timeout) advances them to `open`/`closed`.
*/ */
export type DrawerPhase = 'closed' | 'opening' | 'open' | 'closing'; export interface SnapPoint {
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);
requestAnimationFrame(() => { globalThis.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, getScaleFactor, isVertical } from './helpers'; import { getDrawerWrapper, isVertical } from './helpers';
import { BORDER_RADIUS, TRANSITIONS } from './constants'; import { BORDER_RADIUS, TRANSITIONS, WINDOW_TOP_OFFSET } 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,6 +16,10 @@ 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.
@@ -38,18 +42,17 @@ 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(${scale}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)` } ? { transform: `scale(${getScale()}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)` }
: { transform: `scale(${scale}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)` }), : { transform: `scale(${getScale()}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)` }),
}); });
onWatcherCleanup(() => { onWatcherCleanup(() => {
wrapperStylesCleanup(); wrapperStylesCleanup();
timeoutIdRef.value = setTimeout(() => { timeoutIdRef.value = globalThis.setTimeout(() => {
if (initialBackgroundColor.value) if (initialBackgroundColor.value)
document.body.style.background = initialBackgroundColor.value; document.body.style.background = initialBackgroundColor.value;
else else
@@ -1,10 +1,9 @@
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import { computed, nextTick, watch } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { setStyle } from '@robonen/platform/browsers'; import { setStyle } from '@robonen/platform/browsers';
import { isVertical, translateAxis, writeTransform } from './helpers'; import { useEventListener } from '@robonen/vue';
import { TRANSITIONS } from './constants'; import { isVertical } from './helpers';
import { computeSettleDuration } from './gesture'; import { TRANSITIONS, VELOCITY_THRESHOLD } from './constants';
import { findSnapPointIndex, projectSnapRelease, resolveSnapPointOffset } from './snapping';
import type { DrawerDirection } from './types'; import type { DrawerDirection } from './types';
interface UseSnapPointsProps { interface UseSnapPointsProps {
@@ -15,23 +14,16 @@ 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', duration: number = TRANSITIONS.DURATION) => const transition = (property: 'transform' | 'opacity') =>
`${property} ${duration}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`; `${property} ${TRANSITIONS.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 settles * snap point to a pixel offset, animates the drawer between them, and decides
* on release by projecting the drag target along the fling velocity. * which point to settle on (or whether to close) based on drag distance and
* velocity.
*/ */
export function useSnapPoints({ export function useSnapPoints({
activeSnapPoint, activeSnapPoint,
@@ -41,64 +33,26 @@ export function useSnapPoints({
fadeFromIndex, fadeFromIndex,
onSnapPointChange, onSnapPointChange,
direction, direction,
snapToSequentialPoints,
windowWidth,
windowHeight,
}: UseSnapPointsProps) { }: UseSnapPointsProps) {
// Direction resolved once per change instead of string-comparing per move. const windowDimensions = ref(globalThis.window !== undefined
const verticalAxis = computed(() => isVertical(direction.value)); ? { innerWidth: window.innerWidth, innerHeight: window.innerHeight }
const dismissMultiplier = computed<1 | -1>(() => : undefined);
direction.value === 'bottom' || direction.value === 'right' ? 1 : -1,
);
function windowSizeFor(dir: DrawerDirection): number { function onResize() {
return isVertical(dir) ? windowHeight.value : windowWidth.value; const innerWidth = window.innerWidth;
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 };
} }
let warnedInvalid = false; // Defaults to `defaultWindow` (SSR-safe) and auto-removes on scope dispose.
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 && activeSnapPointIndex.value === snapPoints.value.length - 1) ?? null, () => (snapPoints.value && activeSnapPoint.value === snapPoints.value[snapPoints.value.length - 1]) ?? null,
); );
const shouldFade = computed( const shouldFade = computed(
@@ -111,25 +65,58 @@ 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, options?: { velocity?: number; from?: number | null }) { function snapToPoint(dimension: number) {
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', duration), transition: transition('transform'),
transform: translateAxis(verticalAxis.value, dimension), transform: isVertical(direction.value) ? `translate3d(0, ${dimension}px, 0)` : `translate3d(${dimension}px, 0, 0)`,
}); });
}); });
@@ -138,30 +125,22 @@ 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', duration), opacity: '0' }); setStyle(overlayRef.value, { transition: transition('opacity'), opacity: '0' });
} }
else { else {
setStyle(overlayRef.value, { transition: transition('opacity', duration), opacity: '1' }); setStyle(overlayRef.value, { transition: transition('opacity'), 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 = activeSnapPointIndex.value ?? -1; const newIndex = snapPoints.value?.indexOf(activeSnapPoint.value) ?? -1;
if (snapPointsOffset.value && newIndex !== -1 && Number.isFinite(snapPointsOffset.value[newIndex])) if (snapPointsOffset.value && newIndex !== -1 && typeof snapPointsOffset.value[newIndex] === 'number')
snapToPoint(snapPointsOffset.value[newIndex]); snapToPoint(snapPointsOffset.value[newIndex]);
} }
}, },
@@ -173,66 +152,89 @@ 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 multiplier = dismissMultiplier.value; const currentPosition
const offsets = snapPointsOffset.value.map(offset => offset * multiplier); = direction.value === 'bottom' || direction.value === 'right'
? (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') });
const result = projectSnapRelease({ if (velocity > 2 && !hasDraggedUp) {
offsets, if (dismissible)
activeIndex: activeSnapPointIndex.value, closeDrawer();
draggedDistance, else
velocity, snapToPoint(snapPointsOffset.value[0]); // snap to initial point
drawerSize,
dismissible,
sequential: snapToSequentialPoints.value,
});
if (result.type === 'close') {
closeDrawer();
return; return;
} }
const target = snapPointsOffset.value[result.index]; if (velocity > 2 && hasDraggedUp && snapPointsOffset.value && snapPoints.value) {
const from = (activeSnapPointOffset.value ?? 0) - draggedDistance * multiplier; snapToPoint(snapPointsOffset.value[snapPoints.value.length - 1]);
return;
}
snapToPoint(target, { velocity, from }); // 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 (velocity > VELOCITY_THRESHOLD && Math.abs(draggedDistance) < dim * 0.4) {
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;
}
snapToPoint(closestSnapPoint);
} }
function onDrag({ draggedDistance }: { draggedDistance: number }): number | null { function onDrag({ draggedDistance }: { draggedDistance: number }) {
const activeOffset = activeSnapPointOffset.value; if (activeSnapPointOffset.value === null)
return;
if (activeOffset === null || activeOffset === undefined || !Number.isFinite(activeOffset)) const newValue
return null; = direction.value === 'bottom' || direction.value === 'right'
? (activeSnapPointOffset.value ?? 0) - draggedDistance
const positive = dismissMultiplier.value === 1; : (activeSnapPointOffset.value ?? 0) + draggedDistance;
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 (Number.isFinite(lastOffset) && (positive ? newValue < lastOffset : newValue > lastOffset)) if ((direction.value === 'bottom' || direction.value === 'right') && newValue < snapPointsOffset.value[snapPointsOffset.value.length - 1])
return null; return;
writeTransform(drawerRef.value, translateAxis(verticalAxis.value, newValue)); if ((direction.value === 'top' || direction.value === 'left') && newValue > snapPointsOffset.value[snapPointsOffset.value.length - 1])
return;
return newValue; setStyle(drawerRef.value, {
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) {
@@ -276,7 +278,6 @@ export function useSnapPoints({
activeSnapPointIndex, activeSnapPointIndex,
onRelease, onRelease,
onDrag, onDrag,
restoreActiveSnapPoint,
snapPointsOffset, snapPointsOffset,
}; };
} }
+1 -4
View File
@@ -11,10 +11,7 @@ export default defineConfig({
dts: { vue: true }, dts: { vue: true },
deps: { deps: {
neverBundle: ['vue'], neverBundle: ['vue'],
// `@robonen/*` stay external (deduped by the package manager); only the alwaysBundle: [/^@robonen\//, '@vue/shared'],
// stateless `@vue/shared` helpers are inlined (a Vue internal consumers
// don't install directly, so it can't be externalized reliably).
alwaysBundle: ['@vue/shared'],
}, },
inputOptions: { inputOptions: {
resolve: { resolve: {
-3
View File
@@ -13,9 +13,6 @@ export default defineConfig({
'@': resolve(__dirname, './src'), '@': resolve(__dirname, './src'),
}, },
}, },
optimizeDeps: {
include: ['@robonen/vue'],
},
test: { test: {
browser: { browser: {
enabled: true, enabled: true,
+6 -6
View File
@@ -19,12 +19,12 @@
"devDependencies": { "devDependencies": {
"@robonen/eslint": "workspace:*", "@robonen/eslint": "workspace:*",
"@robonen/tsconfig": "workspace:*", "@robonen/tsconfig": "workspace:*",
"@storybook/addon-a11y": "^10.5.5", "@storybook/addon-a11y": "^10.4.2",
"@storybook/addon-docs": "^10.5.5", "@storybook/addon-docs": "^10.4.2",
"@storybook/vue3-vite": "^10.5.5", "@storybook/vue3-vite": "^10.4.2",
"@vitejs/plugin-vue": "^6.0.8", "@vitejs/plugin-vue": "^6.0.7",
"eslint": "catalog:", "eslint": "catalog:",
"storybook": "^10.5.5", "storybook": "^10.4.2",
"vite": "^8.1.5" "vite": "^8.0.16"
} }
} }
+5 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/vue", "name": "@robonen/vue",
"version": "0.2.0", "version": "0.0.13",
"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,12 +16,11 @@
"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.18.0", "packageManager": "pnpm@10.34.1",
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=24.16.0"
}, },
"type": "module", "type": "module",
"sideEffects": false,
"files": [ "files": [
"dist" "dist"
], ],
@@ -50,14 +49,11 @@
"@robonen/tsdown": "workspace:*", "@robonen/tsdown": "workspace:*",
"@vue/test-utils": "catalog:", "@vue/test-utils": "catalog:",
"eslint": "catalog:", "eslint": "catalog:",
"tsdown": "catalog:", "tsdown": "catalog:"
"vue": "catalog:"
}, },
"dependencies": { "dependencies": {
"@robonen/platform": "workspace:*", "@robonen/platform": "workspace:*",
"@robonen/stdlib": "workspace:*" "@robonen/stdlib": "workspace:*",
}, "vue": "catalog:"
"peerDependencies": {
"vue": "^3.5"
} }
} }
@@ -167,7 +167,7 @@ const RESERVED_KEYS = [
* // Shorthand: third argument is the duration in milliseconds * // Shorthand: third argument is the duration in milliseconds
* useAnimate(el, { opacity: [0, 1] }, 500); * useAnimate(el, { opacity: [0, 1] }, 500);
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useAnimate( export function useAnimate(
target: MaybeComputedElementRef, target: MaybeComputedElementRef,
@@ -81,7 +81,7 @@ export interface UseCountdownReturn extends ResumableActions {
* onComplete: () => console.log('done'), * onComplete: () => console.log('done'),
* }); * });
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useCountdown( export function useCountdown(
initialCountdown: MaybeRefOrGetter<number>, initialCountdown: MaybeRefOrGetter<number>,
@@ -44,15 +44,15 @@ export type UseDateFormatReturn = ComputedRef<string>;
// Matches a token, or a `[literal]` escape that is emitted verbatim. // Matches a token, or a `[literal]` escape that is emitted verbatim.
const REGEX_FORMAT const REGEX_FORMAT
= /[YMDHhms]o|\[([^\]]+)\]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a{1,2}|A{1,2}|m{1,2}|s{1,2}|z{1,4}|SSS/g; = /* #__PURE__ */ /[YMDHhms]o|\[([^\]]+)\]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a{1,2}|A{1,2}|m{1,2}|s{1,2}|z{1,4}|SSS/g;
// Loose ISO-ish parser used for date strings without a trailing `Z`. The optional // Loose ISO-ish parser used for date strings without a trailing `Z`. The optional
// separators make adjacent digit groups technically "misleading" to the linter, // separators make adjacent digit groups technically "misleading" to the linter,
// but this is the deliberate lenient dayjs parser (accepts `2024-01-01` and // but this is the deliberate lenient dayjs parser (accepts `2024-01-01` and
// `20240101`); JS lacks possessive quantifiers to disambiguate it. // `20240101`); JS lacks possessive quantifiers to disambiguate it.
// eslint-disable-next-line regexp/no-misleading-capturing-group // eslint-disable-next-line regexp/no-misleading-capturing-group
const REGEX_PARSE = /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[T\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/i; const REGEX_PARSE = /* #__PURE__ */ /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[T\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/i;
const REGEX_ISO_SUFFIX = /z$/i; const REGEX_ISO_SUFFIX = /* #__PURE__ */ /z$/i;
const ORDINAL_SUFFIXES = ['th', 'st', 'nd', 'rd'] as const; const ORDINAL_SUFFIXES = ['th', 'st', 'nd', 'rd'] as const;
@@ -207,7 +207,7 @@ export function formatDate(
* customMeridiem: (h) => (h < 12 ? 'morning' : 'evening'), * customMeridiem: (h) => (h < 12 ? 'morning' : 'evening'),
* }); * });
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useDateFormat( export function useDateFormat(
date: MaybeRefOrGetter<DateLike>, date: MaybeRefOrGetter<DateLike>,
@@ -59,7 +59,7 @@ export type UseIntervalReturn = Readonly<ShallowRef<number>> | UseIntervalContro
* @example * @example
* const { counter, isActive, pause, resume, reset } = useInterval(1000, { controls: true }); * const { counter, isActive, pause, resume, reset } = useInterval(1000, { controls: true });
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useInterval(interval?: MaybeRefOrGetter<number>, options?: UseIntervalOptions<false>): Readonly<ShallowRef<number>>; export function useInterval(interval?: MaybeRefOrGetter<number>, options?: UseIntervalOptions<false>): Readonly<ShallowRef<number>>;
export function useInterval(interval: MaybeRefOrGetter<number>, options: UseIntervalOptions<true>): UseIntervalControls; export function useInterval(interval: MaybeRefOrGetter<number>, options: UseIntervalOptions<true>): UseIntervalControls;
@@ -70,7 +70,7 @@ export type UseNowReturn<Controls extends boolean>
* // Run a callback on every update * // Run a callback on every update
* useNow({ interval: 1000, callback: date => console.log(date.toISOString()) }); * useNow({ interval: 1000, callback: date => console.log(date.toISOString()) });
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useNow(options?: UseNowOptions<false>): Ref<Date>; export function useNow(options?: UseNowOptions<false>): Ref<Date>;
export function useNow(options: UseNowOptions<true>): UseNowControls; export function useNow(options: UseNowOptions<true>): UseNowControls;
@@ -165,7 +165,7 @@ const DEFAULT_UNITS: Array<UseTimeAgoUnit<UseTimeAgoUnitName>> = [
{ max: Number.POSITIVE_INFINITY, value: 31536000000, name: 'year' }, { max: Number.POSITIVE_INFINITY, value: 31536000000, name: 'year' },
]; ];
const REGEX_DIGIT = /\d/; const REGEX_DIGIT = /* #__PURE__ */ /\d/;
const DEFAULT_MESSAGES: UseTimeAgoMessages<UseTimeAgoUnitName> = { const DEFAULT_MESSAGES: UseTimeAgoMessages<UseTimeAgoUnitName> = {
justNow: 'just now', justNow: 'just now',
@@ -197,7 +197,7 @@ function defaultFullDateFormatter(date: Date): string {
* @example * @example
* formatTimeAgo(new Date(Date.now() - 3 * 60_000)); // '3 minutes ago' * formatTimeAgo(new Date(Date.now() - 3 * 60_000)); // '3 minutes ago'
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function formatTimeAgo<UnitNames extends string = UseTimeAgoUnitName>( export function formatTimeAgo<UnitNames extends string = UseTimeAgoUnitName>(
from: Date, from: Date,
@@ -303,7 +303,7 @@ export function formatTimeAgo<UnitNames extends string = UseTimeAgoUnitName>(
* fullDateFormatter: d => d.toLocaleDateString('fr-FR'), * fullDateFormatter: d => d.toLocaleDateString('fr-FR'),
* }); * });
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useTimeAgo<UnitNames extends string = UseTimeAgoUnitName>( export function useTimeAgo<UnitNames extends string = UseTimeAgoUnitName>(
time: MaybeRefOrGetter<Date | number | string>, time: MaybeRefOrGetter<Date | number | string>,
@@ -61,7 +61,7 @@ export type UseTimeoutReturn
* // Run a callback when the timeout elapses * // Run a callback when the timeout elapses
* useTimeout(5000, { callback: refresh }); * useTimeout(5000, { callback: refresh });
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useTimeout(interval?: MaybeRefOrGetter<number>, options?: UseTimeoutOptions<false>): ComputedRef<boolean>; export function useTimeout(interval?: MaybeRefOrGetter<number>, options?: UseTimeoutOptions<false>): ComputedRef<boolean>;
export function useTimeout(interval: MaybeRefOrGetter<number>, options: UseTimeoutOptions<true>): UseTimeoutControls; export function useTimeout(interval: MaybeRefOrGetter<number>, options: UseTimeoutOptions<true>): UseTimeoutControls;
@@ -58,7 +58,7 @@ export interface UseTimeoutFnReturn<Args extends unknown[]> {
* // Fire once now and again after the delay * // Fire once now and again after the delay
* useTimeoutFn(refresh, 5000, { immediateCallback: true }); * useTimeoutFn(refresh, 5000, { immediateCallback: true });
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useTimeoutFn<T extends AnyFunction>( export function useTimeoutFn<T extends AnyFunction>(
cb: T, cb: T,
@@ -82,7 +82,7 @@ export type UseTimestampReturn<Controls extends boolean> = Controls extends true
* const offset = ref(0); * const offset = ref(0);
* const now = useTimestamp({ offset }); * const now = useTimestamp({ offset });
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useTimestamp(options?: UseTimestampOptions<false>): Ref<number>; export function useTimestamp(options?: UseTimestampOptions<false>): Ref<number>;
export function useTimestamp(options: UseTimestampOptions<true>): UseTimestampControls; export function useTimestamp(options: UseTimestampOptions<true>): UseTimestampControls;
@@ -218,7 +218,7 @@ function valuesEqual(a: TransitionValue, b: TransitionValue): boolean {
* const color = ref([0, 0, 0]); * const color = ref([0, 0, 0]);
* const animated = useTransition(color, { duration: 1000 }); * const animated = useTransition(color, { duration: 1000 });
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useTransition<T extends TransitionValue>( export function useTransition<T extends TransitionValue>(
source: MaybeRefOrGetter<T>, source: MaybeRefOrGetter<T>,
@@ -58,7 +58,7 @@ function isArrayDifferenceOptions<T>(value: unknown): value is UseArrayDifferenc
* const b = ref([2, 3, 4]); * const b = ref([2, 3, 4]);
* const symmetric = useArrayDifference(a, b, { symmetric: true }); // [1, 4] * const symmetric = useArrayDifference(a, b, { symmetric: true }); // [1, 4]
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useArrayDifference<T>( export function useArrayDifference<T>(
list: MaybeRefOrGetter<T[]>, list: MaybeRefOrGetter<T[]>,
@@ -20,7 +20,7 @@ export type UseArrayEveryReturn = ComputedRef<boolean>;
* const items = [ref(2), ref(4), ref(6)]; * const items = [ref(2), ref(4), ref(6)];
* const allEven = useArrayEvery(items, n => n % 2 === 0); // true * const allEven = useArrayEvery(items, n => n % 2 === 0); // true
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useArrayEvery<T>( export function useArrayEvery<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>, list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
@@ -14,7 +14,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue';
* const list = ref([1, 2, 3, 4]); * const list = ref([1, 2, 3, 4]);
* const even = useArrayFilter(list, n => n % 2 === 0); // [2, 4] * const even = useArrayFilter(list, n => n % 2 === 0); // [2, 4]
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useArrayFilter<T>( export function useArrayFilter<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>, list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
@@ -14,7 +14,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue';
* const list = ref([1, 2, 3]); * const list = ref([1, 2, 3]);
* const found = useArrayFind(list, n => n > 1); // 2 * const found = useArrayFind(list, n => n > 1); // 2
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useArrayFind<T>( export function useArrayFind<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>, list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
@@ -16,7 +16,7 @@ export type UseArrayFindIndexReturn = ComputedRef<number>;
* const list = ref([1, 2, 3]); * const list = ref([1, 2, 3]);
* const index = useArrayFindIndex(list, n => n > 1); // 1 * const index = useArrayFindIndex(list, n => n > 1); // 1
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useArrayFindIndex<T>( export function useArrayFindIndex<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>, list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
@@ -35,7 +35,7 @@ const hasNativeFindLast = typeof Array.prototype.findLast === 'function';
* const list = ref([1, 2, 3, 4]); * const list = ref([1, 2, 3, 4]);
* const found = useArrayFindLast(list, n => n % 2 === 0); // 4 * const found = useArrayFindLast(list, n => n % 2 === 0); // 4
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useArrayFindLast<T>( export function useArrayFindLast<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>, list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
@@ -50,7 +50,7 @@ function isArrayIncludesOptions<T, V>(value: unknown): value is UseArrayIncludes
* const list = ref(['a', 'b', 'a']); * const list = ref(['a', 'b', 'a']);
* const fromSecond = useArrayIncludes(list, 'a', { fromIndex: 1 }); // true * const fromSecond = useArrayIncludes(list, 'a', { fromIndex: 1 }); // true
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useArrayIncludes<T, V = T>( export function useArrayIncludes<T, V = T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>, list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
@@ -18,7 +18,7 @@ export type UseArrayJoinReturn = ComputedRef<string>;
* const sep = ref('-'); * const sep = ref('-');
* const joined = useArrayJoin(list, sep); // 'a-b-c' * const joined = useArrayJoin(list, sep); // 'a-b-c'
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useArrayJoin( export function useArrayJoin(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<unknown>>>, list: MaybeRefOrGetter<Array<MaybeRefOrGetter<unknown>>>,
@@ -14,7 +14,7 @@ import type { ComputedRef, MaybeRefOrGetter } from 'vue';
* const list = ref([1, 2, 3]); * const list = ref([1, 2, 3]);
* const doubled = useArrayMap(list, n => n * 2); // [2, 4, 6] * const doubled = useArrayMap(list, n => n * 2); // [2, 4, 6]
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useArrayMap<T, U = T>( export function useArrayMap<T, U = T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>, list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
@@ -19,7 +19,7 @@ export type UseArrayReduceReturn<T> = ComputedRef<T>;
* const list = ref([1, 2, 3, 4]); * const list = ref([1, 2, 3, 4]);
* const sum = useArrayReduce(list, (acc, n) => acc + n); // 10 * const sum = useArrayReduce(list, (acc, n) => acc + n); // 10
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useArrayReduce<T>( export function useArrayReduce<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>, list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
@@ -40,7 +40,7 @@ export function useArrayReduce<T>(
* const list = ref([1, 2, 3, 4]); * const list = ref([1, 2, 3, 4]);
* const sum = useArrayReduce(list, (acc, n) => acc + n, 100); // 110 * const sum = useArrayReduce(list, (acc, n) => acc + n, 100); // 110
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useArrayReduce<T, U>( export function useArrayReduce<T, U>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>, list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
@@ -20,7 +20,7 @@ export type UseArraySomeReturn = ComputedRef<boolean>;
* const items = [ref(1), ref(3), ref(5)]; * const items = [ref(1), ref(3), ref(5)];
* const hasEven = useArraySome(items, n => n % 2 === 0); // false * const hasEven = useArraySome(items, n => n % 2 === 0); // false
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useArraySome<T>( export function useArraySome<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>, list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
@@ -42,7 +42,7 @@ export type UseArrayUniqueReturn<T = unknown> = ComputedRef<T[]>;
* const list = ref([1.1, 1.4, 2.2]); * const list = ref([1.1, 1.4, 2.2]);
* const byFloor = useArrayUnique(list, (a, b) => Math.floor(a) === Math.floor(b)); // [1.1, 2.2] * const byFloor = useArrayUnique(list, (a, b) => Math.floor(a) === Math.floor(b)); // [1.1, 2.2]
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useArrayUnique<T>( export function useArrayUnique<T>(
list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>, list: MaybeRefOrGetter<Array<MaybeRefOrGetter<T>>>,
@@ -99,7 +99,7 @@ const defaultSortFn: UseSortedFn = <T>(source: T[], compareFn: UseSortedCompareF
* useSorted(list, { dirty: true }); * useSorted(list, { dirty: true });
* // list.value is now [1, 2, 3] * // list.value is now [1, 2, 3]
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useSorted<T = unknown>(source: Ref<T[]>, compareFn?: UseSortedCompareFn<T>): Ref<T[]>; export function useSorted<T = unknown>(source: Ref<T[]>, compareFn?: UseSortedCompareFn<T>): Ref<T[]>;
export function useSorted<T = unknown>(source: MaybeRefOrGetter<T[]>, compareFn?: UseSortedCompareFn<T>): ComputedRef<T[]>; export function useSorted<T = unknown>(source: MaybeRefOrGetter<T[]>, compareFn?: UseSortedCompareFn<T>): ComputedRef<T[]>;
@@ -104,7 +104,7 @@ function increaseWithUnit(target: number | string, delta: number): number | stri
* const bp = useBreakpoints({ mobile: 0, tablet: 640, desktop: 1024 }); * const bp = useBreakpoints({ mobile: 0, tablet: 640, desktop: 1024 });
* const active = bp.active(); // ComputedRef<'mobile' | 'tablet' | 'desktop' | ''> * const active = bp.active(); // ComputedRef<'mobile' | 'tablet' | 'desktop' | ''>
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useBreakpoints<K extends string>( export function useBreakpoints<K extends string>(
breakpoints: Breakpoints<K>, breakpoints: Breakpoints<K>,
@@ -76,7 +76,7 @@ export interface UseClipboardReturn<Optional extends boolean> {
* // Copy a lazily/asynchronously resolved value * // Copy a lazily/asynchronously resolved value
* copy(async () => (await fetch('/token').then(r => r.text()))); * copy(async () => (await fetch('/token').then(r => r.text())));
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useClipboard(options?: UseClipboardOptions<undefined>): UseClipboardReturn<false>; export function useClipboard(options?: UseClipboardOptions<undefined>): UseClipboardReturn<false>;
export function useClipboard(options: UseClipboardOptions<MaybeRefOrGetter<string>>): UseClipboardReturn<true>; export function useClipboard(options: UseClipboardOptions<MaybeRefOrGetter<string>>): UseClipboardReturn<true>;
@@ -96,7 +96,7 @@ export interface UseClipboardItemsReturn<Optional extends boolean> {
* const { content } = useClipboardItems({ read: true }); * const { content } = useClipboardItems({ read: true });
* copy(async () => buildClipboardItems()); * copy(async () => buildClipboardItems());
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useClipboardItems(options?: UseClipboardItemsOptions<undefined>): UseClipboardItemsReturn<false>; export function useClipboardItems(options?: UseClipboardItemsOptions<undefined>): UseClipboardItemsReturn<false>;
export function useClipboardItems(options: UseClipboardItemsOptions<MaybeRefOrGetter<ClipboardItems>>): UseClipboardItemsReturn<true>; export function useClipboardItems(options: UseClipboardItemsOptions<MaybeRefOrGetter<ClipboardItems>>): UseClipboardItemsReturn<true>;
@@ -76,7 +76,7 @@ export interface UseCloseWatcherReturn {
* // Programmatically request a close * // Programmatically request a close
* close(); * close();
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useCloseWatcher(options: UseCloseWatcherOptions = {}): UseCloseWatcherReturn { export function useCloseWatcher(options: UseCloseWatcherOptions = {}): UseCloseWatcherReturn {
const { window = defaultWindow } = options; const { window = defaultWindow } = options;
@@ -120,7 +120,7 @@ const CSS_DISABLE_TRANS = '*,*::before,*::after{-webkit-transition:none!importan
* // Read the resolved system + effective state * // Read the resolved system + effective state
* const { system, state } = useColorMode(); * const { system, state } = useColorMode();
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useColorMode<T extends string = BasicColorMode>( export function useColorMode<T extends string = BasicColorMode>(
options: UseColorModeOptions<T> = {}, options: UseColorModeOptions<T> = {},
@@ -42,7 +42,7 @@ export interface UseCssVarReturn extends WritableComputedRef<string | null | und
* @example * @example
* const theme = useCssVar('--theme', null, { initialValue: 'light', observe: true }); * const theme = useCssVar('--theme', null, { initialValue: 'light', observe: true });
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useCssVar( export function useCssVar(
prop: MaybeRefOrGetter<string | null | undefined>, prop: MaybeRefOrGetter<string | null | undefined>,
@@ -58,7 +58,7 @@ export type UseDarkReturn = WritableComputedRef<boolean>;
* const isDark = useDark(); * const isDark = useDark();
* const toggleDark = useToggle(isDark); * const toggleDark = useToggle(isDark);
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useDark(options: UseDarkOptions = {}): UseDarkReturn { export function useDark(options: UseDarkOptions = {}): UseDarkReturn {
const { const {
@@ -109,7 +109,7 @@ export interface UseDocumentPiPReturn {
* pipWindow.value.document.body.append(playerEl); * pipWindow.value.document.body.append(playerEl);
* }); * });
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useDocumentPiP(options: UseDocumentPiPOptions = {}): UseDocumentPiPReturn { export function useDocumentPiP(options: UseDocumentPiPOptions = {}): UseDocumentPiPReturn {
const { const {
@@ -67,7 +67,7 @@ export interface UseEyeDropperReturn {
* if (isSupported.value) * if (isSupported.value)
* await open(); * await open();
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useEyeDropper(options: UseEyeDropperOptions = {}): UseEyeDropperReturn { export function useEyeDropper(options: UseEyeDropperOptions = {}): UseEyeDropperReturn {
const { const {
@@ -44,7 +44,7 @@ const FILE_EXTENSION_RE = /\.([a-z0-9]+)$/i;
* const isDark = useDark(); * const isDark = useDark();
* const favicon = useFavicon(() => isDark.value ? '/dark.png' : '/light.png'); * const favicon = useFavicon(() => isDark.value ? '/dark.png' : '/light.png');
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useFavicon( export function useFavicon(
newIcon: MaybeRefOrGetter<string | null | undefined>, newIcon: MaybeRefOrGetter<string | null | undefined>,
@@ -159,7 +159,7 @@ function toFileList(files: File[] | FileList | undefined): FileList | null {
* const { open } = useFileDialog(); * const { open } = useFileDialog();
* open({ multiple: false, accept: '.pdf' }); * open({ multiple: false, accept: '.pdf' });
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useFileDialog(options: UseFileDialogOptions = {}): UseFileDialogReturn { export function useFileDialog(options: UseFileDialogOptions = {}): UseFileDialogReturn {
const { const {
@@ -186,7 +186,7 @@ export interface UseFileSystemAccessReturn<T = string | ArrayBuffer | Blob> {
* // Read raw bytes * // Read raw bytes
* const { data } = useFileSystemAccess({ dataType: 'ArrayBuffer' }); * const { data } = useFileSystemAccess({ dataType: 'ArrayBuffer' });
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useFileSystemAccess(): UseFileSystemAccessReturn<string | ArrayBuffer | Blob>; export function useFileSystemAccess(): UseFileSystemAccessReturn<string | ArrayBuffer | Blob>;
export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Text' }): UseFileSystemAccessReturn<string>; export function useFileSystemAccess(options: UseFileSystemAccessOptions & { dataType: 'Text' }): UseFileSystemAccessReturn<string>;
@@ -106,7 +106,7 @@ const listenerOptions = { capture: false, passive: true } as const;
* // Fullscreen the whole page * // Fullscreen the whole page
* const { toggle } = useFullscreen(); * const { toggle } = useFullscreen();
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useFullscreen( export function useFullscreen(
target?: MaybeComputedElementRef, target?: MaybeComputedElementRef,
@@ -113,7 +113,7 @@ function loadImage(options: UseImageOptions, ctx: LoadImageContext): Promise<HTM
* const src = ref('/a.png'); * const src = ref('/a.png');
* const { state } = useImage(() => ({ src: src.value, alt: 'photo' })); * const { state } = useImage(() => ({ src: src.value, alt: 'photo' }));
* *
* @since 0.0.14 * @since 0.0.15
*/ */
export function useImage( export function useImage(
options: MaybeRefOrGetter<UseImageOptions>, options: MaybeRefOrGetter<UseImageOptions>,

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