14 Commits

Author SHA1 Message Date
robonen 551b3bd921 fix(writekit): a node selection is a real DOM range, not an absence of one
Publish to NPM / Check version changes and publish (push) Successful in 10m0s
Selecting an atom used to clear every native range and leave the editable
root focused with NO selection. The browser then invents a caret at the
START of the content, `selectionchange` reads it, and the model's node
selection is overwritten by a text caret in the first block — so the
Enter meant to exit the freshly inserted atom split the opening
paragraph, and typing replaced its text. Reproduced live within a minute
of using the slash menu.

- the bridge now writes a node selection as `range.selectNode(blockEl)`;
  the read path maps that range to null, so selectionchange keeps its
  hands off the model
- beforeinput guards the node-selection state: browser edits through the
  element-wrapping range are prevented; delete falls through to
  deleteSelection, insertParagraph to exitAtom
- browser regression test walks the exact race: select atom → DOM range
  exists → selectionchange rewrites nothing → Enter lands a paragraph
  below the atom

writekit 0.0.4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 05:52:54 +07:00
robonen 1d105b1f55 feat(writekit): a way out of atoms, and a slash menu that behaves
Publish to NPM / Check version changes and publish (push) Successful in 9m58s
Two gaps an author hits within the first minute of using atom blocks:

- there was no way to add a paragraph after a non-text block. Enter on a
  selected atom now starts a paragraph below it (exitAtom, chained before
  splitBlock), and a click on the root's padding below a trailing atom
  does the same — ends-in-text just places the caret at the end
- the slash menu ignored its own overflow: keyboard navigation walked the
  highlight out of view (now scrollIntoView nearest), and a background
  wheel scroll tore the menu off its caret anchor (now prevented outside
  the menu; scrolling the list itself stays native)
- BlockMeta gains `description`; the menu shows a detail pane beside the
  list with the highlighted item's description, replaceable wholesale via
  the new #preview slot. Headless stays headless: the pane is unstyled
  text and appears only when there is a description or a slot. Preset
  blocks are all described

Both floating menus (slash, bubble) are re-layered to the combobox
convention: PopperRoot provides the positioning context OUTSIDE a bare
Portal, which resolves its target from the ConfigProvider's
teleportTarget — the previous hardcoded to="body" was overriding the
app's configured target. A Combobox itself is the wrong base here on
purpose: its keyboard lives on ComboboxInput, while a suggestion menu
must leave focus in the contenteditable — the editor is the input.

writekit 0.0.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 05:28:06 +07:00
robonen 66d9faad22 build: bump privitives to 0.0.6 and writekit to 0.0.2
Publish to NPM / Check version changes and publish (push) Successful in 9m53s
2026-08-11 03:45:34 +07:00
robonen edcccf16d8 feat(docs): stop hiding half the component API — typed emits, exposes, flow guide
Publish to NPM / Check version changes and publish (push) Has been cancelled
The per-part regex only saw defineEmits<{ inline literal }>(), so a named
interface — how Flow, Popover, Dialog, Menu and Drawer all declare their
events — extracted as zero emits, and defineExpose was not extracted at
all: 36 components' template-ref surfaces were invisible. Consumers
rebuilt what existed (nodeDragStop from @nodes-change, a renderless
child to reach fitView).

- extractor: one type-checking project per components package with a
  virtual <file>.vue.ts mirror per SFC, so cross-file emits interfaces
  (extends included) and expose spreads resolve through the checker —
  ...api expands into the composable's full return with its JSDoc
- parts gain exposes; emits/exposes members carry their JSDoc text;
  update:* model emits get a stock description
- UI: Description column on emits, an Exposes (template ref) table; MCP
  get_doc renders the same
- FlowRoot: JSDoc on every emit and exposed member
- new primitives guide page: building flow graphs — sizing, custom
  nodes, .nodrag, click family, instance API, edge labels

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:37:23 +07:00
robonen d2838ba8ee fix(primitives): flow renders visibly by default and its declared events fire
- pane fills its parent instead of collapsing to the 0px strip every
  consumer debugged as a data bug; background/viewport/panel get a
  default stacking triple (0/1/2) so chrome no longer paints over nodes
- nodeClick/edgeClick/paneClick were declared in FlowRootEmits but never
  emitted — wired for real; nodeDoubleClick synthesized in the drag
  layer (it already tells clicks from drags), and dblclick-zoom ignores
  [data-flow-node] so opening a node no longer also zooms the canvas
- FlowEdge.label was typed but never rendered — the default edge now
  draws a haloed midpoint label, and label joins the v-memo keys so
  edits are not frozen by the memo
- fitViewOnMount prop fits once nodes AND the pane are measured (either
  can finish first), skipped when the viewport is controlled

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:22:28 +07:00
robonen cc93715c03 fix(writekit): attr coercion stops erasing data, validate runs, undo coalesces
Three fixes driven by building a real consumer (cyrille studio) on 0.0.1, each
pinned by tests:

- `coerceAttrs` treated the spec as a whitelist: any attribute a block
  definition did not declare was silently deleted by the first
  `normalizeDocument` pass — and since normalization runs on load and consumers
  autosave, the erasure wrote itself back to storage. Coercion now fills
  defaults and keeps unknown keys verbatim. Parse rules build attrs explicitly,
  so pasted markup cannot smuggle keys through this path; the CRDT never calls
  coercion, so replica semantics are unchanged.

- `AttrSpec.validate` was consulted only by `validateDocument`, which nothing
  in the library calls — it looked like enforcement and was inert. A provided
  value failing `validate` now falls back to the declared default,
  deterministically (CRDT-safe given one spec) and loudly in dev.

- Undo recorded one entry per transaction — one keystroke per Ctrl+Z, and 200
  keystrokes evicted the entire earlier history. Plain typing in one block now
  coalesces within a 500ms window by concatenation, which preserves the replay
  invariant (`inverted` stays in application order, replayed reversed), counts
  as ONE entry against maxSize, and never merges across blocks, structural
  changes, or a foreign transaction (remote setDoc, undo/redo, selection-only
  moves interrupt the chain). `coalesceMs: 0` opts out.

Also: `component` in a block definition may now be a lazy loader
(`() => import('./Card.vue')`) — a registry imported for its schema (codecs,
tests, server-side normalizers) then carries no view graph; the view wraps the
loader in a cached async component on first render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 03:04:12 +07:00
robonen ea96d720f2 perf(primitives): place the select panel in one layout pass
Publish to NPM / Check version changes and publish (push) Successful in 9m38s
`position()` interleaved geometry reads with inline-style writes: the horizontal
branch wrote `minWidth`/`left` and the vertical branch then read `scrollHeight`,
`getComputedStyle`, `offsetHeight` and `offsetTop`, and a second write of
`bottom` was followed by a read of `clientHeight`. Each read after a write
forces a synchronous layout, so a single placement cost at least two — and
placement runs on mount, on resize and while scrolling an expanding panel.

Split it into measure, compute and commit: every read now happens before the
first write, and the result is applied as one object rather than nine separate
property assignments.

The commit also always writes both edges of each axis. A resize can flip the
vertical branch from `bottom` to `top`, and the previous code left the old edge
in place, over-constraining the box.

No behavioural change — the arithmetic is untouched, and the placement tests
pass unchanged.
2026-08-10 04:37:43 +07:00
robonen 6da5ecaa83 fix(primitives): place the select panel when nothing is selected
Publish to NPM / Check version changes and publish (push) Has been cancelled
Item-aligned placement centres the panel on the selected item, and `position()`
returned early unless both the item and its text node were known. The content
already adopts the first valid item as the anchor when the model matches no
option, but only the item is registered — the text node registers solely for the
selected value, so the pair was never complete and the early return fired. The
wrapper is `position: fixed`, so it stayed at the viewport origin: to a user the
dropdown simply does not open.

This is not an edge case. A stale id, a deleted record or a directory that has
not finished loading all leave the model unmatched, and the whole select then
looks broken rather than merely unlabelled.

Recover the text node from the item's own `aria-labelledby` instead of adding a
second registration — writing the anchor refs from inside the item's tracking
effect closes a reactive cycle ("Maximum recursive updates exceeded"). Reset the
text ref alongside the item ref per open cycle so a stale node cannot pair with
a fresh item. Finally, keep the guard from ever stranding the panel again: with
no anchors at all — an empty option list — fall back to a plain trigger-aligned
drop instead of returning with the wrapper unplaced.

Both paths are covered by browser tests that fail on the previous code.
2026-08-10 04:30:24 +07:00
robonen 1d2130f279 fix(primitives): make v-model, native attributes and panel styling usable under strict TS
Publish to NPM / Check version changes and publish (push) Successful in 11m33s
Consuming the package under `verbatimModuleSyntax` + `strictTemplates` surfaced
four defects that forced workarounds downstream.

- Drop the deprecated `SelectValue` string alias. It collided with the
  `SelectValue` component exported from the same barrel, so the component
  resolved to the type meaning and could not be imported (TS1484).

- Narrow the select's model to `SelectModelValue<T, Multiple>` and make
  `TabsRoot` generic over its value, so a plain `v-model` on a `Ref<string>`
  type-checks. Both roots declare the value prop and emit explicitly instead of
  via `defineModel`, which would widen the payload with `| undefined` even
  though neither control ever clears its value.

- Stop declaring `defineModel` keys in `defineEmits` as well. The duplicate
  erased the payload type from the generated declarations, shipping
  `(...args: unknown[]) => any` for eleven components' model events.

- Let every part accept global DOM attributes (`id`, `role`, `aria-*`,
  `data-*`, ...) through `PrimitiveAttributes`. The heritage clause is marked
  `@vue-ignore`, so they stay out of the runtime props and keep falling through
  via `$attrs` exactly as before.

- Give `SelectContent` and `SelectViewport` a single styleable root: the
  content forwards `$attrs` onto the panel, and the viewport's scrollbar CSS
  moves to a reference-counted `<head>` style tag. A forwarded `class` was
  previously dropped, leaving the panel unstyled.

The tsconfig vue preset gains `htmlAttributes: ["aria-*", "data-*"]` so
hyphenated data attributes are not camelized before they reach those types.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:14:44 +07:00
robonen e59e05ac05 chore: bump version to 0.0.2 in jsr.json and package.json
Publish to NPM / Check version changes and publish (push) Successful in 9m40s
2026-08-03 21:53:43 +07:00
robonen 85313c6046 feat: update useSnapPoints to improve drawer snapping behavior and add new features
Publish to NPM / Check version changes and publish (push) Successful in 11m14s
2026-08-03 21:12:35 +07:00
Renovate Bot f444feb7b3 chore(deps): update actions/setup-node action to v7
Publish to NPM / Check version changes and publish (push) Successful in 9m25s
2026-07-30 22:26:13 +00:00
robonen 38c2cd5504 feat: enhance npm publish workflow with improved authentication and concurrency handling
Publish to NPM / Check version changes and publish (push) Successful in 11m13s
2026-07-31 04:54:09 +07:00
robonen c6b6b93e81 chore: update dependencies and package manager versions across projects
Publish to NPM / Check version changes and publish (push) Failing after 10m54s
2026-07-31 04:21:32 +07:00
123 changed files with 8548 additions and 3809 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
with:
run_install: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
+42 -4
View File
@@ -5,6 +5,12 @@ on:
branches:
- 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:
NODE_VERSION: 24.x
@@ -22,11 +28,41 @@ jobs:
with:
run_install: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
registry-url: 'https://registry.npmjs.org'
# No `registry-url:` on purpose. It writes an npmrc holding the literal
# string `${NODE_AUTH_TOKEN}` and points NPM_CONFIG_USERCONFIG at it,
# which would shadow the file the next step writes. We supply the token
# verbatim instead, so no variable-expansion rules can apply.
# npm masks an unauthorized write as `404 Not Found` rather than 401, so a
# dead token surfaces as "package does not exist" three steps later, after
# a full build+test. Verify the credential up front and say so plainly.
- name: Authenticate to npm
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
if [ -z "${NPM_TOKEN:-}" ]; then
echo "::error::secrets.NPM_TOKEN is empty or not set for this repo."
echo "::error::Add it under Gitea > Settings > Actions > Secrets."
exit 1
fi
printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" > "$HOME/.npmrc"
chmod 600 "$HOME/.npmrc"
if ! WHO=$(npm whoami --registry=https://registry.npmjs.org 2>&1); then
echo "::error::npm rejected the token — expired, revoked, or wrong account."
echo "::error::npm said: ${WHO}"
echo "::error::Mint a granular token with read+write on the @robonen scope"
echo "::error::at npmjs.com and update the Gitea secret."
exit 1
fi
echo "Authenticated to npm as: ${WHO}"
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -38,8 +74,6 @@ jobs:
run: pnpm build && pnpm test
- name: Check for version changes and publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
# Find all package.json files (excluding node_modules)
PACKAGE_FILES=$(find . -path "*/package.json" -not -path "*/node_modules/*")
@@ -79,3 +113,7 @@ jobs:
echo "No version change detected for $PACKAGE_NAME"
fi
done
- name: Scrub credentials
if: always()
run: rm -f "$HOME/.npmrc"
+10 -10
View File
@@ -17,7 +17,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "configs/eslint"
},
"packageManager": "pnpm@11.7.0",
"packageManager": "pnpm@11.18.0",
"engines": {
"node": ">=24.16.0"
},
@@ -48,15 +48,15 @@
"dependencies": {
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "catalog:",
"@vitest/eslint-plugin": "^1.6.20",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-n": "^18.1.0",
"eslint-plugin-regexp": "^3.1.0",
"eslint-plugin-unicorn": "^67.0.0",
"eslint-plugin-vue": "^10.9.2",
"globals": "^17.6.0",
"@vitest/eslint-plugin": "^1.6.24",
"eslint-plugin-import-x": "^4.17.1",
"eslint-plugin-n": "^18.2.2",
"eslint-plugin-regexp": "^3.1.1",
"eslint-plugin-unicorn": "^72.0.0",
"eslint-plugin-vue": "^10.10.0",
"globals": "^17.8.0",
"jiti": "^2.7.0",
"typescript-eslint": "^8.61.1",
"typescript-eslint": "^8.65.0",
"vue-eslint-parser": "^10.4.1"
},
"devDependencies": {
@@ -67,7 +67,7 @@
"tsdown": "catalog:"
},
"peerDependencies": {
"eslint": ">=10.8.0"
"eslint": ">=9.39.4"
},
"publishConfig": {
"access": "public"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@robonen/tsconfig",
"version": "0.1.0",
"version": "0.1.1",
"license": "Apache-2.0",
"description": "Base typescript configuration for projects",
"keywords": [
@@ -15,7 +15,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "packages/tsconfig"
},
"packageManager": "pnpm@11.7.0",
"packageManager": "pnpm@11.18.0",
"engines": {
"node": ">=24.16.0"
},
+1
View File
@@ -8,6 +8,7 @@
"vueCompilerOptions": {
"strictTemplates": true,
"fallthroughAttributes": true,
"htmlAttributes": ["aria-*", "data-*"],
"inferTemplateDollarAttrs": true,
"inferTemplateDollarEl": true,
"inferTemplateDollarRefs": true
+1 -1
View File
@@ -15,7 +15,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "configs/tsdown"
},
"packageManager": "pnpm@11.7.0",
"packageManager": "pnpm@11.18.0",
"engines": {
"node": ">=24.16.0"
},
+1 -1
View File
@@ -17,7 +17,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "core/crdt"
},
"packageManager": "pnpm@11.7.0",
"packageManager": "pnpm@11.18.0",
"engines": {
"node": ">=24.16.0"
},
+1 -1
View File
@@ -13,7 +13,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "core/encoding"
},
"packageManager": "pnpm@11.7.0",
"packageManager": "pnpm@11.18.0",
"engines": {
"node": ">=24.16.0"
},
+1 -1
View File
@@ -15,7 +15,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "core/fetch"
},
"packageManager": "pnpm@11.7.0",
"packageManager": "pnpm@11.18.0",
"engines": {
"node": ">=24.16.0"
},
+1 -1
View File
@@ -18,7 +18,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "packages/platform"
},
"packageManager": "pnpm@11.7.0",
"packageManager": "pnpm@11.18.0",
"engines": {
"node": ">=24.16.0"
},
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@robonen/stdlib",
"version": "0.0.11",
"version": "0.0.12",
"license": "Apache-2.0",
"description": "A collection of tools, utilities, and helpers for TypeScript",
"keywords": [
@@ -18,7 +18,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "packages/stdlib"
},
"packageManager": "pnpm@11.7.0",
"packageManager": "pnpm@11.18.0",
"engines": {
"node": ">=24.16.0"
},
@@ -52,6 +52,6 @@
"@robonen/tsdown": "workspace:*",
"eslint": "catalog:",
"tsdown": "catalog:",
"typescript": "^6.0.3"
"typescript": "catalog:"
}
}
@@ -21,4 +21,28 @@ describe('createMachine', () => {
it('send returns the (typed) resulting state', () => {
expectTypeOf(machine.send('START')).toEqualTypeOf<'idle' | 'running'>();
});
it('empty terminal nodes do not widen the event union to string', () => {
const terminal = createMachine({
initial: 'idle',
states: {
idle: { on: { START: 'done' } },
done: {},
},
});
expectTypeOf(terminal.send).parameter(0).toEqualTypeOf<'START'>();
});
it('entry/exit-only nodes do not widen the event union either', () => {
const hooked = createMachine({
initial: 'idle',
states: {
idle: { on: { START: 'done' } },
done: { entry: () => {} },
},
});
expectTypeOf(hooked.send).parameter(0).toEqualTypeOf<'START'>();
});
});
@@ -418,6 +418,7 @@ describe('asyncStateMachine', () => {
},
});
// @ts-expect-error -- deliberately undeclared event: runtime must ignore it
const result = await machine.send('STOP');
expect(result).toBe('idle');
@@ -597,6 +598,7 @@ describe('asyncStateMachine', () => {
},
});
// @ts-expect-error -- deliberately undeclared event: can() must report false
expect(await machine.can('STOP')).toBe(false);
});
@@ -57,8 +57,12 @@ export type AsyncStateNodeConfig<Context> = StateNodeConfig<Context, MaybePromis
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> = {
[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
: never;
}[keyof T];
+6 -1
View File
@@ -89,7 +89,12 @@ const roleColor: Record<string, string> = {
<DocsEmitsTable :emits="part.emits" />
</div>
<p v-if="part.props.length === 0 && part.emits.length === 0" class="text-sm text-fg-subtle italic">
<div v-if="part.exposes?.length" class="mb-3">
<div class="text-[11px] font-semibold uppercase tracking-wider text-fg-subtle mb-2">Exposes (template ref)</div>
<DocsExposesTable :exposes="part.exposes" />
</div>
<p v-if="part.props.length === 0 && part.emits.length === 0 && !part.exposes?.length" class="text-sm text-fg-subtle italic">
No props or events renders its element and forwards attributes.
</p>
</div>
+5
View File
@@ -12,6 +12,7 @@ defineProps<{
<tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Event</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Payload</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
</tr>
</thead>
<tbody>
@@ -22,6 +23,10 @@ defineProps<{
<td class="py-2.5 px-4">
<code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ e.payload }}</code>
</td>
<td class="py-2.5 px-4 text-fg-muted min-w-48">
<DocsText v-if="e.description" :text="e.description" />
<span v-else></span>
</td>
</tr>
</tbody>
</table>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">import type { PropertyMeta } from '../../modules/extractor/types';
defineProps<{
exposes: PropertyMeta[];
}>();
</script>
<template>
<div v-if="exposes.length > 0" class="overflow-x-auto rounded-xl border border-border">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Name</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Type</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
</tr>
</thead>
<tbody>
<tr v-for="x in exposes" :key="x.name" class="border-t border-border align-top">
<td class="py-2.5 px-4 whitespace-nowrap">
<code class="text-accent-text font-mono text-[13px] font-medium">{{ x.name }}</code><span v-if="x.optional" class="text-fg-subtle text-xs">?</span>
</td>
<td class="py-2.5 px-4">
<code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ x.type }}</code>
</td>
<td class="py-2.5 px-4 text-fg-muted min-w-48">
<DocsText v-if="x.description" :text="x.description" />
<span v-else></span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
+152 -6
View File
@@ -12,7 +12,7 @@
import { basename, dirname, relative, resolve } from 'node:path';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { Node, Project, SyntaxKind } from 'ts-morph';
import { Node, Project, SyntaxKind, ts } from 'ts-morph';
import type { ClassDeclaration, FunctionDeclaration, InterfaceDeclaration, JSDoc, JSDocTag, MethodDeclaration, PropertyDeclaration, PropertySignature, SourceFile, TypeAliasDeclaration, VariableDeclaration } from 'ts-morph';
import type {
CategoryMeta,
@@ -858,6 +858,144 @@ function extractScriptBlock(sfc: string, setup: boolean): string {
return '';
}
// ── SFC type project ─────────────────────────────────────────────────────────
/**
* One type-checking project per components package: every real `src/**\/*.ts`
* file plus, for each SFC part, a virtual `<file>.vue.ts` mirror holding its
* two script blocks. TS resolves a `./X.vue` specifier by appending `.ts`, so
* the mirrors make cross-file shapes resolve for real — `defineEmits<XEmits>()`
* where the interface lives in another block, a sibling `.ts` or another SFC,
* and `defineExpose({ ...api })` where the spread's type is a composable's
* return. The per-part regexes never saw any of those, which is exactly how
* half of Flow's API ended up invisible in the docs.
*/
function buildSfcProject(pkgDir: string): Project {
const srcDir = resolve(pkgDir, 'src');
const tsconfigPath = resolve(pkgDir, 'tsconfig.json');
const project = new Project({
tsConfigFilePath: existsSync(tsconfigPath) ? tsconfigPath : undefined,
skipAddingFilesFromTsConfig: true,
});
project.addSourceFilesAtPaths([`${srcDir}/**/*.ts`, `!${srcDir}/**/__test__/**`]);
for (const entry of readdirSync(srcDir, { recursive: true, withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.vue') || entry.name === 'demo.vue') continue;
const full = resolve(entry.parentPath, entry.name);
if (full.includes('__test__')) continue;
const sfc = readFileSync(full, 'utf-8');
const script = `${extractScriptBlock(sfc, false)}\n${extractScriptBlock(sfc, true)}`;
if (script.trim()) project.createSourceFile(`${full}.ts`, script, { overwrite: true });
}
return project;
}
/** Type display: keep alias names (`Ref<T>`, not its expansion), never truncate. */
const TYPE_TEXT_FLAGS = ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | ts.TypeFormatFlags.NoTruncation;
/** JSDoc description of a declaration; a const's doc sits on its statement. */
function describeDecl(node: Node | undefined): string {
if (!node) return '';
const holder = Node.isVariableDeclaration(node) ? node.getVariableStatement() ?? node : node;
if (!Node.isJSDocable(holder)) return '';
const jsdocs = holder.getJsDocs();
return getDescription(jsdocs, getJsDocTags(jsdocs));
}
/**
* Emits through the checker's view of `defineEmits<T>()`: the inline literal
* AND a named interface (same block, sibling `.ts`, another SFC via the
* mirrors), `extends` chains included — with each member's JSDoc.
*/
function extractEmitsFrom(sf: SourceFile): EmitMeta[] {
const call = sf.getDescendantsOfKind(SyntaxKind.CallExpression)
.find(c => c.getExpression().getText() === 'defineEmits');
const typeArg = call?.getTypeArguments()[0];
if (!call || !typeArg) return [];
const emits: EmitMeta[] = [];
for (const prop of typeArg.getType().getProperties()) {
const decl = prop.getDeclarations()[0];
const written = decl && Node.isPropertySignature(decl) ? decl.getTypeNode()?.getText() : undefined;
emits.push({
name: prop.getName(),
payload: cleanType(written ?? prop.getTypeAtLocation(call).getText(call, TYPE_TEXT_FLAGS)),
description: describeDecl(decl),
});
}
return emits;
}
/**
* `defineExpose({ … })` → the template-ref surface. Spreads expand through the
* checker (`...api` lists every member of the composable's return type with its
* JSDoc), so the docs show the full instance API instead of nothing at all.
*/
function extractExposesFrom(sf: SourceFile): PropertyMeta[] {
const call = sf.getDescendantsOfKind(SyntaxKind.CallExpression)
.find(c => c.getExpression().getText() === 'defineExpose');
const arg = call?.getArguments()[0];
if (!arg || !Node.isObjectLiteralExpression(arg)) return [];
const out: PropertyMeta[] = [];
const push = (name: string, type: string, description: string, optional = false) => {
if (!out.some(p => p.name === name))
out.push({ name, type: cleanType(type), description, optional, defaultValue: null, readonly: false });
};
for (const member of arg.getProperties()) {
if (Node.isSpreadAssignment(member)) {
const spreadType = member.getExpression().getType();
const props = spreadType.getProperties();
// Unresolvable spread — surface it verbatim rather than dropping it.
if (spreadType.isAny() || props.length === 0) {
push(member.getText(), '', '');
continue;
}
for (const prop of props) {
const decl = prop.getDeclarations()[0];
const written = decl && Node.isPropertySignature(decl) ? decl.getTypeNode()?.getText() : undefined;
push(
prop.getName(),
written ?? prop.getTypeAtLocation(member).getText(member, TYPE_TEXT_FLAGS),
describeDecl(decl),
decl !== undefined && Node.isQuestionTokenable(decl) && decl.hasQuestionToken(),
);
}
}
else if (Node.isShorthandPropertyAssignment(member)) {
const local = sf.getProject().getTypeChecker().getShorthandAssignmentValueSymbol(member);
push(
member.getName(),
member.getType().getText(member, TYPE_TEXT_FLAGS),
describeDecl(local?.getDeclarations()[0]),
);
}
else if (Node.isPropertyAssignment(member)) {
const init = member.getInitializer();
const initDecl = init && Node.isIdentifier(init) ? init.getSymbol()?.getDeclarations()[0] : undefined;
push(
member.getName().replaceAll(/^['"]|['"]$/g, ''),
(init ?? member).getType().getText(member, TYPE_TEXT_FLAGS),
describeDecl(member) || describeDecl(initDecl),
);
}
else if (Node.isMethodDeclaration(member)) {
push(member.getName(), member.getType().getText(member, TYPE_TEXT_FLAGS), describeDecl(member));
}
}
return out;
}
/** Parse `defineEmits<{ 'a': [x: T]; b: [] }>()` from a setup block. */
function extractEmits(setupScript: string): EmitMeta[] {
const m = setupScript.match(/defineEmits<\{([\s\S]*?)\}>\s*\(\s*\)/);
@@ -907,7 +1045,7 @@ function extractModels(setupScript: string): { props: PropertyMeta[]; emits: Emi
defaultValue: null,
readonly: false,
});
emits.push({ name: `update:${name}`, payload: `[value: ${type}]`, description: '' });
emits.push({ name: `update:${name}`, payload: `[value: ${type}]`, description: `Emitted when \`v-model${name === 'modelValue' ? '' : `:${name}`}\` updates.` });
}
return { props, emits };
@@ -968,7 +1106,7 @@ function roleFromName(componentName: string, base: string): string {
* not a component group (no `.vue`). `category` is the display label; `entryPoint`
* is the package subpath (e.g. `./forms/checkbox`).
*/
function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string): ComponentMeta | null {
function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string, sfcProject?: Project): ComponentMeta | null {
// A component group is any dir that ships at least one .vue file.
const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue'));
if (vueFiles.length === 0) return null;
@@ -1001,16 +1139,22 @@ function buildComponentAt(dir: string, slug: string, category: string, entryPoin
const role = roleFromName(name, base);
if (role === 'Root' && description && !groupDescription) groupDescription = description;
// Emits/exposes come from the typed SFC project when it has this part;
// the regex parser stays as the fallback for inline-literal emits.
const virtual = sfcProject?.getSourceFile(`${resolve(dir, file)}.ts`);
let emits = virtual ? extractEmitsFrom(virtual) : [];
if (emits.length === 0) emits = extractEmits(setup);
const exposes = virtual ? extractExposesFrom(virtual) : [];
// Merge in `defineModel` v-model props/emits (invisible to the interface/
// defineEmits parsers), de-duping against any explicitly-declared ones.
const models = extractModels(setup);
const emits = extractEmits(setup);
for (const mp of models.props)
if (!props.some(p => p.name === mp.name)) props.push(mp);
for (const me of models.emits)
if (!emits.some(e => e.name === me.name)) emits.push(me);
parts.push({ name, role, description, props, emits });
parts.push({ name, role, description, props, emits, exposes });
}
return {
@@ -1030,6 +1174,7 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
const srcDir = resolve(pkgDir, 'src');
if (!existsSync(srcDir)) return [];
const sfcProject = buildSfcProject(pkgDir);
const components: ComponentMeta[] = [];
// Components live one level deep, in category folders: src/<category>/<component>/.
@@ -1048,13 +1193,14 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
compEntry.name,
label,
`./${catEntry.name}/${compEntry.name}`,
sfcProject,
);
if (c) components.push(c);
}
}
else {
// Backward-compat: a flat component dir directly under src.
const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`);
const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`, sfcProject);
if (c) components.push(c);
}
}
+5
View File
@@ -142,6 +142,11 @@ export interface ComponentPartMeta {
props: PropertyMeta[];
/** Emitted events parsed from `defineEmits` */
emits: EmitMeta[];
/**
* The template-ref surface parsed from `defineExpose`, spreads expanded
* through the type checker (`...api` lists the composable's whole return).
*/
exposes?: PropertyMeta[];
}
export interface EmitMeta {
+5
View File
@@ -226,6 +226,11 @@ function renderComponentPart(part: ComponentPartMeta): string[] {
const rows = part.emits.map(e => [cell(e.name), cell(`\`${e.payload}\``), cell(e.description)]);
out.push('#### Emits', '', table(['Event', 'Payload', 'Description'], rows), '');
}
if (part.exposes && part.exposes.length > 0) {
const rows = part.exposes.map(x => [cell(x.name), cell(`\`${x.type}\``), cell(x.description)]);
out.push('#### Exposes (template ref)', '', table(['Name', 'Type', 'Description'], rows), '');
}
return out;
}
+7 -7
View File
@@ -17,22 +17,22 @@
"extract": "jiti ./modules/extractor/extract.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"marked": "^18.0.5",
"shiki": "^4.2.0",
"@modelcontextprotocol/sdk": "^1.30.0",
"marked": "^18.0.7",
"shiki": "^4.3.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@nuxt/fonts": "^0.14.0",
"@nuxt/kit": "^4.4.8",
"@nuxt/kit": "^4.5.1",
"@robonen/eslint": "workspace:*",
"@tailwindcss/vite": "^4.3.1",
"@tailwindcss/vite": "^4.3.3",
"eslint": "catalog:",
"jiti": "^2.7.0",
"nuxt": "catalog:",
"tailwindcss": "^4.3.1",
"tailwindcss": "^4.3.3",
"ts-morph": "^28.0.0",
"vue": "catalog:",
"vue-router": "^5.1.0"
"vue-router": "^5.2.0"
}
}
+2 -2
View File
@@ -16,7 +16,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "packages/renovate"
},
"packageManager": "pnpm@11.7.0",
"packageManager": "pnpm@11.18.0",
"engines": {
"node": ">=24.16.0"
},
@@ -27,6 +27,6 @@
"test": "renovate-config-validator ./default.json"
},
"devDependencies": {
"renovate": "^43.228.0"
"renovate": "^44.2.1"
}
}
+3 -3
View File
@@ -15,20 +15,20 @@
"type": "git",
"url": "git+https://github.com/robonen/tools.git"
},
"packageManager": "pnpm@11.7.0",
"packageManager": "pnpm@11.18.0",
"engines": {
"node": ">=24.16.0"
},
"type": "module",
"devDependencies": {
"@types/node": "^25.9.3",
"@types/node": "^26.1.2",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
"citty": "^0.2.2",
"jiti": "^2.7.0",
"jsdom": "catalog:",
"scule": "^1.3.0",
"typescript": "^6.0.3",
"typescript": "catalog:",
"vitest": "catalog:"
},
"scripts": {
+3296 -3043
View File
File diff suppressed because it is too large Load Diff
+23 -10
View File
@@ -16,17 +16,30 @@ allowBuilds:
catalog:
'@stylistic/eslint-plugin': ^5.10.0
'@vitest/browser': ^4.1.9
'@vitest/coverage-v8': ^4.1.9
'@vitest/ui': ^4.1.9
'@vue/shared': ^3.5.38
'@vitest/browser': ^4.1.10
'@vitest/coverage-v8': ^4.1.10
'@vitest/ui': ^4.1.10
'@vue/shared': ^3.5.40
'@vue/test-utils': ^2.4.11
eslint: ^10.5.0
jsdom: ^29.1.1
nuxt: ^4.4.8
tsdown: ^0.22.3
vitest: ^4.1.9
vue: ^3.5.38
eslint: ^10.8.0
jsdom: ^30.0.1
nuxt: ^4.5.1
tsdown: ^0.22.14
typescript: npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2
vitest: ^4.1.10
vue: ^3.5.40
# TypeScript 7.0 dropped the classic JS compiler API (`require('typescript')`
# exports only `version`/`versionMajorMinor`), which breaks every Volar/tsc-API
# consumer we depend on: `@vue/compiler-sfc` type resolution (needs `ts.sys`),
# `rolldown-plugin-dts`'s Vue language (needs `ts.ScriptKind`) and `vue-tsc`.
# `typescript-native-bridge` keeps that API surface while type-checking on the
# native tsgo 7.0.2 engine, so we get the new compiler without the breakage.
# Overridden (not just cataloged) because the consumers above resolve
# `typescript` as a transitive peer, not as one of our direct dependencies.
# The version must be pinned exactly — caret ranges never match prereleases.
overrides:
typescript: npm:typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2
ignoredBuiltDependencies:
- '@parcel/watcher'
+191
View File
@@ -0,0 +1,191 @@
<!-- title: Building flow graphs -->
<script setup lang="ts">
// Prose + snippets only — static content, prerenders cleanly.
const minimal = `<script setup lang="ts">
import { FlowBackground, FlowControls, FlowRoot } from '@robonen/primitives';
import type { FlowEdge, FlowNode } from '@robonen/primitives';
const nodes: FlowNode[] = [
{ id: 'a', position: { x: 0, y: 0 }, data: { label: 'Start' } },
{ id: 'b', position: { x: 260, y: 120 }, data: { label: 'Finish' } },
];
const edges: FlowEdge[] = [
{ id: 'a-b', source: 'a', target: 'b', label: 'then' },
];
<\/script>
<template>
<!-- The pane fills this element — give it a real height. -->
<div style="height: 480px">
<FlowRoot :default-nodes="nodes" :default-edges="edges" fit-view-on-mount>
<template #node-default="{ node }">
<div class="card">{{ node.data.label }}</div>
</template>
<FlowBackground />
<FlowControls />
</FlowRoot>
</div>
</template>`;
const customNode = `<!-- Register per-type renderers via nodeTypes (module-level map)… -->
<FlowRoot :node-types="{ scene: SceneNode }" … />
<!-- …or inline via a #node-<type> scoped slot: -->
<FlowRoot :default-nodes="nodes">
<template #node-scene="{ node, selected }">
<article :data-selected="selected" class="scene">
<h4>{{ node.data.title }}</h4>
<!-- One SOURCE handle per row: anchor it to the row, not the side's
midpoint — same-position handles of one type otherwise overlap. -->
<div v-for="option in node.data.options" :key="option.id" class="row">
{{ option.label }}
<FlowHandle
:id="'opt:' + option.id"
type="source"
position="right"
class="row-port"
/>
</div>
</article>
</template>
</FlowRoot>`;
const nodrag = `<!-- Form controls inside a node already win over dragging:
input, textarea, select, button, [contenteditable], [data-handleid]
start no drag. Everything else opts out with the .nodrag class: -->
<template #node-scene="{ node }">
<div class="scene">
<button @click="open(node.id)">Edit</button> <!-- just works -->
<div class="nodrag">
<MyColorWheel /> <!-- opted out -->
</div>
</div>
</template>`;
const events = `<FlowRoot
:default-nodes="nodes"
@node-click="(id) => select(id)"
@node-double-click="(id) => openEditor(id)"
@node-drag-stop="(ids) => persistPositions(ids)"
@pane-click="clearInspector()"
@edge-click="(id) => selectEdge(id)"
/>`;
const instance = `<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { FlowRoot } from '@robonen/primitives';
const flow = useTemplateRef('flow');
function frameSelection(ids: string[]) {
flow.value?.fitView({ padding: 0.2, nodes: ids });
}
function addAtCursor(event: MouseEvent) {
const position = flow.value!.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// …push a node at \`position\`
}
<\/script>
<template>
<FlowRoot ref="flow" :default-nodes="nodes" fit-view-on-mount />
</template>`;
</script>
<template>
<div class="docs-section">
<div class="prose-docs">
<h1>Building flow graphs</h1>
<p>
<code>Flow</code> is a headless node-and-edge canvas: panning, zooming,
dragging, connecting, selection and virtualization are handled for you;
every pixel of a node is yours. This guide covers the contracts that are
easy to miss: sizing, custom nodes, drag opt-out, events and the
imperative API.
</p>
<h2>A minimal graph</h2>
<p>
The pane fills its nearest sized ancestor the graph lives in
absolutely-positioned layers, so the <em>host</em> element must have a
real height. <code>fit-view-on-mount</code> frames the graph once nodes
are measured; it is skipped when you control the viewport yourself
(<code>v-model:viewport</code> / <code>defaultViewport</code>).
</p>
</div>
<DocsCode :code="minimal" lang="vue" />
<div class="prose-docs">
<h2>Custom nodes</h2>
<p>
Nodes render through a component map (<code>nodeTypes</code>, keyed by
<code>node.type</code>) or a <code>#node-&lt;type&gt;</code> scoped
slot. The slot receives the internal node (<code>node.data</code> is
yours) and its <code>selected</code> state. Place
<code>FlowHandle</code>s anywhere inside give repeated same-side
handles their own anchors, since handles of one type default to the
side's midpoint and would overlap.
</p>
</div>
<DocsCode :code="customNode" lang="vue" />
<div class="prose-docs">
<h2>Interactive content and <code>.nodrag</code></h2>
<p>
The drag layer owns <code>pointerdown</code> on the node. Native form
controls (<code>input</code>, <code>textarea</code>, <code>select</code>,
<code>button</code>), <code>[contenteditable]</code> elements and
handles are excluded automatically; any other interactive element opts
out of dragging with the <code>.nodrag</code> class.
</p>
</div>
<DocsCode :code="nodrag" lang="vue" />
<div class="prose-docs">
<h2>Click, double-click, drag</h2>
<p>
The drag layer distinguishes a settled click from a drag, so
<code>@node-click</code> never fires after a real move, and
<code>@node-double-click</code> pairs two settled clicks double-click
on a node does <em>not</em> zoom the canvas. Positions are persisted
from <code>@node-drag-stop</code>, which reports every node that moved.
</p>
</div>
<DocsCode :code="events" lang="vue" />
<div class="prose-docs">
<h2>The instance API</h2>
<p>
<code>FlowRoot</code> exposes its whole imperative surface through the
template ref <code>fitView</code>, zooming, viewport get/set,
coordinate conversion (<code>screenToFlowPosition</code> /
<code>flowToScreenPosition</code>), node/edge lookups and selection
control. The full list is on the <code>Flow</code> component page under
<em>Exposes</em>.
</p>
</div>
<DocsCode :code="instance" lang="vue" />
<div class="prose-docs">
<h2>Edge labels</h2>
<p>
An edge with a <code>label</code> renders it at the path midpoint as
<code>[data-flow-edge-label]</code>, haloed with
<code>--flow-edge-label-halo</code> (defaults to white) so it stays
readable over the wire. For richer labels, take over the edge with
<code>edgeTypes</code> or an <code>#edge-&lt;type&gt;</code> slot.
</p>
</div>
</div>
</template>
+1 -1
View File
@@ -2,6 +2,6 @@
"$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@robonen/primitives",
"license": "Apache-2.0",
"version": "0.0.1",
"version": "0.0.6",
"exports": "./src/index.ts"
}
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@robonen/primitives",
"version": "0.0.1",
"version": "0.0.6",
"license": "Apache-2.0",
"description": "Collection of UI primitives",
"keywords": [
@@ -15,7 +15,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "vue/primitives"
},
"packageManager": "pnpm@11.7.0",
"packageManager": "pnpm@11.18.0",
"engines": {
"node": ">=24.16.0"
},
@@ -59,19 +59,19 @@
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"@vitest/browser": "catalog:",
"@vitest/browser-playwright": "^4.1.9",
"@vitest/browser-playwright": "^4.1.10",
"@vue/test-utils": "catalog:",
"axe-core": "^4.12.1",
"eslint": "catalog:",
"playwright": "^1.61.0",
"playwright": "^1.62.0",
"tsdown": "catalog:",
"unplugin-vue": "^7.2.0",
"vitest-browser-vue": "^2.1.0",
"vue": "catalog:",
"vue-tsc": "^3.3.5"
"vue-tsc": "^3.3.8"
},
"dependencies": {
"@floating-ui/vue": "^2.0.0",
"@floating-ui/vue": "^2.0.1",
"@robonen/encoding": "workspace:*",
"@robonen/platform": "workspace:*",
"@robonen/stdlib": "workspace:*",
+6 -6
View File
@@ -13,14 +13,14 @@
"dependencies": {
"@robonen/primitives": "workspace:*",
"vue": "catalog:",
"vue-router": "^5.1.0"
"vue-router": "^5.2.0"
},
"devDependencies": {
"@robonen/tsconfig": "workspace:*",
"@tailwindcss/vite": "^4.3.1",
"@vitejs/plugin-vue": "^6.0.7",
"tailwindcss": "^4.3.1",
"vite": "^8.0.16",
"vue-tsc": "^3.3.5"
"@tailwindcss/vite": "^4.3.3",
"@vitejs/plugin-vue": "^6.0.8",
"tailwindcss": "^4.3.3",
"vite": "^8.1.5",
"vue-tsc": "^3.3.8"
}
}
@@ -54,7 +54,7 @@ const linePath = computed(() => {
<svg
data-flow-background=""
:data-variant="variant"
:style="{ position: 'absolute', inset: '0', width: '100%', height: '100%', pointerEvents: 'none', color }"
:style="{ position: 'absolute', inset: '0', width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0, color }"
>
<pattern
:id="patternId"
+17 -1
View File
@@ -134,13 +134,14 @@ function onPointerdown(event: PointerEvent): void {
if (event.button !== 0 || edge.value?.selectable === false || !ctx.elementsSelectable.value) return;
event.stopPropagation();
ctx.selectEdge(id, event.shiftKey || event.metaKey || event.ctrlKey);
ctx.emitEdgeClick(id, event);
}
</script>
<template>
<g
v-if="endpoints"
v-memo="[path[0], selected, edge?.animated, edge?.selectable, edge?.data, markerStartRef, markerEndRef]"
v-memo="[path[0], selected, edge?.animated, edge?.selectable, edge?.data, edge?.label, markerStartRef, markerEndRef]"
data-flow-edge=""
:data-id="id"
:data-type="resolvedType"
@@ -176,6 +177,21 @@ function onPointerdown(event: PointerEvent): void {
:style="interactionPathStyle"
@pointerdown="onPointerdown"
/>
<!-- The halo (paint-order + stroke) keeps the text legible over the
path and the background without the consumer styling anything. -->
<text
v-if="edge?.label"
data-flow-edge-label=""
:x="path[1]"
:y="path[2]"
text-anchor="middle"
dominant-baseline="middle"
fill="currentColor"
stroke="var(--flow-edge-label-halo, white)"
stroke-width="3"
paint-order="stroke"
:style="{ pointerEvents: 'none', fontSize: '12px' }"
>{{ edge.label }}</text>
</template>
</g>
</template>
+13 -2
View File
@@ -65,8 +65,10 @@ useKeyboard(currentElement, ctx, useViewportApi(ctx));
useEventListener(currentElement, 'click', (event: MouseEvent) => {
const target = event.target as Element | null;
if (target && !target.closest('[data-flow-node],[data-flow-edge]'))
if (target && !target.closest('[data-flow-node],[data-flow-edge]')) {
ctx.clearSelection();
ctx.emitPaneClick(event as PointerEvent);
}
});
</script>
@@ -79,7 +81,16 @@ useEventListener(currentElement, 'click', (event: MouseEvent) => {
:data-interactive="ctx.interactive.value ? '' : undefined"
:role="ctx.disableKeyboardA11y.value ? undefined : 'application'"
:tabindex="ctx.disableKeyboardA11y.value ? undefined : 0"
:style="{ position: 'relative', overflow: 'hidden', touchAction: 'none' }"
:style="{
position: 'relative',
overflow: 'hidden',
touchAction: 'none',
// Everything inside is absolutely positioned, so content-sizing always
// collapsed to 0×N and the graph rendered into an invisible strip.
// Vue merges a consumer's style attr over this, so it stays overridable.
width: '100%',
height: '100%',
}"
>
<slot />
+3 -1
View File
@@ -28,7 +28,9 @@ const { forwardRef } = useForwardExpose();
const style = computed<CSSProperties>(() => {
const [v, h] = position.split('-') as ['top' | 'bottom', 'left' | 'center' | 'right'];
const s: CSSProperties = { position: 'absolute', pointerEvents: 'all' };
// Above the viewport's explicit layer (zIndex 1): a positioned sibling
// with z-index auto would otherwise paint underneath the graph.
const s: CSSProperties = { position: 'absolute', pointerEvents: 'all', zIndex: 2 };
s[v] = '0';
if (h === 'center') {
s.left = '50%';
+76 -1
View File
@@ -73,26 +73,46 @@ export interface FlowRootProps extends PrimitiveProps {
isValidConnection?: IsValidConnection;
/** Cull nodes/edges outside the viewport — for large graphs. @default false */
onlyRenderVisibleElements?: boolean;
/**
* Frame the whole graph once after the initial nodes are measured. Skipped
* when an explicit `viewport` / `defaultViewport` is provided — a restored
* viewport must not be stomped by a fit. With virtualization the fit uses
* whatever is measured plus declared node sizes; fully unmeasured nodes are
* framed by position alone. @default false
*/
fitViewOnMount?: boolean | FitViewParams;
/** Extra px kept rendered around the viewport when virtualizing. @default 200 */
virtualizationBuffer?: number;
}
export interface FlowRootEmits {
/** Granular node mutations (position, selection, removal) — apply them to your controlled state. */
nodesChange: [changes: NodeChange[]];
/** Granular edge mutations (selection, removal). */
edgesChange: [changes: EdgeChange[]];
/** A connection gesture completed between two handles. */
connect: [connection: Connection];
/** A connection gesture started from a handle. */
connectStart: [payload: { nodeId: string; handleId: string | null; handleType: HandleType }];
/** The connection gesture ended, successfully or not. */
connectEnd: [];
/** A node drag finished; ids of every node that moved. */
nodeDragStop: [ids: string[]];
/** The set of selected nodes/edges changed. */
selectionChange: [selection: { nodes: string[]; edges: string[] }];
/** A click landed on the empty pane — not on a node or an edge. */
paneClick: [event: PointerEvent];
/** A settled click on a node (a drag that never started moving). */
nodeClick: [id: string, event: PointerEvent];
/** Two settled clicks on the same node within the double-click interval. */
nodeDoubleClick: [id: string, event: PointerEvent];
/** A click on an edge path. */
edgeClick: [id: string, event: PointerEvent];
}
</script>
<script setup lang="ts">
import { computed, shallowRef, toRef, triggerRef, useSlots, watch } from 'vue';
import { computed, getCurrentInstance, shallowRef, toRef, triggerRef, useSlots, watch } from 'vue';
import { useId } from '@robonen/vue';
import FlowPane from './FlowPane.vue';
import FlowViewport from './FlowViewport.vue';
@@ -124,6 +144,7 @@ const {
disableKeyboardA11y = false,
isValidConnection,
onlyRenderVisibleElements = false,
fitViewOnMount = false,
virtualizationBuffer = 200,
as = 'div',
} = defineProps<FlowRootProps>();
@@ -135,6 +156,7 @@ const flowId = useId(undefined, 'flow').value;
// ── models (controlled + uncontrolled) ────────────────────────────────────
const localNodes = shallowRef<FlowNode[]>(defaultNodes ? defaultNodes.slice() : []);
/** Current nodes (controlled `v-model:nodes` or internal state). */
const nodes = defineModel<FlowNode[]>('nodes', {
get: external => external ?? localNodes.value,
set: (value) => {
@@ -144,6 +166,7 @@ const nodes = defineModel<FlowNode[]>('nodes', {
});
const localEdges = shallowRef<FlowEdge[]>(defaultEdges ? defaultEdges.slice() : []);
/** Current edges (controlled `v-model:edges` or internal state). */
const edges = defineModel<FlowEdge[]>('edges', {
get: external => external ?? localEdges.value,
set: (value) => {
@@ -153,6 +176,7 @@ const edges = defineModel<FlowEdge[]>('edges', {
});
const localViewport = shallowRef<Viewport>(defaultViewport ?? { x: 0, y: 0, zoom: 1 });
/** Current viewport (controlled `v-model:viewport` or internal state). */
const viewport = defineModel<Viewport>('viewport', {
get: external => external ?? localViewport.value,
set: (value) => {
@@ -168,6 +192,7 @@ const viewport = defineModel<Viewport>('viewport', {
// would never visually update). ────────────────────────────────────────────
const nodeLookup = shallowRef(new Map<string, InternalNode>());
const edgeLookup = shallowRef(new Map<string, FlowEdge>());
/** Selected node/edge id sets. */
const selection = shallowRef<FlowSelection>({ nodes: new Set(), edges: new Set() });
const paneRect = shallowRef({ left: 0, top: 0, width: 0, height: 0 });
const isDragging = shallowRef(false);
@@ -330,6 +355,7 @@ function setNodeMeasured(id: string, size: Dimensions, handleBounds: InternalNod
// pick up the fresh measurement / handle geometry.
map.set(id, { ...n, measured: sizeChanged ? size : n.measured, handleBounds });
triggerRef(nodeLookup);
maybeFitOnMount();
}
function updateNode(id: string, patch: Partial<FlowNode>): void {
@@ -346,6 +372,7 @@ function emitSelection(): void {
emit('selectionChange', { nodes: [...selection.value.nodes], edges: [...selection.value.edges] });
}
/** Select a node — replacing the selection, or adding to it. */
function selectNode(id: string, additive = false): void {
if (!elementsSelectable) return;
const sel = selection.value;
@@ -357,6 +384,7 @@ function selectNode(id: string, additive = false): void {
emitSelection();
}
/** Select an edge — replacing the selection, or adding to it. */
function selectEdge(id: string, additive = false): void {
if (!elementsSelectable) return;
const sel = selection.value;
@@ -368,17 +396,20 @@ function selectEdge(id: string, additive = false): void {
emitSelection();
}
/** Replace the selection with exactly these nodes and edges. */
function setSelection(nodeIds: string[], edgeIds: string[]): void {
selection.value = { nodes: new Set(nodeIds), edges: new Set(edgeIds) };
emitSelection();
}
/** Deselect everything. */
function clearSelection(): void {
if (selection.value.nodes.size === 0 && selection.value.edges.size === 0) return;
selection.value = { nodes: new Set(), edges: new Set() };
emitSelection();
}
/** Remove every selected node (with its edges) and selected edge. */
function removeSelected(): void {
const sel = selection.value;
if (sel.nodes.size === 0 && sel.edges.size === 0) return;
@@ -515,12 +546,56 @@ const context: FlowContext = {
endConnection,
emitNodesChange: changes => emit('nodesChange', changes),
emitEdgesChange: changes => emit('edgesChange', changes),
emitNodeClick: (id, event) => emit('nodeClick', id, event),
emitNodeDoubleClick: (id, event) => emit('nodeDoubleClick', id, event),
emitEdgeClick: (id, event) => emit('edgeClick', id, event),
emitPaneClick: event => emit('paneClick', event),
};
provideFlowContext(context);
// Imperative API, also exposed so consumers can drive the flow via a template ref.
const api = useViewportApi(context);
// ── fitViewOnMount ────────────────────────────────────────────────────────
// A viewport the consumer controls (v-model:viewport) or seeds
// (defaultViewport) is restored state; a fit must never stomp it. Model
// getters fall back to a local default, so controlledness is read off the
// vnode, not the value.
const vnodeProps = getCurrentInstance()?.vnode.props ?? {};
let fitOnMountPending = fitViewOnMount !== false
&& defaultViewport === undefined
&& !('viewport' in vnodeProps)
&& !('onUpdate:viewport' in vnodeProps);
/**
* Armed until it fires once: waits for every RENDERED node to report a
* measurement — fitting to unmeasured nodes fits to nothing. Under
* virtualization only the rendered subset ever measures; the rest contribute
* their declared or positional bounds through `fitView` itself.
*/
function maybeFitOnMount(): void {
if (!fitOnMountPending) return;
// Nodes can finish measuring before the pane has a size (or the reverse);
// the shot must not burn against a 0×0 container, so both gates hold it and
// the pane-rect watcher below re-arms the attempt.
const rect = paneRect.value;
if (rect.width === 0 || rect.height === 0) return;
const map = nodeLookup.value;
if (map.size === 0) return;
for (const id of visibleNodeIds.value) {
const n = map.get(id);
if (n && n.measured.width === 0 && n.measured.height === 0) return;
}
fitOnMountPending = false;
api.fitView(typeof fitViewOnMount === 'object' ? fitViewOnMount : undefined);
}
watch(paneRect, maybeFitOnMount);
const nodeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'node' || n.startsWith('node-')));
const edgeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'edge' || n.startsWith('edge-')));
@@ -42,6 +42,9 @@ const transform = computed(() => {
left: '0',
width: '100%',
height: '100%',
// The slot (background, panels) renders after this element; explicit
// layers keep the graph above the background and below the chrome.
zIndex: 1,
transformOrigin: '0 0',
transform,
willChange: ctx.isInteracting.value ? 'transform' : undefined,
@@ -0,0 +1,191 @@
import type { VueWrapper } from '@vue/test-utils';
import type { FlowEdge, FlowNode } from '../index';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { h, nextTick } from 'vue';
import { FlowBackground, FlowPanel, FlowRoot } from '../index';
/**
* Regressions found by building a real story-map consumer: the pane rendered
* into zero area, the background painted over the graph, edge labels never
* rendered, the declared click emits never fired, and dblclick on a node
* zoomed the canvas. Each test pins the fixed contract.
*/
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
const nodes: FlowNode[] = [
{ id: 'a', position: { x: 0, y: 0 } },
{ id: 'b', position: { x: 300, y: 200 } },
];
function pointer(el: Element, type: string, x = 10, y = 10) {
el.dispatchEvent(new PointerEvent(type, { button: 0, pointerId: 1, clientX: x, clientY: y, bubbles: true, cancelable: true }));
}
/** The pane sizes to its parent; give the test-utils wrapper a real box. */
function sizeWrapper(w: VueWrapper<any>, width = 600, height = 400) {
const el = w.element as HTMLElement;
el.style.width = `${width}px`;
el.style.height = `${height}px`;
}
const edges: FlowEdge[] = [
{ id: 'a-b', source: 'a', target: 'b', label: 'take me' },
];
function flow(props: Record<string, unknown> = {}, slots: Record<string, unknown> = {}) {
return track(mount(FlowRoot, {
attachTo: document.body,
props: { defaultNodes: nodes, defaultEdges: edges, ...props },
slots: { 'node-default': () => h('div', { style: 'width:120px;height:40px' }, 'n'), ...slots },
}));
}
describe('pane sizing', () => {
it('fills its parent instead of collapsing to zero height', () => {
const w = flow();
sizeWrapper(w);
const pane = w.find('[data-flow-pane]').element as HTMLElement;
// All pane content is absolutely positioned; without an own height the
// whole graph rendered inside an invisible 0px strip.
expect(pane.clientHeight).toBe(400);
});
});
describe('stacking', () => {
it('layers background under the graph and panels above it', () => {
const w = flow({}, {
default: () => [h(FlowBackground), h(FlowPanel, { position: 'top-right' }, () => 'p')],
});
const viewport = (w.find('[data-flow-viewport]').element as HTMLElement).style.zIndex;
const background = (w.find('[data-flow-background]').element as HTMLElement).style.zIndex;
const panel = (w.find('[data-flow-panel]').element as HTMLElement).style.zIndex;
// The slot chrome renders AFTER the viewport in DOM order; without these
// layers the background dots painted over every node.
expect(Number(background)).toBeLessThan(Number(viewport));
expect(Number(panel)).toBeGreaterThan(Number(viewport));
});
});
describe('edge labels', () => {
it('renders the label the type always promised', async () => {
const w = flow();
await nextTick();
const label = w.find('[data-flow-edge-label]');
expect(label.exists()).toBe(true);
expect(label.text()).toBe('take me');
});
it('renders no label element when there is none', async () => {
const w = flow({ defaultEdges: [{ id: 'a-b', source: 'a', target: 'b' }] });
await nextTick();
expect(w.find('[data-flow-edge-label]').exists()).toBe(false);
});
});
describe('the click family', () => {
async function settle(w: VueWrapper<any>, selector: string, times = 1, gap = 50) {
const el = w.find(selector).element;
for (let index = 0; index < times; index++) {
pointer(el, 'pointerdown');
pointer(el, 'pointerup');
await nextTick();
if (gap)
await new Promise(resolve => setTimeout(resolve, gap));
}
}
it('emits nodeClick for a settled click', async () => {
const w = flow();
await nextTick();
await settle(w, '[data-flow-node][data-id="a"]');
expect(w.emitted('nodeClick')?.[0]?.[0]).toBe('a');
});
it('pairs two settled clicks into nodeDoubleClick', async () => {
const w = flow();
await nextTick();
await settle(w, '[data-flow-node][data-id="a"]', 2, 40);
expect(w.emitted('nodeDoubleClick')?.[0]?.[0]).toBe('a');
});
it('emits paneClick only for background clicks', async () => {
const w = flow();
await nextTick();
await w.find('[data-flow-pane]').trigger('click');
expect(w.emitted('paneClick')).toHaveLength(1);
await w.find('[data-flow-node][data-id="a"] div').trigger('click');
expect(w.emitted('paneClick')).toHaveLength(1);
});
it('emits edgeClick when the edge is picked', async () => {
const w = flow();
await nextTick();
pointer(w.findAll('[data-flow-edge] path')[1]!.element, 'pointerdown');
await nextTick();
expect(w.emitted('edgeClick')?.[0]?.[0]).toBe('a-b');
});
it('does not zoom on a node double click', async () => {
const w = flow();
await nextTick();
const before = (w.find('[data-flow-viewport]').element as HTMLElement).style.transform;
await w.find('[data-flow-node][data-id="a"]').trigger('dblclick');
await nextTick();
// The gesture belongs to the node (nodeDoubleClick), not the camera.
expect((w.find('[data-flow-viewport]').element as HTMLElement).style.transform).toBe(before);
});
});
describe('fitViewOnMount', () => {
it('frames the graph once nodes are measured', async () => {
const w = flow({ fitViewOnMount: true });
sizeWrapper(w);
await vi.waitFor(() => {
const t = (w.find('[data-flow-viewport]').element as HTMLElement).style.transform;
expect(t).not.toBe('translate(0px, 0px) scale(1)');
});
});
it('never stomps a consumer-controlled viewport', async () => {
const w = flow({
fitViewOnMount: true,
viewport: { x: 17, y: 23, zoom: 1.5 },
'onUpdate:viewport': () => {},
});
await new Promise(resolve => setTimeout(resolve, 120));
// A bound viewport is restored state; the fit must skip it entirely.
expect((w.find('[data-flow-viewport]').element as HTMLElement).style.transform)
.toBe('translate(17px, 23px) scale(1.5)');
});
});
@@ -35,6 +35,9 @@ export interface NodeDragOptions {
/** Elements inside a node that must not initiate a drag. */
const NO_DRAG_SELECTOR = 'input, textarea, select, button, [contenteditable="true"], [data-handleid], .nodrag';
/** Two settled clicks within this window read as a double click. */
const DOUBLE_CLICK_MS = 350;
/**
* Pointer-capture node drag. Moves the node (and every co-selected node) by the
* pointer delta converted to flow space (`delta / zoom`), optionally snapped to
@@ -57,6 +60,7 @@ export function useNodeDrag(
let startX = 0;
let startY = 0;
let started = false;
let lastClickAt = 0;
let lastX = 0;
let lastY = 0;
let rafId: number | null = null;
@@ -150,6 +154,26 @@ export function useNodeDrag(
if (started) {
flush();
ctx.commitNodeDrag();
lastClickAt = 0;
}
else if (snapshot.size > 0) {
// The pointer never crossed the drag threshold: this is a click. The
// pane cannot see it (propagation stopped on pointerdown), so the node
// is the only place that can report it — and pair two settled clicks
// into a double click.
const id = toValue(nodeId);
ctx.emitNodeClick(id, event);
const now = event.timeStamp;
if (now - lastClickAt <= DOUBLE_CLICK_MS) {
ctx.emitNodeDoubleClick(id, event);
lastClickAt = 0;
}
else {
lastClickAt = now;
}
}
pointerId = -1;
started = false;
@@ -158,7 +158,9 @@ export function usePanZoom(
// ── double-click zoom ──────────────────────────────────────────────────────
useEventListener(target, 'dblclick', (event: MouseEvent) => {
if (!zoomOnDoubleClick || !ctx.interactive.value) return;
if (event.target instanceof Element && event.target.closest('.nopan')) return;
// A double click on a node belongs to the node (nodeDoubleClick), not
// to the zoom gesture.
if (event.target instanceof Element && event.target.closest('.nopan, [data-flow-node]')) return;
const vp = current();
const newZoom = clampZoom(vp.zoom * doubleClickZoomFactor, ctx.minZoom.value, ctx.maxZoom.value);
if (newZoom === vp.zoom) return;
@@ -121,6 +121,10 @@ export interface FlowContext {
// ── change emission ──────────────────────────────────────────────────────
emitNodesChange: (changes: NodeChange[]) => void;
emitEdgesChange: (changes: EdgeChange[]) => void;
emitNodeClick: (id: string, event: PointerEvent) => void;
emitNodeDoubleClick: (id: string, event: PointerEvent) => void;
emitEdgeClick: (id: string, event: PointerEvent) => void;
emitPaneClick: (event: PointerEvent) => void;
}
const flow = useContextFactory<FlowContext>('FlowContext');
@@ -14,6 +14,9 @@ import type { RovingDirection } from '../../internal/utils/roving-focus';
export type AccordionType = 'single' | 'multiple';
export interface AccordionRootProps extends PrimitiveProps {
/** Controlled open value(s). Bind with `v-model`. */
modelValue?: string | string[];
/** Initial value(s) for uncontrolled mode. */
defaultValue?: string | string[];
@@ -51,6 +54,10 @@ export interface AccordionRootProps extends PrimitiveProps {
/**
* Emit contract for `AccordionRoot`. The payload narrows with `Type`: a single
* accordion emits `string | undefined`, a multiple accordion emits `string[]`.
*
* The event itself is declared by `defineModel`: passing a model key through
* `defineEmits` as well erases its payload type from the generated
* declarations, leaving consumers with `unknown`.
*/
export interface AccordionRootEmits<Type extends AccordionType = AccordionType> {
'update:modelValue': [value: (Type extends 'single' ? string : string[]) | undefined];
@@ -79,8 +86,6 @@ const {
as = 'div',
} = defineProps<AccordionRootProps>();
defineEmits<AccordionRootEmits>();
defineSlots<{
default?: (props: {
/** Current open value(s): a `string | undefined` in single mode, `string[]` in multiple. */
+25 -15
View File
@@ -13,11 +13,11 @@ import type { TabsValue } from './context';
* via `defaultValue`), orientation, keyboard roving focus across triggers, and
* provides context to every `TabsList`, `TabsTrigger`, and `TabsContent`.
*/
export interface TabsRootProps extends PrimitiveProps {
export interface TabsRootProps<Value extends TabsValue = TabsValue> extends PrimitiveProps {
/** Controlled selected value. Bind with `v-model`. */
modelValue?: TabsValue;
modelValue?: Value;
/** Uncontrolled initial value. */
defaultValue?: TabsValue;
defaultValue?: Value;
/** Orientation of the tab list. @default 'horizontal' */
orientation?: 'horizontal' | 'vertical';
/**
@@ -40,13 +40,14 @@ export interface TabsRootProps extends PrimitiveProps {
unmountOnHide?: boolean;
}
export interface TabsRootEmits {
export interface TabsRootEmits<Value extends TabsValue = TabsValue> {
/** Fired when the selected value changes. */
'update:modelValue': [value: TabsValue | undefined];
'update:modelValue': [value: Value];
}
</script>
<script setup lang="ts">
<script setup lang="ts" generic="Value extends TabsValue = TabsValue">
import type { Ref } from 'vue';
import { computed, ref, shallowRef, toRef } from 'vue';
import { resolveNextIndex, rovingKeyToAction } from '../../internal/utils/roving-focus';
import { useCollectionProvider } from '../../utilities/collection';
@@ -63,15 +64,16 @@ const {
activationMode = 'automatic',
unmountOnHide = true,
defaultValue,
modelValue,
as = 'div',
} = defineProps<TabsRootProps>();
} = defineProps<TabsRootProps<Value>>();
defineEmits<TabsRootEmits>();
const emit = defineEmits<TabsRootEmits<Value>>();
defineSlots<{
default?: (props: {
/** Current selected value. */
value: TabsValue | undefined;
value: Value | undefined;
}) => unknown;
}>();
@@ -79,16 +81,24 @@ const { forwardRef } = useForwardExpose();
const direction = useDirection(() => dir);
const localValue = ref<TabsValue | undefined>(defaultValue);
// `defineModel` would type `update:modelValue` as `TabsValue | undefined`,
// forcing every consumer's `v-model` target to accept `undefined` even though
// a tab is never deselected. The prop and the emit are declared separately so
// the emitted payload stays exactly `TabsValue` (see AGENTS §3.2.3).
const localValue = ref<Value | undefined>(defaultValue) as Ref<Value | undefined>;
const value = defineModel<TabsValue | undefined>({
get: v => v ?? localValue.value,
const value = computed<Value | undefined>({
get: () => modelValue ?? localValue.value,
set: (v) => {
localValue.value = v;
return v;
if (v !== undefined) emit('update:modelValue', v);
},
});
// The tab parts read and write plain `TabsValue`s through the context; the
// narrowed `Value` only exists to keep the consumer's `v-model` typed.
const contextValue = value as unknown as Ref<TabsValue | undefined>;
const baseId = useId(undefined, 'tabs');
const tabsListElement = shallowRef<HTMLElement>();
@@ -116,7 +126,7 @@ function unregisterContent(v: TabsValue): void {
function select(v: TabsValue): void {
if (disabled) return;
value.value = v;
contextValue.value = v;
}
// DOM-order tabs via Collection primitive — survives `v-for` reorders and
@@ -161,7 +171,7 @@ function onTriggerKeyDown(event: KeyboardEvent, el: HTMLElement): void {
}
provideTabsContext({
value,
value: contextValue,
// Identity passthroughs via `toRef` — reactive without `computed`'s effect/cache.
orientation: toRef(() => orientation),
direction,
@@ -66,6 +66,11 @@ export interface CalendarRootProps extends PrimitiveProps {
dateAdapter?: DateAdapter<Date>;
}
/**
* Emit contract for `CalendarRoot`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface CalendarRootEmits {
'update:modelValue': [date: Date | Date[] | undefined];
'update:placeholder': [date: Date];
@@ -106,8 +111,6 @@ const {
dateAdapter,
} = defineProps<CalendarRootProps>();
defineEmits<CalendarRootEmits>();
defineSlots<{
default?: (props: {
date: Date;
@@ -40,6 +40,11 @@ export interface DatePickerRootProps extends PrimitiveProps,
hourCycle?: HourCycle;
}
/**
* Emit contract for `DatePickerRoot`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface DatePickerRootEmits {
'update:modelValue': [date: Date | undefined];
'update:placeholder': [date: Date];
@@ -95,8 +100,6 @@ const {
dateAdapter,
} = defineProps<DatePickerRootProps>();
defineEmits<DatePickerRootEmits>();
const { forwardRef, currentElement: parentElement } = useForwardExpose();
// Resolve the effective date backend: per-instance prop wins over the global
@@ -37,6 +37,11 @@ export interface ProgressRootProps extends PrimitiveProps {
accessibleLabel?: string | ((value: number | null, max: number) => string | undefined);
}
/**
* Emit contract for `ProgressRoot`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface ProgressRootEmits {
/** Emitted when the value changes (after validation/clamping). */
'update:modelValue': [value: number | null];
@@ -59,8 +64,6 @@ const {
as = 'div',
} = defineProps<ProgressRootProps>();
defineEmits<ProgressRootEmits>();
const { forwardRef } = useForwardExpose();
const localValue = ref<number | null>(null);
+5 -2
View File
@@ -43,6 +43,11 @@ export interface SwitchProps<T = boolean> extends PrimitiveProps {
value?: string;
}
/**
* Emit contract for `Switch`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface SwitchEmits<T = boolean> {
/** Emitted whenever the value changes (also drives `v-model`). */
'update:modelValue': [value: T];
@@ -71,8 +76,6 @@ const {
as = 'button',
} = defineProps<SwitchProps<T>>();
defineEmits<SwitchEmits<T>>();
const { forwardRef, currentElement } = useForwardExpose();
const local = ref<T>((defaultValue ?? falsy) as T) as Ref<T>;
+5 -3
View File
@@ -4,7 +4,11 @@ import type { PrimitiveProps } from '../../internal/primitive';
/** Canonical `data-state` value reflected on the host element. */
export type ToggleState = 'on' | 'off';
/** Events emitted by `Toggle`. */
/**
* Emit contract for `Toggle`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface ToggleEmits {
/** Fired when the pressed state changes. Backs `v-model:pressed`. */
'update:pressed': [pressed: boolean];
@@ -58,8 +62,6 @@ const {
value = 'on',
} = defineProps<ToggleProps>();
defineEmits<ToggleEmits>();
const { forwardRef, currentElement } = useForwardExpose();
// A standalone Toggle nested inside a ToggleGroup must not also submit its own
@@ -4,7 +4,35 @@ import { renderSlotChild } from './Slot';
type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
export interface PrimitiveProps {
type Booleanish = boolean | 'true' | 'false';
/**
* Global DOM attributes any part accepts and forwards, through `$attrs`, to the
* element it renders. They are deliberately kept out of the runtime props (the
* `@vue-ignore` marker on the heritage clause below stops the SFC compiler from
* lifting them out of `$attrs`), so this only teaches `strictTemplates` that
* they are valid — the runtime behaviour is unchanged.
*/
export interface PrimitiveAttributes {
id?: string;
role?: string;
title?: string;
tabindex?: number | string;
lang?: string;
dir?: string;
hidden?: Booleanish | 'until-found' | '';
inert?: Booleanish;
autofocus?: Booleanish;
draggable?: Booleanish;
spellcheck?: Booleanish;
translate?: 'yes' | 'no';
nonce?: string;
part?: string;
slot?: string;
[key: `data-${string}` | `aria-${string}`]: unknown;
}
export interface PrimitiveProps extends /* @vue-ignore */ PrimitiveAttributes {
as?: keyof IntrinsicElementAttributes | Component;
}
@@ -1,2 +1,2 @@
export { Primitive, type PrimitiveProps } from './Primitive';
export { Primitive, type PrimitiveAttributes, type PrimitiveProps } from './Primitive';
export { Slot } from './Slot';
@@ -38,6 +38,11 @@ export interface NavigationMenuRootProps extends PrimitiveProps {
unmountOnHide?: boolean;
}
/**
* Emit contract for `NavigationMenuRoot`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface NavigationMenuRootEmits {
'update:modelValue': [value: string];
}
@@ -70,8 +75,6 @@ const {
as = 'nav',
} = defineProps<NavigationMenuRootProps>();
defineEmits<NavigationMenuRootEmits>();
defineSlots<{
default?: (props: { modelValue: string }) => unknown;
}>();
@@ -15,6 +15,11 @@ export interface NavigationMenuSubProps extends PrimitiveProps {
orientation?: Orientation;
}
/**
* Emit contract for `NavigationMenuSub`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface NavigationMenuSubEmits {
'update:modelValue': [value: string];
}
@@ -35,8 +40,6 @@ defineOptions({ inheritAttrs: false });
const { defaultValue, orientation = 'horizontal', as = 'div' } = defineProps<NavigationMenuSubProps>();
defineEmits<NavigationMenuSubEmits>();
defineSlots<{
default?: (props: { modelValue: string }) => unknown;
}>();
@@ -44,6 +44,14 @@ export interface ToolbarRootEmits {
/** Backs `v-model:currentTabStopId`. */
'update:currentTabStopId': [value: string | null | undefined];
}
/**
* The subset `defineEmits` declares. `update:currentTabStopId` comes from
* `defineModel`; passing a model key through `defineEmits` as well erases its
* payload type from the generated declarations, leaving consumers with
* `unknown`.
*/
type ToolbarRootOwnEmits = Omit<ToolbarRootEmits, 'update:currentTabStopId'>;
</script>
<script setup lang="ts">
@@ -64,7 +72,7 @@ const {
as = 'div',
} = defineProps<ToolbarRootProps>();
const emit = defineEmits<ToolbarRootEmits>();
const emit = defineEmits<ToolbarRootOwnEmits>();
const { forwardRef } = useForwardExpose();
@@ -0,0 +1,25 @@
<script lang="ts">
import type { DialogCloseProps } from '../dialog';
/**
* A button that closes the drawer when activated. A thin wrapper over Dialog's
* Close that tags the resulting `update:open` with the `close-press` reason.
*/
export interface DrawerCloseProps extends DialogCloseProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { DialogClose } from '../dialog';
import { injectDrawerRootContext } from './context';
const props = defineProps<DrawerCloseProps>();
const { armReason } = injectDrawerRootContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<DialogClose v-bind="props" :ref="forwardRef" @click="armReason('close-press')">
<slot />
</DialogClose>
</template>
@@ -14,6 +14,7 @@ export type DrawerContentEmits = DialogContentEmits;
<script setup lang="ts">
import { computed, ref, watch, watchEffect } from 'vue';
import { isClient } from '@robonen/platform/multi';
import { useForwardExpose } from '@robonen/vue';
import { DialogContent } from '../dialog';
import { injectDrawerRootContext } from './context';
@@ -30,6 +31,9 @@ const {
onPress,
onDrag,
onRelease,
onCancel,
armReason,
isAllowedToDrag,
modal,
dismissible,
keyboardIsOpen,
@@ -49,10 +53,12 @@ useScaleBackground();
const delayedSnapPoints = ref(false);
const snapPointHeight = computed(() => {
if (snapPointsOffset.value && snapPointsOffset.value.length > 0)
return `${snapPointsOffset.value[0]}px`;
const offset = snapPointsOffset.value?.[0];
return '0';
if (typeof offset === 'number' && Number.isFinite(offset))
return `${offset}px`;
return '0px';
});
function handlePointerDownOutside(event: Event) {
@@ -66,13 +72,21 @@ function handlePointerDownOutside(event: Event) {
// Let the underlying DismissableLayer close a dismissible modal drawer;
// otherwise hold it open.
if (!dismissible.value)
if (!dismissible.value) {
event.preventDefault();
return;
}
armReason('outside-press');
}
function handleEscapeKeyDown(event: KeyboardEvent) {
if (!dismissible.value)
if (!dismissible.value) {
event.preventDefault();
return;
}
armReason('escape-key');
}
function handlePointerDown(event: PointerEvent) {
@@ -88,8 +102,9 @@ function handlePointerMove(event: PointerEvent) {
}
watchEffect(() => {
if (hasSnapPoints.value) {
globalThis.requestAnimationFrame(() => {
// `flush: 'pre'` effects run during SSR, where rAF doesn't exist.
if (hasSnapPoints.value && isClient) {
requestAnimationFrame(() => {
delayedSnapPoints.value = true;
});
}
@@ -103,10 +118,13 @@ watchEffect(() => {
:data-drawer-direction="direction"
:data-drawer-delayed-snap-points="delayedSnapPoints ? 'true' : 'false'"
:data-drawer-snap-points="isOpen && hasSnapPoints ? 'true' : 'false'"
:data-swiping="isAllowedToDrag ? 'true' : undefined"
:style="{ '--snap-point-height': snapPointHeight }"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="onRelease"
@pointercancel="onCancel"
@lostpointercapture="onCancel"
@open-auto-focus.prevent
@pointer-down-outside="handlePointerDownOutside"
@escape-key-down="handleEscapeKeyDown"
@@ -11,7 +11,8 @@ export type { DrawerHandleProps } from './controls';
</script>
<script setup lang="ts">
import { ref, useTemplateRef, watchPostEffect } from 'vue';
import { onScopeDispose, useTemplateRef, watch, watchPostEffect } from 'vue';
import { onLongPress, useStateMachine } from '@robonen/vue';
import { injectDrawerRootContext } from './context';
const { preventCycle = false } = defineProps<DrawerHandleProps>();
@@ -19,7 +20,7 @@ const { preventCycle = false } = defineProps<DrawerHandleProps>();
const LONG_HANDLE_PRESS_TIMEOUT = 250;
const DOUBLE_TAP_TIMEOUT = 120;
const { onPress, onDrag, handleRef, handleOnly, isOpen, snapPoints, activeSnapPoint, isDragging, dismissible, closeDrawer }
const { onPress, onDrag, onCancel, handleRef, handleOnly, isOpen, snapPoints, activeSnapPoint, isDragging, isAllowedToDrag, dismissible, closeDrawer }
= injectDrawerRootContext();
// Mirror the element into the shared context ref. A local template ref + watch
@@ -31,33 +32,67 @@ watchPostEffect(() => {
handleRef.value = handleElement.value;
});
const closeTimeoutId = ref<number | null>(null);
const shouldCancelInteraction = ref(false);
let cycleTimer: ReturnType<typeof setTimeout> | undefined;
function handleStartCycle() {
// Ignore the second tap of a double-tap.
if (shouldCancelInteraction.value) {
handleCancelInteraction();
return;
}
// Tap-to-cycle as an explicit machine: a tap schedules the cycle after the
// double-tap window, a long hold suppresses it, and a second press inside the
// window cancels the pending cycle — so a double-tap cycles once, never twice.
const tap = useStateMachine({
initial: 'idle',
states: {
idle: { on: { PRESS: 'pressed', TAP: 'tapPending' } },
pressed: { on: { LONG_PRESS: 'suppressed', DRAG: 'suppressed', TAP: 'tapPending', CANCEL: 'idle' } },
suppressed: { on: { TAP: 'idle', PRESS: 'pressed', CANCEL: 'idle' } },
tapPending: {
entry: () => {
cycleTimer = setTimeout(fireCycleElapsed, DOUBLE_TAP_TIMEOUT);
},
exit: () => clearTimeout(cycleTimer),
on: {
ELAPSED: { target: 'idle', action: cycleSnapPoints },
PRESS: 'pressed',
// A long-press timer armed before the release can still outrace the
// pending cycle — treat it as suppression, like the release-time flag
// check of the pre-machine code did.
LONG_PRESS: 'suppressed',
DRAG: 'suppressed',
CANCEL: 'idle',
},
},
},
});
globalThis.setTimeout(() => {
handleCycleSnapPoints();
}, DOUBLE_TAP_TIMEOUT);
// The exit hook covers every transition; this covers unmount mid-window.
onScopeDispose(() => clearTimeout(cycleTimer));
// A gesture that actually engaged the drawer must never read as a tap: pointer
// capture keeps the release's click on the handle, and 120ms later the drag is
// long over (isDragging is false again), so only a latch armed DURING the
// press can tell a short drag apart from a tap.
watch(isAllowedToDrag, (dragging) => {
if (dragging)
tap.send('DRAG');
});
// Annotated `: void` so the machine config can reference it without a type cycle.
function fireCycleElapsed(): void {
tap.send('ELAPSED');
}
function handleCycleSnapPoints() {
// Don't treat an accidental tap during a resize as a cycle.
if (isDragging.value || preventCycle || shouldCancelInteraction.value) {
handleCancelInteraction();
return;
}
// A long hold suppresses the tap-to-cycle. `distanceThreshold: false` keeps the
// 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 });
handleCancelInteraction();
function cycleSnapPoints() {
// Don't treat an accidental tap during a resize as a cycle.
if (isDragging.value || preventCycle)
return;
if (!snapPoints.value || snapPoints.value.length === 0) {
if (!dismissible.value)
closeDrawer();
if (dismissible.value)
closeDrawer('handle-press');
return;
}
@@ -65,7 +100,7 @@ function handleCycleSnapPoints() {
const isLastSnapPoint = activeSnapPoint.value === snapPoints.value[snapPoints.value.length - 1];
if (isLastSnapPoint && dismissible.value) {
closeDrawer();
closeDrawer('handle-press');
return;
}
@@ -78,30 +113,38 @@ function handleCycleSnapPoints() {
activeSnapPoint.value = snapPoints.value[nextSnapPointIndex];
}
function handleStartInteraction() {
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 handleClick() {
tap.send('TAP');
}
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)
onPress(event);
handleStartInteraction();
onPress(event, handleElement.value ?? undefined);
}
function handlePointerMove(event: PointerEvent) {
if (handleOnly.value)
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>
<template>
@@ -110,8 +153,9 @@ function handlePointerMove(event: PointerEvent) {
:data-drawer-visible="isOpen ? 'true' : 'false'"
data-drawer-handle
aria-hidden="true"
@click="handleStartCycle"
@pointercancel="handleCancelInteraction"
@click="handleClick"
@pointercancel="handlePointerCancel"
@lostpointercapture="handleLostPointerCapture"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
>
@@ -17,7 +17,7 @@ import { injectDrawerRootContext } from './context';
defineProps<DrawerOverlayProps>();
const { overlayRef, hasSnapPoints, isOpen, shouldFade } = injectDrawerRootContext();
const { overlayRef, hasSnapPoints, isOpen, shouldFade, isAllowedToDrag } = injectDrawerRootContext();
const { forwardRef, currentElement } = useForwardExpose();
watch(currentElement, (el) => {
@@ -31,6 +31,7 @@ watch(currentElement, (el) => {
data-drawer-overlay
:data-drawer-snap-points="isOpen && hasSnapPoints ? 'true' : 'false'"
:data-drawer-snap-points-overlay="isOpen && shouldFade ? 'true' : 'false'"
:data-swiping="isAllowedToDrag ? 'true' : undefined"
>
<slot />
</DialogOverlay>
@@ -18,12 +18,13 @@ export type { DrawerRootEmits, DrawerRootProps } from './controls';
<script setup lang="ts">
import { computed, ref, toRefs, watch } from 'vue';
import { useStyleTag } from '@robonen/vue';
import { useEventListener, useStyleTag } from '@robonen/vue';
import { isClient } from '@robonen/platform/multi';
import { DialogRoot } from '../dialog';
import { provideDrawerRootContext } from './context';
import { useDrawer } from './controls';
import { CLOSE_THRESHOLD, SCROLL_LOCK_TIMEOUT, TRANSITIONS } from './constants';
import { DRAWER_STYLES, DRAWER_STYLE_ID } from './style';
import { DRAWER_STYLES, DRAWER_STYLE_ID, registerDrawerCssProperties } from './style';
defineOptions({ inheritAttrs: false });
@@ -45,6 +46,7 @@ const props = withDefaults(defineProps<DrawerRootProps>(), {
noBodyStyles: false,
handleOnly: false,
preventScrollRestoration: false,
snapToSequentialPoints: false,
});
const emit = defineEmits<DrawerRootEmits>();
@@ -52,6 +54,9 @@ const emit = defineEmits<DrawerRootEmits>();
// Inject the critical drawer CSS once (reference-counted across every drawer).
useStyleTag(DRAWER_STYLES, { id: DRAWER_STYLE_ID });
if (isClient)
registerDrawerCssProperties();
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
@@ -64,14 +69,6 @@ watch(() => props.open, (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>(
props.activeSnapPoint ?? props.snapPoints?.[0] ?? null,
);
@@ -91,7 +88,7 @@ const emitHandlers = {
emitClose: () => emit('close'),
};
const { modal } = provideDrawerRootContext(
const { modal, drawerRef, pendingReason, notifySettled, hasSnapPoints } = provideDrawerRootContext(
useDrawer({
...emitHandlers,
...toRefs(props),
@@ -101,6 +98,68 @@ const { modal } = provideDrawerRootContext(
}),
);
// `animationEnd` fires on the drawer element's own transitionend/animationend
// (so dynamic settle durations and consumer-tuned animations report honestly),
// with a fixed-duration timeout kept as an upper-bound fallback for
// reduced-motion and animation-less environments. The listener rides the
// reactive `drawerRef`, so it (re)attaches whenever the content (re)mounts;
// `pendingAnimationEnd` gates it to the transition armed by the open flip.
let pendingAnimationEnd: boolean | null = null;
let animationEndTimer: ReturnType<typeof setTimeout> | undefined;
function fireAnimationEnd() {
if (pendingAnimationEnd === null)
return;
const open = pendingAnimationEnd;
pendingAnimationEnd = null;
clearTimeout(animationEndTimer);
// Advance the engine's lifecycle phase first, so `animationEnd` observers see
// the settled state (e.g. the snap point already reset after a close).
notifySettled();
emit('animationEnd', open);
}
useEventListener(drawerRef, ['transitionend', 'animationend'], (event) => {
// Only the drawer's own settle counts — ignore bubbled child transitions.
if (event.target !== event.currentTarget)
return;
if (event.type === 'transitionend') {
// Transform transitions signal a settle only for snap-point drawers; the
// keyframe-driven enter/exit of plain drawers also sees transform
// transitions from other sources (a nested child writing to this element,
// a drag settle) that must not consume an armed flip.
if (!hasSnapPoints.value || (event as TransitionEvent).propertyName !== 'transform')
return;
}
// Only the stylesheet's slide keyframes mark a settle; consumer keyframes on
// the content fall through to the fallback timeout instead.
else if (!(event as AnimationEvent).animationName.startsWith('slide')) {
return;
}
fireAnimationEnd();
});
// Every change to `isOpen` (from any source) notifies the consumer's `v-model`
// once — tagged with the reason armed by whichever part caused the flip — and
// arms `animationEnd`. Close-specific effects (`close`, snap reset) live in the
// engine's own watch on the same ref.
watch(isOpen, (o, _prev, onCleanup) => {
const reason = pendingReason.current;
pendingReason.current = undefined;
emit('update:open', o, reason ? { reason } : undefined);
pendingAnimationEnd = o;
animationEndTimer = setTimeout(fireAnimationEnd, TRANSITIONS.DURATION * 1000);
// Runs before the next flip re-arms, and on unmount — the fallback never
// outlives the transition it was armed for.
onCleanup(() => clearTimeout(animationEndTimer));
});
// The Dialog reports its own dismissals (trigger, close button, escape, outside
// click) here; mirror them into `isOpen` and let the watchers do the rest.
function handleOpenChange(o: boolean) {
@@ -9,6 +9,7 @@
<script setup lang="ts">
import DrawerRoot from './DrawerRoot.vue';
import type { DrawerRootEmits, DrawerRootProps } from './controls';
import type { DrawerOpenChangeDetails } from './types';
import { injectDrawerRootContext } from './context';
const props = defineProps<DrawerRootProps>();
@@ -31,10 +32,10 @@ function onRelease(open: boolean) {
emit('release', open);
}
function onOpenChange(open: boolean) {
function onOpenChange(open: boolean, details?: DrawerOpenChangeDetails) {
if (open)
onNestedOpenChange(open);
emit('update:open', open);
emit('update:open', open, details);
}
</script>
@@ -0,0 +1,25 @@
<script lang="ts">
import type { DialogTriggerProps } from '../dialog';
/**
* The button that toggles the drawer open. A thin wrapper over Dialog's Trigger
* that tags the resulting `update:open` with the `trigger-press` reason.
*/
export interface DrawerTriggerProps extends DialogTriggerProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { DialogTrigger } from '../dialog';
import { injectDrawerRootContext } from './context';
const props = defineProps<DrawerTriggerProps>();
const { armReason } = injectDrawerRootContext();
const { forwardRef } = useForwardExpose();
</script>
<template>
<DialogTrigger v-bind="props" :ref="forwardRef" @click="armReason('trigger-press')">
<slot />
</DialogTrigger>
</template>
@@ -2,6 +2,7 @@ import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import type { VNode } from 'vue';
import {
DrawerClose,
DrawerContent,
@@ -13,6 +14,7 @@ import {
DrawerTitle,
DrawerTrigger,
} from '../index';
import { DRAWER_STYLE_ID } from '../style';
const wrappers: Array<VueWrapper<any>> = [];
@@ -20,7 +22,7 @@ afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
document.body.removeAttribute('style');
document.getElementById('robonen-drawer')?.remove();
document.getElementById(DRAWER_STYLE_ID)?.remove();
});
function track<T extends VueWrapper<any>>(w: T): T {
@@ -35,6 +37,16 @@ async function flush(): Promise<void> {
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 {
return document.querySelector<T>(selector);
}
@@ -51,14 +63,52 @@ function $close(): HTMLButtonElement | undefined {
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 {
open?: boolean;
defaultOpen?: boolean;
modal?: boolean;
dismissible?: boolean;
direction?: 'top' | 'bottom' | 'left' | 'right';
snapPoints?: Array<number | string>;
handleOnly?: boolean;
withHandle?: boolean;
onUpdateOpen?: (v: boolean) => void;
contentStyle?: Record<string, string>;
extraContent?: () => VNode;
onUpdateOpen?: (v: boolean, details?: { reason?: string }) => void;
onUpdateActiveSnapPoint?: (v: number | string) => void;
onRelease?: (open: boolean) => void;
onAnimationEnd?: (open: boolean) => void;
onClose?: () => void;
}
@@ -75,7 +125,12 @@ function mountDrawer(options: MountOptions = {}) {
modal: options.modal ?? true,
dismissible: options.dismissible ?? true,
direction: options.direction ?? 'bottom',
snapPoints: options.snapPoints,
handleOnly: options.handleOnly,
'onUpdate:open': options.onUpdateOpen,
'onUpdate:activeSnapPoint': options.onUpdateActiveSnapPoint,
onRelease: options.onRelease,
onAnimationEnd: options.onAnimationEnd,
onClose: options.onClose,
},
{
@@ -84,12 +139,13 @@ function mountDrawer(options: MountOptions = {}) {
h(DrawerPortal, null, {
default: () => [
h(DrawerOverlay, { 'data-testid': 'overlay' }),
h(DrawerContent, null, {
h(DrawerContent, { style: { height: '200px', width: '200px', ...options.contentStyle } }, {
default: () => [
withHandle ? h(DrawerHandle) : null,
h(DrawerTitle, null, { default: () => 'Title' }),
h(DrawerDescription, null, { default: () => 'Desc' }),
h(DrawerClose, null, { default: () => 'Close' }),
options.extraContent ? options.extraContent() : null,
],
}),
],
@@ -113,7 +169,7 @@ describe('Drawer / markup', () => {
it('injects the critical drawer stylesheet once', async () => {
mountDrawer({ defaultOpen: true });
await flush();
const tags = document.querySelectorAll('#robonen-drawer');
const tags = document.querySelectorAll(`#${DRAWER_STYLE_ID}`);
expect(tags.length).toBe(1);
expect(tags[0]!.textContent).toContain('@keyframes slideFromBottom');
});
@@ -152,13 +208,13 @@ describe('Drawer / open state', () => {
expect($content()?.getAttribute('data-state') ?? 'closed').toBe('closed');
});
it('emits update:open when the trigger is clicked (controlled)', async () => {
it('emits update:open with a trigger-press reason (controlled)', async () => {
const onUpdateOpen = vi.fn();
mountDrawer({ open: false, onUpdateOpen });
$trigger().click();
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(true);
expect(onUpdateOpen).toHaveBeenCalledWith(true, { reason: 'trigger-press' });
});
it('emits close exactly once when dismissed via DrawerClose', async () => {
@@ -175,6 +231,7 @@ describe('Drawer / open state', () => {
// Regression: closing purely by setting the bound `open` prop to false (not
// via a dialog dismissal) must still run the close side effects.
const onClose = vi.fn();
const onUpdateOpen = vi.fn();
const state = ref(true);
const Wrapper = defineComponent({
setup() {
@@ -182,7 +239,10 @@ describe('Drawer / open state', () => {
DrawerRoot,
{
open: state.value,
'onUpdate:open': (v: boolean) => { state.value = v; },
'onUpdate:open': (v: boolean, details?: unknown) => {
state.value = v;
onUpdateOpen(v, details);
},
onClose,
},
{
@@ -203,6 +263,8 @@ describe('Drawer / open state', () => {
state.value = false;
await flush();
expect(onClose).toHaveBeenCalledTimes(1);
// A programmatic flip carries no reason.
expect(onUpdateOpen).toHaveBeenCalledWith(false, undefined);
});
});
@@ -219,3 +281,497 @@ describe('Drawer / overlay', () => {
expect($('[data-drawer-overlay]')).toBeNull();
});
});
describe('Drawer / dismiss reasons', () => {
it('tags an Escape dismissal with escape-key', async () => {
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen });
await flush();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }));
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'escape-key' });
});
it('tags a DrawerClose click with close-press', async () => {
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen });
await flush();
$close()!.click();
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'close-press' });
});
});
describe('Drawer / handle', () => {
it('closes a dismissible drawer without snap points on a handle tap', async () => {
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen });
await flush();
$('[data-drawer-handle]')!.click();
await sleep(250); // past the double-tap window
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'handle-press' });
});
it('keeps a non-dismissible drawer open on a handle tap', async () => {
// Regression: the condition used to be inverted — a handle tap closed
// exactly the drawers that declared themselves non-dismissible.
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, dismissible: false, onUpdateOpen });
await flush();
$('[data-drawer-handle]')!.click();
await sleep(250);
await flush();
expect(onUpdateOpen).not.toHaveBeenCalled();
expect($content()!.getAttribute('data-state')).toBe('open');
});
it('cycles snap points on tap and reports the new active point', async () => {
const onUpdateActiveSnapPoint = vi.fn();
mountDrawer({ defaultOpen: true, snapPoints: [0.5, 1], onUpdateActiveSnapPoint });
await flush();
$('[data-drawer-handle]')!.click();
await sleep(250);
await flush();
expect(onUpdateActiveSnapPoint).toHaveBeenCalledWith(1);
});
it('cycles once on a double tap, not twice', async () => {
const onUpdateActiveSnapPoint = vi.fn();
mountDrawer({ defaultOpen: true, snapPoints: [0.3, 0.6, 1], onUpdateActiveSnapPoint });
await flush();
onUpdateActiveSnapPoint.mockClear();
const handle = $('[data-drawer-handle]')!;
// A full tap is pointerdown → pointerup → click. Both taps are dispatched
// in the same synchronous block: no timer can fire in between, so the
// second press deterministically lands inside the double-tap window and
// must cancel the first pending cycle.
pointer(handle, 'pointerdown', 100, 100);
pointer(handle, 'pointerup', 100, 100);
handle.click();
pointer(handle, 'pointerdown', 100, 100);
pointer(handle, 'pointerup', 100, 100);
handle.click();
await sleep(300);
await flush();
expect(onUpdateActiveSnapPoint).toHaveBeenCalledWith(0.6);
expect(onUpdateActiveSnapPoint).not.toHaveBeenCalledWith(1);
});
it('does not cycle when a short drag on the handle ends in a click', async () => {
const onUpdateActiveSnapPoint = vi.fn();
const onUpdateOpen = vi.fn();
// Full-height content: fraction snap points assume the drawer can cover
// the window, otherwise every release projects as "closer to closed".
mountDrawer({ defaultOpen: true, snapPoints: [0.5, 1], contentStyle: { height: '100vh' }, onUpdateActiveSnapPoint, onUpdateOpen });
await openSettled();
onUpdateActiveSnapPoint.mockClear();
const handle = $('[data-drawer-handle]')!;
// A small real drag from the handle (upward, so a dismissible drawer at
// its first snap point doesn't legitimately close), then the click the
// browser dispatches after release — pointer capture keeps it on the
// handle. The engaged drag must suppress the tap-to-cycle.
await slowDrag(handle, [[100, 300], [100, 290], [100, 280]]);
pointer(handle, 'pointerup', 100, 280);
handle.click();
await sleep(300);
await flush();
expect(onUpdateActiveSnapPoint).not.toHaveBeenCalledWith(1);
expect(onUpdateOpen).not.toHaveBeenCalled();
});
it('does not fire a pending tap cycle after the handle unmounts', async () => {
const showHandle = ref(true);
const onUpdateActiveSnapPoint = vi.fn();
// The root must stay mounted (its emitter alive) while only the handle
// unmounts — otherwise a leaked timer could never be observed.
const Wrapper = defineComponent({
setup() {
return () => h(
DrawerRoot,
{ defaultOpen: true, snapPoints: [0.5, 1], 'onUpdate:activeSnapPoint': onUpdateActiveSnapPoint },
{
default: () => h(DrawerPortal, null, {
default: () => h(DrawerContent, { style: { height: '200px' } }, {
default: () => [
showHandle.value ? h(DrawerHandle) : null,
h(DrawerTitle, null, { default: () => 'Title' }),
h(DrawerDescription, null, { default: () => 'Desc' }),
],
}),
}),
},
);
},
});
track(mount(Wrapper, { attachTo: document.body }));
await flush();
onUpdateActiveSnapPoint.mockClear();
$('[data-drawer-handle]')!.click();
showHandle.value = false; // unmount inside the 120ms window
await flush();
await sleep(250);
expect(onUpdateActiveSnapPoint).not.toHaveBeenCalled();
});
});
describe('Drawer / drag gesture', () => {
it('closes on a swipe past the close threshold with a swipe reason', async () => {
const onUpdateOpen = vi.fn();
const onRelease = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen, onRelease });
await openSettled();
const content = $content()!;
await slowDrag(content, [[100, 300], [100, 330], [100, 360], [100, 390]]);
pointer(content, 'pointerup', 100, 390);
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
expect(onRelease).toHaveBeenCalledWith(false);
});
it('marks the content and overlay with data-swiping while dragging', async () => {
mountDrawer({ defaultOpen: true });
await openSettled();
const content = $content()!;
// Small drag + pause: stays under both the distance and velocity
// thresholds, so the drawer remains open after release.
await slowDrag(content, [[100, 300], [100, 315], [100, 330]]);
expect(content.hasAttribute('data-swiping')).toBe(true);
expect(content.classList.contains('drawer-dragging')).toBe(true);
expect($('[data-drawer-overlay]')!.hasAttribute('data-swiping')).toBe(true);
pointer(content, 'pointerup', 100, 330);
await flush();
expect(content.getAttribute('data-state')).toBe('open');
expect(content.hasAttribute('data-swiping')).toBe(false);
});
it('settles back below the threshold when released without momentum', async () => {
const onUpdateOpen = vi.fn();
const onRelease = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen, onRelease });
await openSettled();
const content = $content()!;
// 30px of a 200px drawer — under the 25% threshold; pause kills momentum.
await slowDrag(content, [[100, 300], [100, 315], [100, 330]]);
pointer(content, 'pointerup', 100, 330);
await flush();
expect(onUpdateOpen).not.toHaveBeenCalled();
expect(onRelease).toHaveBeenCalledWith(true);
expect(content.style.transform).toBe('translate3d(0px, 0px, 0px)');
});
it('does not close after the user reverses past the cancel threshold', async () => {
// "Changed my mind": drag well past the close threshold, pull back, hold,
// release — the drawer must stay open even though the release point alone
// clears the distance threshold.
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen });
await openSettled();
const content = $content()!;
await slowDrag(content, [[100, 300], [100, 360], [100, 420], [100, 360]]);
pointer(content, 'pointerup', 100, 360);
await flush();
expect(onUpdateOpen).not.toHaveBeenCalled();
expect(content.getAttribute('data-state')).toBe('open');
});
it('recovers cleanly from pointercancel mid-drag', async () => {
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen });
await openSettled();
const content = $content()!;
await fastDrag(content, [[100, 300], [100, 330], [100, 360]]);
expect(content.classList.contains('drawer-dragging')).toBe(true);
pointer(content, 'pointercancel', 100, 360);
await flush();
expect(content.classList.contains('drawer-dragging')).toBe(false);
expect(content.hasAttribute('data-swiping')).toBe(false);
expect(onUpdateOpen).not.toHaveBeenCalled();
expect(content.style.transform).toBe('translate3d(0px, 0px, 0px)');
// The next gesture still works.
await slowDrag(content, [[100, 300], [100, 340], [100, 380], [100, 420]]);
pointer(content, 'pointerup', 100, 420);
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
});
it('ignores a cross-axis gesture (axis lock)', async () => {
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen });
await openSettled();
const content = $content()!;
// Mostly-horizontal movement on a bottom drawer must never latch a drag.
await fastDrag(content, [[100, 300], [140, 305], [180, 310], [220, 315]]);
pointer(content, 'pointerup', 220, 315);
await flush();
expect(content.classList.contains('drawer-dragging')).toBe(false);
expect(onUpdateOpen).not.toHaveBeenCalled();
});
it('scales the close-out animation with the fling velocity', async () => {
const onUpdateOpen = vi.fn();
const onAnimationEnd = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen, onAnimationEnd });
await openSettled();
const content = $content()!;
// Rapid successive moves keep the instantaneous velocity high.
await fastDrag(content, [[100, 300], [100, 330], [100, 360], [100, 390]]);
pointer(content, 'pointerup', 100, 390);
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
// The inline duration overrides the stylesheet's 0.5s for this close only.
expect(content.style.animationDuration).not.toBe('');
expect(Number.parseFloat(content.style.animationDuration)).toBeLessThan(0.5);
// animationEnd follows the (scaled) animation, via the real animationend.
await vi.waitFor(() => expect(onAnimationEnd).toHaveBeenCalledWith(false), { timeout: 1000 });
});
it('keeps the default close duration for a slow release past the threshold', async () => {
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen });
await openSettled();
const content = $content()!;
await slowDrag(content, [[100, 300], [100, 330], [100, 360], [100, 390]]);
pointer(content, 'pointerup', 100, 390);
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
expect(content.style.animationDuration).toBe('');
});
});
describe('Drawer / pointer capture', () => {
it('captures the pointer on the pressed element, not the drawer content', async () => {
mountDrawer({
defaultOpen: true,
extraContent: () => h('button', { 'data-testid': 'inner' }, 'Inner'),
});
await flush();
const content = $content()!;
const button = $<HTMLButtonElement>('[data-testid="inner"]')!;
const captured: Element[] = [];
for (const el of [content, button])
(el as any).setPointerCapture = () => captured.push(el);
pointer(button, 'pointerdown', 100, 300);
// Capturing on the drawer would retarget the compat mouse events, so the
// button would never receive `click` — the capture must land on the button.
expect(captured).toEqual([button]);
pointer(button, 'pointerup', 100, 300);
await flush();
expect(content.getAttribute('data-state')).toBe('open');
});
it('captures on the handle for handleOnly gestures', async () => {
mountDrawer({ defaultOpen: true, handleOnly: true });
await flush();
const content = $content()!;
const handle = $('[data-drawer-handle]')!;
const hitarea = $('[data-drawer-handle-hitarea]')!;
const captured: Element[] = [];
for (const el of [content, handle, hitarea])
(el as any).setPointerCapture = () => captured.push(el);
pointer(hitarea, 'pointerdown', 100, 300);
expect(captured).toEqual([handle]);
pointer(hitarea, 'pointerup', 100, 300);
await flush();
});
});
describe('Drawer / lifecycle machine', () => {
it('resets the active snap point only when a close actually settles', async () => {
const open = ref(true);
const active = ref<number | string | null | undefined>(0.9);
const Wrapper = defineComponent({
setup() {
return () => h(
DrawerRoot,
{
open: open.value,
snapPoints: [0.4, 0.9],
activeSnapPoint: active.value,
'onUpdate:open': (v: boolean) => { open.value = v; },
'onUpdate:activeSnapPoint': (v: number | string) => { active.value = v; },
},
{
default: () => h(DrawerPortal, null, {
default: () => h(DrawerContent, { style: { height: '200px' } }, {
default: () => [
h(DrawerTitle, null, { default: () => 'Title' }),
h(DrawerDescription, null, { default: () => 'Desc' }),
],
}),
}),
},
);
},
});
track(mount(Wrapper, { attachTo: document.body }));
await flush();
// Close, then reopen before the exit settles: the pending close cleanup
// must NOT fire on the now-live drawer (the old fixed 500ms timeout did).
open.value = false;
await flush();
await sleep(60);
open.value = true;
await flush();
await sleep(700);
expect(active.value).toBe(0.9);
// A close that actually settles still resets to the first snap point.
open.value = false;
await flush();
await sleep(700);
expect(active.value).toBe(0.4);
});
});
describe('Drawer / scroll containers', () => {
function scrollerContent(direction: 'vertical' | 'horizontal') {
return () => h(
'div',
{
'data-testid': 'scroller',
style: direction === 'vertical'
? 'height: 100px; overflow-y: auto;'
: 'width: 100px; overflow-x: auto;',
},
[h('div', {
style: direction === 'vertical' ? 'height: 400px;' : 'width: 400px; height: 20px;',
}, [h('span', { 'data-testid': 'leaf' }, 'content')])],
);
}
it('lets a mid-scroll container own the gesture (vertical)', async () => {
const onUpdateOpen = vi.fn();
mountDrawer({ defaultOpen: true, onUpdateOpen, extraContent: scrollerContent('vertical') });
await openSettled();
const content = $content()!;
const scroller = $('[data-testid="scroller"]')!;
const leaf = $('[data-testid="leaf"]')!;
scroller.scrollTop = 50;
await slowDrag(leaf, [[100, 300], [100, 340], [100, 380], [100, 420]]);
pointer(leaf, 'pointerup', 100, 420);
await flush();
expect(content.classList.contains('drawer-dragging')).toBe(false);
expect(onUpdateOpen).not.toHaveBeenCalled();
// At the top edge the same gesture is a dismiss.
scroller.scrollTop = 0;
await sleep(150); // clear the scroll-lock timeout
await slowDrag(leaf, [[100, 300], [100, 340], [100, 380], [100, 420]]);
pointer(leaf, 'pointerup', 100, 420);
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
});
it('respects horizontal scroll containers in side drawers', async () => {
// Regression: left/right drawers used to skip every shouldDrag check.
const onUpdateOpen = vi.fn();
mountDrawer({
defaultOpen: true,
direction: 'right',
onUpdateOpen,
extraContent: scrollerContent('horizontal'),
});
await openSettled();
const content = $content()!;
const scroller = $('[data-testid="scroller"]')!;
const leaf = $('[data-testid="leaf"]')!;
scroller.scrollLeft = 50;
await slowDrag(leaf, [[100, 300], [140, 300], [180, 300], [220, 300]]);
pointer(leaf, 'pointerup', 220, 300);
await flush();
expect(content.classList.contains('drawer-dragging')).toBe(false);
expect(onUpdateOpen).not.toHaveBeenCalled();
scroller.scrollLeft = 0;
await sleep(150);
await slowDrag(leaf, [[100, 300], [140, 300], [180, 300], [220, 300]]);
pointer(leaf, 'pointerup', 220, 300);
await flush();
expect(onUpdateOpen).toHaveBeenCalledWith(false, { reason: 'swipe' });
});
});
describe('Drawer / snap points', () => {
it('positions the drawer at the first snap point and exposes the offsets', async () => {
mountDrawer({ defaultOpen: true, snapPoints: [0.5, 1] });
await flush();
const content = $content()!;
expect(content.getAttribute('data-drawer-snap-points')).toBe('true');
const expected = Math.round(window.innerHeight - window.innerHeight * 0.5);
await vi.waitFor(() => {
expect(content.style.transform).toBe(`translate3d(0px, ${expected}px, 0px)`);
});
expect(content.style.getPropertyValue('--snap-point-height')).toBe(`${expected}px`);
});
it('resolves px and rem snap points', async () => {
mountDrawer({ defaultOpen: true, snapPoints: ['10rem', '500px'] });
await flush();
const content = $content()!;
const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
const expected = Math.round(window.innerHeight - 10 * rem);
await vi.waitFor(() => {
expect(content.style.transform).toBe(`translate3d(0px, ${expected}px, 0px)`);
});
});
});
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import axe from 'axe-core';
import { defineComponent, h, nextTick } from 'vue';
import {
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHandle,
DrawerOverlay,
DrawerPortal,
DrawerRoot,
DrawerTitle,
DrawerTrigger,
} from '../index';
async function violations(element: Element) {
const results = await axe.run(element);
return results.violations;
}
async function flush() {
await nextTick();
await nextTick();
await nextTick();
}
function drawerFixture(defaultOpen: boolean) {
return defineComponent({
setup() {
return () => h(DrawerRoot, { defaultOpen }, {
default: () => [
h(DrawerTrigger, null, { default: () => 'Open drawer' }),
h(DrawerPortal, null, {
default: () => [
h(DrawerOverlay),
h(DrawerContent, null, {
default: () => [
h(DrawerHandle),
h(DrawerTitle, null, { default: () => 'Drawer title' }),
h(DrawerDescription, null, { default: () => 'Drawer description' }),
h(DrawerClose, null, { default: () => 'Close' }),
],
}),
],
}),
],
});
},
});
}
describe('Drawer a11y', () => {
let wrapper: ReturnType<typeof mount> | undefined;
afterEach(() => {
wrapper?.unmount();
wrapper = undefined;
document.body.innerHTML = '';
document.body.removeAttribute('style');
});
it('has no axe violations when closed', async () => {
wrapper = mount(drawerFixture(false), { attachTo: document.body });
await flush();
expect(await violations(document.body)).toHaveLength(0);
});
it('has no axe violations when open', async () => {
wrapper = mount(drawerFixture(true), { attachTo: document.body });
await flush();
expect(await violations(document.body)).toHaveLength(0);
});
});
@@ -0,0 +1,224 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
computeSettleDuration,
createReverseCancelTracker,
createVelocityTracker,
findScrollableAncestor,
isAtScrollEdge,
} from '../gesture';
import { MAX_VELOCITY_AGE, MIN_SETTLE_DURATION, MIN_VELOCITY_DT, TRANSITIONS } from '../constants';
afterEach(() => {
document.body.innerHTML = '';
});
describe('createVelocityTracker', () => {
it('computes instantaneous velocity from the trailing pair of samples', () => {
const tracker = createVelocityTracker();
tracker.add(0, 0);
tracker.add(10, 20); // 0.5 px/ms — but superseded below
tracker.add(50, 40); // (50-10)/20 = 2 px/ms
expect(tracker.read(45)).toBe(2);
});
it('reads 0 when the pointer paused before release', () => {
const tracker = createVelocityTracker();
tracker.add(0, 0);
tracker.add(100, 20);
expect(tracker.read(20 + MAX_VELOCITY_AGE + 1)).toBe(0);
});
it('clamps tiny sample intervals so same-frame bursts do not spike', () => {
const tracker = createVelocityTracker();
tracker.add(0, 0);
tracker.add(32, 1); // dt clamped 1 → MIN_VELOCITY_DT
expect(tracker.read(2)).toBe(32 / MIN_VELOCITY_DT);
});
it('reads 0 before two samples exist', () => {
const tracker = createVelocityTracker();
expect(tracker.read(0)).toBe(0);
tracker.add(10, 0);
expect(tracker.read(1)).toBe(0);
});
it('ignores out-of-order samples', () => {
const tracker = createVelocityTracker();
tracker.add(0, 100);
tracker.add(50, 120);
tracker.add(999, 90); // stale timestamp — must not produce a velocity
expect(tracker.read(121)).toBe(50 / 20);
});
});
describe('createReverseCancelTracker', () => {
it('cancels once an armed gesture pulls back past the threshold', () => {
const tracker = createReverseCancelTracker();
tracker.update(60); // armed (> 20)
expect(tracker.cancelled).toBe(false);
tracker.update(45); // pulled back 15 > 10
expect(tracker.cancelled).toBe(true);
});
it('does not cancel before the arm distance', () => {
const tracker = createReverseCancelTracker();
tracker.update(15);
tracker.update(0); // pulled back 15, but max never armed
expect(tracker.cancelled).toBe(false);
});
it('tolerates jitter below the reverse threshold', () => {
const tracker = createReverseCancelTracker();
tracker.update(80);
tracker.update(72); // only 8 back
expect(tracker.cancelled).toBe(false);
});
it('re-arms when the drag surpasses its previous furthest point', () => {
const tracker = createReverseCancelTracker();
tracker.update(60);
tracker.update(40);
expect(tracker.cancelled).toBe(true);
tracker.update(70); // renewed intent
expect(tracker.cancelled).toBe(false);
});
});
describe('computeSettleDuration', () => {
it('keeps the default duration for slow releases', () => {
expect(computeSettleDuration(300, 0.1)).toBe(TRANSITIONS.DURATION);
expect(computeSettleDuration(300, 0)).toBe(TRANSITIONS.DURATION);
});
it('scales the duration down with a hard flick', () => {
// 100px left at 2px/ms → 50ms, clamped up to the minimum.
expect(computeSettleDuration(100, 2)).toBe(MIN_SETTLE_DURATION / 1000);
// 400px left at 1px/ms → 400ms.
expect(computeSettleDuration(400, 1)).toBe(0.4);
});
it('never exceeds the default duration', () => {
expect(computeSettleDuration(10_000, 0.5)).toBe(TRANSITIONS.DURATION);
});
it('falls back on degenerate distances', () => {
expect(computeSettleDuration(0, 3)).toBe(TRANSITIONS.DURATION);
expect(computeSettleDuration(Number.NaN, 3)).toBe(TRANSITIONS.DURATION);
});
});
function scrollableFixture() {
document.body.innerHTML = `
<div id="drawer" style="height: 200px;">
<div id="scroller" style="height: 100px; width: 100px; overflow: auto;">
<div id="inner" style="height: 400px; width: 400px;">
<span id="leaf">content</span>
</div>
</div>
</div>
`;
return {
drawer: document.getElementById('drawer')! as HTMLElement,
scroller: document.getElementById('scroller')! as HTMLElement,
leaf: document.getElementById('leaf')! as HTMLElement,
};
}
describe('findScrollableAncestor', () => {
it('finds the nearest scrollable ancestor along the axis', () => {
const { drawer, scroller, leaf } = scrollableFixture();
expect(findScrollableAncestor(leaf, drawer, 'y')).toBe(scroller);
expect(findScrollableAncestor(leaf, drawer, 'x')).toBe(scroller);
});
it('returns null when nothing scrolls', () => {
const { drawer } = scrollableFixture();
expect(findScrollableAncestor(drawer, drawer, 'y')).toBeNull();
});
it('stops at the boundary', () => {
const { scroller, leaf } = scrollableFixture();
const inner = document.getElementById('inner')! as HTMLElement;
// Boundary below the scroller — the walk must not escape it.
expect(findScrollableAncestor(leaf, inner, 'y')).toBeNull();
void scroller;
});
it('ignores overflow visible/hidden containers', () => {
document.body.innerHTML = `
<div id="drawer">
<div id="clipped" style="height: 50px; overflow: hidden;">
<div style="height: 300px;"><span id="leaf">x</span></div>
</div>
</div>
`;
const drawer = document.getElementById('drawer')! as HTMLElement;
const leaf = document.getElementById('leaf')! as HTMLElement;
expect(findScrollableAncestor(leaf, drawer, 'y')).toBeNull();
});
});
describe('isAtScrollEdge', () => {
it('bottom drawer requires the scroller at its top', () => {
const { scroller } = scrollableFixture();
scroller.scrollTop = 0;
expect(isAtScrollEdge(scroller, 'bottom')).toBe(true);
scroller.scrollTop = 50;
expect(isAtScrollEdge(scroller, 'bottom')).toBe(false);
});
it('top drawer requires the scroller at its bottom', () => {
const { scroller } = scrollableFixture();
scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
expect(isAtScrollEdge(scroller, 'top')).toBe(true);
scroller.scrollTop = 0;
expect(isAtScrollEdge(scroller, 'top')).toBe(false);
});
it('right drawer requires the scroller at its left edge', () => {
const { scroller } = scrollableFixture();
scroller.scrollLeft = 0;
expect(isAtScrollEdge(scroller, 'right')).toBe(true);
scroller.scrollLeft = 40;
expect(isAtScrollEdge(scroller, 'right')).toBe(false);
});
it('left drawer requires the scroller at its right edge', () => {
const { scroller } = scrollableFixture();
scroller.scrollLeft = scroller.scrollWidth - scroller.clientWidth;
expect(isAtScrollEdge(scroller, 'left')).toBe(true);
scroller.scrollLeft = 0;
expect(isAtScrollEdge(scroller, 'left')).toBe(false);
});
});
@@ -0,0 +1,192 @@
import { describe, expect, it } from 'vitest';
import {
findSnapPointIndex,
projectSnapRelease,
resolveSnapPointOffset,
resolveSnapPointSize,
} from '../snapping';
const WINDOW = 800;
const REM = 16;
describe('resolveSnapPointSize', () => {
it('treats numbers in (0, 1] as window fractions', () => {
expect(resolveSnapPointSize(0.5, WINDOW, REM)).toBe(400);
expect(resolveSnapPointSize(1, WINDOW, REM)).toBe(WINDOW);
});
it('treats numbers above 1 as pixels', () => {
expect(resolveSnapPointSize(620, WINDOW, REM)).toBe(620);
});
it('parses px strings', () => {
expect(resolveSnapPointSize('148px', WINDOW, REM)).toBe(148);
expect(resolveSnapPointSize('148.6px', WINDOW, REM)).toBe(149);
});
it('parses rem strings against the root font size', () => {
expect(resolveSnapPointSize('30rem', WINDOW, REM)).toBe(480);
expect(resolveSnapPointSize('30rem', WINDOW, 20)).toBe(600);
});
it('rejects unknown units and degenerate values', () => {
expect(resolveSnapPointSize('50%', WINDOW, REM)).toBeNull();
expect(resolveSnapPointSize('10vh', WINDOW, REM)).toBeNull();
expect(resolveSnapPointSize('abc', WINDOW, REM)).toBeNull();
expect(resolveSnapPointSize('-10px', WINDOW, REM)).toBeNull();
expect(resolveSnapPointSize(0, WINDOW, REM)).toBeNull();
expect(resolveSnapPointSize(-0.5, WINDOW, REM)).toBeNull();
expect(resolveSnapPointSize(Number.NaN, WINDOW, REM)).toBeNull();
});
});
describe('resolveSnapPointOffset', () => {
it('signs the translate toward the anchored edge', () => {
expect(resolveSnapPointOffset(0.25, 'bottom', WINDOW, REM)).toBe(600);
expect(resolveSnapPointOffset(0.25, 'right', WINDOW, REM)).toBe(600);
expect(resolveSnapPointOffset(0.25, 'top', WINDOW, REM)).toBe(-600);
expect(resolveSnapPointOffset(0.25, 'left', WINDOW, REM)).toBe(-600);
});
it('clamps oversized snap points at fully open', () => {
expect(resolveSnapPointOffset(1200, 'bottom', WINDOW, REM)).toBe(0);
});
it('maps invalid points to NaN', () => {
expect(resolveSnapPointOffset('50%', 'bottom', WINDOW, REM)).toBeNaN();
});
});
describe('findSnapPointIndex', () => {
const points = [0.25, '400px', '30rem'];
it('matches by identity first', () => {
expect(findSnapPointIndex(points, '400px', WINDOW, REM)).toBe(1);
});
it('matches equivalent representations by resolved size', () => {
expect(findSnapPointIndex(points, 0.5, WINDOW, REM)).toBe(1); // 0.5 * 800 = 400px
expect(findSnapPointIndex(points, 480, WINDOW, REM)).toBe(2); // 30rem = 480px
});
it('returns null when nothing matches', () => {
expect(findSnapPointIndex(points, 0.9, WINDOW, REM)).toBeNull();
expect(findSnapPointIndex(points, null, WINDOW, REM)).toBeNull();
expect(findSnapPointIndex(points, undefined, WINDOW, REM)).toBeNull();
});
});
describe('projectSnapRelease', () => {
// Dismiss-positive space on an 800px-tall drawer: fully open = 0, closed = 800.
const base = {
offsets: [600, 400, 0], // least → most visible
drawerSize: 800,
dismissible: true,
sequential: false,
};
it('snaps to the point nearest the drag target when slow', () => {
// From 600, dragged 180 toward open → 420 → nearest is 400.
expect(projectSnapRelease({
...base,
activeIndex: 0,
draggedDistance: 180,
velocity: 0,
})).toEqual({ type: 'snap', index: 1 });
});
it('stays on the active point after a tiny slow drag', () => {
expect(projectSnapRelease({
...base,
activeIndex: 0,
draggedDistance: 40,
velocity: 0,
})).toEqual({ type: 'snap', index: 0 });
});
it('projects a fling across points the drag alone would not reach', () => {
// From 600, dragged only 40 toward open, but flung at -1.5 px/ms
// (toward open) → 560 - 450 = 110 → nearest is 0 (fully open).
expect(projectSnapRelease({
...base,
activeIndex: 0,
draggedDistance: 40,
velocity: -1.5,
})).toEqual({ type: 'snap', index: 2 });
});
it('closes when the projection lands nearer to fully-closed', () => {
// From 600, dragged 100 toward dismiss → 700; 100 from closed vs 100 from
// 600 — ties stay open; add a dismiss fling to push past.
expect(projectSnapRelease({
...base,
activeIndex: 0,
draggedDistance: -100,
velocity: 0.6,
})).toEqual({ type: 'close' });
});
it('never closes a non-dismissible drawer', () => {
expect(projectSnapRelease({
...base,
dismissible: false,
activeIndex: 0,
draggedDistance: -150,
velocity: 2,
})).toEqual({ type: 'snap', index: 0 });
});
it('clamps the fling velocity', () => {
// Absurd velocity toward open must land on the last point, not overshoot
// into an invalid index.
expect(projectSnapRelease({
...base,
activeIndex: 0,
draggedDistance: 0,
velocity: -50,
})).toEqual({ type: 'snap', index: 2 });
});
it('skips NaN offsets', () => {
expect(projectSnapRelease({
...base,
offsets: [600, Number.NaN, 0],
activeIndex: 0,
draggedDistance: 250, // → 350, nearest usable is 600? |350-600|=250 vs |350-0|=350
velocity: 0,
})).toEqual({ type: 'snap', index: 0 });
});
describe('sequential mode', () => {
const sequential = { ...base, sequential: true };
it('advances a single step on a physical crossing', () => {
// From 600 dragged far toward open (target 100, crossed 400) — but only
// one step is allowed.
expect(projectSnapRelease({
...sequential,
activeIndex: 0,
draggedDistance: 500,
velocity: 0,
})).toEqual({ type: 'snap', index: 1 });
});
it('advances on a fast fling without a crossing', () => {
expect(projectSnapRelease({
...sequential,
activeIndex: 1,
draggedDistance: 60,
velocity: -0.8,
})).toEqual({ type: 'snap', index: 2 });
});
it('stays put on a slow drag without a crossing', () => {
expect(projectSnapRelease({
...sequential,
activeIndex: 1,
draggedDistance: 60,
velocity: 0,
})).toEqual({ type: 'snap', index: 1 });
});
});
});
@@ -24,3 +24,33 @@ export const WINDOW_TOP_OFFSET = 26;
/** Class applied to the drawer element while a drag is in progress. */
export const DRAG_CLASS = 'drawer-dragging';
/** Smallest dt (ms) a velocity sample may span — clamps out same-frame event spikes. */
export const MIN_VELOCITY_DT = 16;
/** A velocity sample older than this (ms) at release means the pointer stopped — velocity is 0. */
export const MAX_VELOCITY_AGE = 80;
/** Dismiss displacement (px) a gesture must reach before the reverse-cancel detector arms. */
export const REVERSE_CANCEL_ARM_DISTANCE = 20;
/** Pulling back this many px from the gesture's furthest point cancels the dismiss. */
export const REVERSE_CANCEL_THRESHOLD = 10;
/** Pointer movement (px) needed before the gesture locks onto an axis. */
export const AXIS_LOCK_DISTANCE = 2;
/** Snap release: velocity (px/ms) below which the fling projection is skipped. */
export const SNAP_VELOCITY_THRESHOLD = 0.5;
/** Snap release: ms worth of travel a fling projects the release target ahead. */
export const SNAP_VELOCITY_MULTIPLIER = 300;
/** Snap release: velocity clamp (px/ms) for the fling projection. */
export const MAX_SNAP_VELOCITY = 4;
/** Release velocity (px/ms) below which the settle keeps the default duration. */
export const SETTLE_VELOCITY_THRESHOLD = 0.2;
/** Fastest settle transition (ms) a hard flick can produce. */
export const MIN_SETTLE_DURATION = 80;
+38 -10
View File
@@ -1,13 +1,24 @@
import type { Ref } from 'vue';
import type { Ref, ShallowRef } from 'vue';
import { useContextFactory } from '@robonen/vue';
import type { MaybeElementRef } from '@robonen/vue';
import type { DrawerDirection } from './types';
import type { DrawerDirection, DrawerOpenChangeReason, DrawerPhase } from './types';
export interface DrawerRootContext {
/** Source-of-truth open state (also bound to the underlying Dialog). */
open: Ref<boolean>;
/** Alias of {@link open}; kept for parity with consumers reading `isOpen`. */
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). */
modal: Ref<boolean>;
/** Becomes `true` the first time the drawer opens; gates Safari position fixes. */
@@ -20,11 +31,11 @@ export interface DrawerRootContext {
handleRef: MaybeElementRef<HTMLElement | undefined>;
/** Whether a pointer drag is currently in progress. */
isDragging: Ref<boolean>;
/** Timestamp the active drag started, for velocity calculations. */
dragStartTime: Ref<Date | null>;
/** `event.timeStamp` of the active drag's start (ms, `performance.now()` clock). */
dragStartTime: Ref<number | null>;
/** Latched once a drag is permitted, so it can't be cancelled mid-gesture. */
isAllowedToDrag: Ref<boolean>;
/** Configured snap points (fractions of the screen or px strings). */
/** Configured snap points (fractions of the screen, px numbers, or px/rem strings). */
snapPoints: Ref<Array<number | string> | undefined>;
/** Whether any snap points are configured. */
hasSnapPoints: Ref<boolean>;
@@ -38,18 +49,35 @@ export interface DrawerRootContext {
dismissible: Ref<boolean>;
/** Measured height of the drawer content in px. */
drawerHeightRef: Ref<number>;
/** Pixel offset of each snap point along the drag axis. */
/** Pixel offset of each snap point along the drag axis (`NaN` for invalid points). */
snapPointsOffset: Ref<number[]>;
/** The edge the drawer is anchored to. */
direction: Ref<DrawerDirection>;
/** Begin a drag gesture. */
onPress: (event: PointerEvent) => void;
/**
* Begin a drag gesture. `captureTarget` is the element that receives pointer
* capture (defaults to the pressed element — capturing any higher, e.g. on
* the drawer itself, would retarget `click` away from controls inside; the
* handle passes itself so `handleOnly` gestures keep receiving moves).
*/
onPress: (event: PointerEvent, captureTarget?: HTMLElement) => void;
/** Update the drawer position during a drag. */
onDrag: (event: PointerEvent) => void;
/** Settle the drawer (snap, close, or reset) when the pointer is released. */
onRelease: (event: PointerEvent) => void;
/** Programmatically close the drawer. */
closeDrawer: () => void;
/**
* Abort the active drag without a user release (`pointercancel`, lost
* capture): resets the drag state and settles the drawer back in place.
*/
onCancel: (event: PointerEvent) => void;
/** Programmatically close the drawer, optionally tagging what caused it. */
closeDrawer: (reason?: DrawerOpenChangeReason) => void;
/**
* Tag the next open-state flip with a reason. Consumed (and cleared) by
* DrawerRoot's `update:open` emitter; auto-expires when no flip follows.
*/
armReason: (reason: DrawerOpenChangeReason) => void;
/** Reason armed for the next open-state flip, if any. */
pendingReason: { current: DrawerOpenChangeReason | undefined };
/** Whether the overlay should fade with the drag at the current snap point. */
shouldFade: Ref<boolean>;
/** Snap point index from which the overlay starts fading. */
+473 -234
View File
@@ -2,21 +2,31 @@ import type { Ref } from 'vue';
import { computed, ref, shallowRef, watch, watchEffect } from 'vue';
import { isClient } from '@robonen/platform/multi';
import { getTranslate, resetStyle, setStyle } from '@robonen/platform/browsers';
import { dampenValue, getDrawerWrapper, isVertical } from './helpers';
import { BORDER_RADIUS, DRAG_CLASS, NESTED_DISPLACEMENT, TRANSITIONS, VELOCITY_THRESHOLD, WINDOW_TOP_OFFSET } from './constants';
import { useStateMachine, useTextSelection, useWindowSize } from '@robonen/vue';
import { dampenValue, getDrawerWrapper, getScaleFactor, isVertical, translate3d, translateAxis, writeTransform } from './helpers';
import {
AXIS_LOCK_DISTANCE,
BORDER_RADIUS,
DRAG_CLASS,
NESTED_DISPLACEMENT,
TRANSITIONS,
VELOCITY_THRESHOLD,
} from './constants';
import type { GestureAxis, ReverseCancelTracker, VelocityTracker } from './gesture';
import { computeSettleDuration, createReverseCancelTracker, createVelocityTracker, findScrollableAncestor, isAtScrollEdge } from './gesture';
import { useSnapPoints } from './useSnapPoints';
import { usePositionFixed } from './usePositionFixed';
import type { DrawerRootContext } from './context';
import type { DrawerDirection } from './types';
import type { DrawerDirection, DrawerOpenChangeDetails, DrawerOpenChangeReason } from './types';
/** Shared, never-mutated — avoids allocating `{ transition: 'none' }` per drag frame. */
const STYLE_NO_TRANSITION = { transition: 'none' };
export interface WithoutFadeFromProps {
/**
* Fractions (01) of the screen each snap point occupies, ordered from least
* to most visible — e.g. `[0.2, 0.5, 0.8]`. Px strings (e.g. `'200px'`) are
* also accepted and ignore screen height.
* Snap points ordered from least to most visible: fractions (01) of the
* screen, raw pixel numbers (> 1), or `'Npx'`/`'Nrem'` strings e.g.
* `[0.2, '148px', 0.8]`.
*/
snapPoints?: Array<number | string>;
/** Index of the snap point from which the overlay fade begins. Defaults to the last. */
@@ -82,6 +92,12 @@ export type DrawerRootProps = {
handleOnly?: boolean;
/** Don't restore scroll position when the drawer closes after a navigation. */
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;
export interface UseDrawerProps {
@@ -101,6 +117,7 @@ export interface UseDrawerProps {
noBodyStyles: Ref<boolean>;
preventScrollRestoration: Ref<boolean>;
handleOnly: Ref<boolean>;
snapToSequentialPoints: Ref<boolean>;
}
export interface DrawerRootEmits {
@@ -110,8 +127,8 @@ export interface DrawerRootEmits {
(e: 'release', open: boolean): void;
/** Fired when the drawer begins closing. */
(e: 'close'): void;
/** Two-way binding for the open state. */
(e: 'update:open', open: boolean): void;
/** Two-way binding for the open state. `details.reason` says what flipped it. */
(e: 'update:open', open: boolean, details?: DrawerOpenChangeDetails): void;
/** Two-way binding for the active snap point. */
(e: 'update:activeSnapPoint', val: string | number): void;
/** Fired after the open/close animation ends, with the open state at that time. */
@@ -129,6 +146,47 @@ export interface DrawerHandleProps {
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> {
return prop && !!prop.value ? (prop as Ref<T>) : defaultRef;
}
@@ -157,19 +215,19 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
noBodyStyles,
handleOnly,
preventScrollRestoration,
snapToSequentialPoints,
} = props;
const hasBeenOpened = ref(open.value);
const isDragging = ref(false);
const justReleased = ref(false);
const isAllowedToDrag = ref(false);
const dragStartTime = ref<number | null>(null);
const overlayRef = shallowRef<HTMLElement | undefined>(undefined);
const openTime = ref<Date | null>(null);
const dragStartTime = ref<Date | null>(null);
const dragEndTime = ref<Date | null>(null);
const lastTimeDragPrevented = ref<Date | null>(null);
const isAllowedToDrag = ref(false);
// Timestamps on the `performance.now()` clock (same origin as event.timeStamp).
let openTime: number | null = null;
let lastTimeDragPrevented: number | null = null;
const nestedOpenChangeTimer = ref<number | null>(null);
@@ -185,11 +243,31 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
const handleRef = shallowRef<HTMLElement | undefined>(undefined);
// 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 {
activeSnapPointIndex,
onRelease: onReleaseSnapPoints,
snapPointsOffset,
onDrag: onDragSnapPoints,
restoreActiveSnapPoint,
shouldFade,
getPercentageDragged: getSnapPointsPercentageDragged,
} = useSnapPoints({
@@ -200,13 +278,16 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
overlayRef,
onSnapPointChange,
direction,
snapToSequentialPoints,
windowWidth,
windowHeight,
});
function onSnapPointChange(activeSnapPointIndex: number, snapPointsOffset: number[]) {
// Refresh openTime when we reach the last snap point so scrollable content
// there isn't immediately draggable.
if (snapPoints.value && activeSnapPointIndex === snapPointsOffset.length - 1)
openTime.value = new Date();
openTime = performance.now();
}
usePositionFixed({
@@ -218,203 +299,300 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
preventScrollRestoration,
});
function getScale() {
return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth;
}
// The drawer's lifecycle as explicit phases. `OPEN`/`CLOSE` are driven by the
// shared `open` ref below; `SETTLE` arrives from DrawerRoot when the enter/exit
// animation actually ends (element event or its fallback timeout). Close-side
// cleanup lives on the `closed` entry hook instead of duration-guessing
// timeouts: re-opening mid-close moves `closing → opening`, so it can never
// fire on a live drawer.
const lifecycle = useStateMachine({
initial: open.value ? 'open' : 'closed',
states: {
closed: {
entry: () => {
if (snapPoints.value)
activeSnapPoint.value = snapPoints.value[0];
},
on: { OPEN: 'opening' },
},
opening: {
entry: () => {
openTime = performance.now();
hasBeenOpened.value = true;
// A fast-flick close writes an inline animation-duration override;
// reopening before that exit settles reuses the SAME element
// (Presence keeps it alive), so clear the override here or the enter
// — and any later gentle exit — replays at flick speed.
drawerRef.value?.style.removeProperty('animation-duration');
overlayRef.value?.style.removeProperty('animation-duration');
},
on: { SETTLE: 'open', CLOSE: 'closing' },
},
open: { on: { CLOSE: 'closing' } },
closing: { on: { SETTLE: 'closed', OPEN: 'opening' } },
},
});
let gesture: GestureState | null = null;
function shouldDrag(el: EventTarget | null, isDraggingInDirection: boolean, now: number): boolean {
const g = gesture!;
function shouldDrag(el: EventTarget | null, isDraggingInDirection: boolean) {
if (!el)
return false;
let element = el as HTMLElement;
const highlightedText = globalThis.getSelection()?.toString();
const swipeAmount = drawerRef.value ? getTranslate(drawerRef.value, isVertical(direction.value) ? 'y' : 'x') : null;
const date = new Date();
if (element.hasAttribute('data-drawer-no-drag') || element.closest('[data-drawer-no-drag]'))
const element = el as HTMLElement;
if (element.closest?.('[data-drawer-no-drag]'))
return false;
if (direction.value === 'right' || direction.value === 'left')
return true;
// Allow scrolling during the open animation.
if (openTime.value && date.getTime() - openTime.value.getTime() < 500)
if (openTime !== null && now - openTime < 500)
return false;
if (swipeAmount !== null) {
if (direction.value === 'bottom' ? swipeAmount > 0 : swipeAmount < 0)
return true;
}
// Partially hidden (a snap point below fully open, or a mid-animation
// grab) — the drawer is always draggable.
const swipeAmount = g.translate;
// Don't drag when text is selected.
if (highlightedText && highlightedText.length > 0)
if (g.multiplier === 1 ? swipeAmount > 0 : swipeAmount < 0)
return true;
// Don't drag when text is selected (reactive — no per-move getSelection).
if (selectedText.value.length > 0)
return false;
// Don't drag right after scrolling inside the drawer.
if (
lastTimeDragPrevented.value
&& date.getTime() - lastTimeDragPrevented.value.getTime() < scrollLockTimeout.value
lastTimeDragPrevented !== null
&& now - lastTimeDragPrevented < scrollLockTimeout.value
&& swipeAmount === 0
) {
lastTimeDragPrevented.value = date;
lastTimeDragPrevented = now;
return false;
}
if (isDraggingInDirection) {
lastTimeDragPrevented.value = date;
lastTimeDragPrevented = now;
// Dragging in the open direction → allow scrolling instead.
return false;
}
// Walk up the tree; if a scrollable ancestor isn't at the top, scroll it instead of dragging.
while (element) {
if (element.scrollHeight > element.clientHeight) {
if (element.scrollTop !== 0) {
lastTimeDragPrevented.value = new Date();
return false;
}
if (element.getAttribute('role') === 'dialog')
return true;
}
element = element.parentNode as HTMLElement;
// A scroll container under the pointer owns the gesture unless it already
// sits at the edge the dismiss direction pulls away from.
if (g.scroller && !isAtScrollEdge(g.scroller, direction.value)) {
lastTimeDragPrevented = now;
return false;
}
return true;
}
// Measured once per gesture in onPress and reused every move — avoids a
// per-frame getBoundingClientRect (forced reflow) and document.querySelector.
let dragStartHeight = 0;
let dragWrapper: HTMLElement | null = null;
function onPress(event: PointerEvent, captureTarget?: HTMLElement) {
// One gesture at a time; a second touch never steals an active drag. But a
// gesture whose capture element left the DOM can never finish (its
// lostpointercapture fires at the document, past our listeners) — reclaim
// it instead of wedging every future drag.
if (gesture) {
if (gesture.captureTarget.isConnected)
return;
function onPress(event: PointerEvent) {
gesture = null;
isAllowedToDrag.value = false;
isDragging.value = false;
drawerRef.value?.classList.remove(DRAG_CLASS);
}
if (!dismissible.value && !snapPoints.value)
return;
if (drawerRef.value && !drawerRef.value.contains(event.target as Node))
if (event.button > 0)
return;
isDragging.value = true;
dragStartTime.value = new Date();
dragStartHeight = drawerRef.value?.getBoundingClientRect().height || 0;
dragWrapper = getDrawerWrapper();
(event.target as HTMLElement).setPointerCapture(event.pointerId);
pointerStart.value = isVertical(direction.value) ? event.clientY : event.clientX;
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;
dragStartTime.value = event.timeStamp;
pointerStart.value = vertical ? event.clientY : event.clientX;
gesture = {
pointerId: event.pointerId,
captureTarget: capture,
vertical,
multiplier: direction.value === 'bottom' || direction.value === 'right' ? 1 : -1,
startX: event.clientX,
startY: event.clientY,
size: (vertical ? rect.height : rect.width) || 0,
windowSize: vertical ? windowHeight.value : windowWidth.value,
scale: getScaleFactor(windowWidth.value),
// The one intentional computed-style read of the gesture: catches the
// drawer mid-animation so the drag continues from the on-screen position.
translate: getTranslate(el, axis) ?? 0,
wrapper: getDrawerWrapper(),
scroller: findScrollableAncestor(event.target as Element, el, axis),
axisLocked: false,
blocked: false,
velocity: createVelocityTracker(),
reverse: createReverseCancelTracker(),
lastOverlayOpacity: '',
lastWrapperProgress: -1,
};
}
function onDrag(event: PointerEvent) {
if (!drawerRef.value)
const g = gesture;
if (!g || event.pointerId !== g.pointerId || !isDragging.value || g.blocked || !drawerRef.value)
return;
if (isDragging.value) {
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;
const dx = event.clientX - g.startX;
const dy = event.clientY - g.startY;
// Don't allow dragging toward close past the first snap point when not dismissible.
const noCloseSnapPointsPreCondition = snapPoints.value && !dismissible.value && !isDraggingInDirection;
// Lock onto an axis on the first significant movement. A gesture that
// locks onto the cross axis is a scroll/pan — never a drawer drag.
if (!g.axisLocked) {
const absX = Math.abs(dx);
const absY = Math.abs(dy);
if (noCloseSnapPointsPreCondition && activeSnapPointIndex.value === 0)
if (absX < AXIS_LOCK_DISTANCE && absY < AXIS_LOCK_DISTANCE)
return;
const absDraggedDistance = Math.abs(draggedDistance);
const wrapper = dragWrapper;
g.axisLocked = true;
// 1 means the closed position. Height cached at drag start (no reflow).
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)`,
});
if ((absX > absY) === g.vertical) {
g.blocked = true;
return;
}
}
const opacityValue = 1 - percentageDragged;
g.velocity.add(g.vertical ? event.clientY : event.clientX, event.timeStamp);
if (shouldFade.value || (fadeFromIndex.value && activeSnapPointIndex.value === fadeFromIndex.value - 1)) {
emitDrag(percentageDragged);
const draggedDistance = (g.vertical ? g.startY - event.clientY : g.startX - event.clientX) * g.multiplier;
const isDraggingInDirection = draggedDistance > 0;
setStyle(overlayRef.value, { opacity: `${opacityValue}`, transition: 'none' }, true);
// Dismiss-positive displacement feeds the "changed my mind" detector.
g.reverse.update(-draggedDistance);
// Don't allow dragging toward close past the first snap point when not dismissible.
const noCloseSnapPointsPreCondition = snapPoints.value && !dismissible.value && !isDraggingInDirection;
if (noCloseSnapPointsPreCondition && activeSnapPointIndex.value === 0)
return;
const absDraggedDistance = Math.abs(draggedDistance);
// 1 means the closed position. Size cached at drag start (no reflow).
let percentageDragged = absDraggedDistance / (g.size || 1);
const snapPointPercentageDragged = getSnapPointsPercentageDragged(absDraggedDistance, isDraggingInDirection);
if (snapPointPercentageDragged !== null)
percentageDragged = snapPointPercentageDragged;
if (noCloseSnapPointsPreCondition && percentageDragged >= 1)
return;
// Decide-to-drag gate + one-time gesture setup. Once allowed, stay allowed
// for the whole gesture, so the class add + transition writes fire ONCE,
// not on every move.
if (!isAllowedToDrag.value) {
if (!shouldDrag(event.target, isDraggingInDirection, event.timeStamp))
return;
isAllowedToDrag.value = true;
drawerRef.value.classList.add(DRAG_CLASS);
setStyle(drawerRef.value, STYLE_NO_TRANSITION);
setStyle(overlayRef.value, STYLE_NO_TRANSITION);
}
if (snapPoints.value) {
const applied = onDragSnapPoints({ draggedDistance });
if (applied !== null)
g.translate = applied;
}
// Rubber-band past the open position when there are no snap points.
if (isDraggingInDirection && !snapPoints.value) {
const dampenedDraggedDistance = dampenValue(draggedDistance);
const translateValue = Math.min(dampenedDraggedDistance * -1, 0) * g.multiplier;
writeTransform(drawerRef.value, translateAxis(g.vertical, translateValue));
g.translate = translateValue;
return;
}
if (shouldFade.value || (fadeFromIndex.value && activeSnapPointIndex.value === fadeFromIndex.value - 1)) {
emitDrag(percentageDragged);
const overlay = overlayRef.value;
const opacity = `${1 - percentageDragged}`;
if (overlay && opacity !== g.lastOverlayOpacity) {
g.lastOverlayOpacity = opacity;
overlay.style.opacity = opacity;
overlay.style.transition = 'none';
}
}
if (wrapper && overlayRef.value && shouldScaleBackground.value) {
const scaleValue = Math.min(getScale() + percentageDragged * (1 - getScale()), 1);
const borderRadiusValue = 8 - percentageDragged * 8;
const translateValue = Math.max(0, 14 - percentageDragged * 14);
if (g.wrapper && overlayRef.value && shouldScaleBackground.value && percentageDragged !== g.lastWrapperProgress) {
g.lastWrapperProgress = percentageDragged;
setStyle(
wrapper,
{
borderRadius: `${borderRadiusValue}px`,
transform: isVertical(direction.value)
? `scale(${scaleValue}) translate3d(0, ${translateValue}px, 0)`
: `scale(${scaleValue}) translate3d(${translateValue}px, 0, 0)`,
transition: 'none',
},
true,
);
}
const scaleValue = Math.min(g.scale + percentageDragged * (1 - g.scale), 1);
const borderRadiusValue = 8 - percentageDragged * 8;
const translateValue = Math.max(0, 14 - percentageDragged * 14);
const style = g.wrapper.style;
if (!snapPoints.value) {
const translateValue = absDraggedDistance * directionMultiplier;
style.borderRadius = `${borderRadiusValue}px`;
style.transform = g.vertical
? `scale(${scaleValue}) translate3d(0, ${translateValue}px, 0)`
: `scale(${scaleValue}) translate3d(${translateValue}px, 0, 0)`;
style.transition = 'none';
}
setStyle(drawerRef.value, {
transform: isVertical(direction.value)
? `translate3d(0, ${translateValue}px, 0)`
: `translate3d(${translateValue}px, 0, 0)`,
});
}
if (!snapPoints.value) {
const translateValue = absDraggedDistance * g.multiplier;
writeTransform(drawerRef.value, translateAxis(g.vertical, translateValue));
g.translate = translateValue;
}
}
function resetDrawer() {
function resetDrawer(duration: number = TRANSITIONS.DURATION, currentSwipeAmount?: number | null) {
if (!drawerRef.value)
return;
const wrapper = getDrawerWrapper();
const currentSwipeAmount = getTranslate(drawerRef.value, isVertical(direction.value) ? 'y' : 'x');
const swipeAmount = currentSwipeAmount
?? getTranslate(drawerRef.value, isVertical(direction.value) ? 'y' : 'x');
const ease = `cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
setStyle(drawerRef.value, {
transform: 'translate3d(0, 0, 0)',
transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
transition: `transform ${duration}s ${ease}`,
});
setStyle(overlayRef.value, {
transition: `opacity ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
transition: `opacity ${duration}s ${ease}`,
opacity: '1',
});
// Keep the background scaled if we didn't swipe back down.
if (shouldScaleBackground.value && currentSwipeAmount && currentSwipeAmount > 0 && open.value) {
if (shouldScaleBackground.value && swipeAmount && swipeAmount > 0 && open.value) {
setStyle(
wrapper,
{
@@ -422,11 +600,11 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
overflow: 'hidden',
...(isVertical(direction.value)
? {
transform: `scale(${getScale()}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)`,
transform: `scale(${getScaleFactor(windowWidth.value)}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)`,
transformOrigin: 'top',
}
: {
transform: `scale(${getScale()}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)`,
transform: `scale(${getScaleFactor(windowWidth.value)}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)`,
transformOrigin: 'left',
}),
transitionProperty: 'transform, border-radius',
@@ -442,13 +620,136 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
// snap-point reset, update:open) is driven off the `open` transition below, so
// this stays the single place that closes — whatever the trigger (drag, handle,
// dialog dismissal, or a controlled `v-model:open` flip).
function closeDrawer() {
function closeDrawer(reason?: DrawerOpenChangeReason) {
if (!drawerRef.value)
return;
if (reason)
armReason(reason);
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(() => {
if (!open.value && shouldScaleBackground.value && isClient) {
// The component is invisible by the time onAnimationEnd would fire, so use a timeout.
@@ -462,99 +763,28 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
return undefined;
});
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
// the shared `open` ref: the drag/handle paths (closeDrawer), the dialog's
// dismissals (DrawerRoot.handleOpenChange), and a controlled `v-model:open`
// flip (DrawerRoot's prop watch). `update:open`/`animationEnd` are emitted by
// DrawerRoot's own watch on the same ref.
// DrawerRoot's own watch on the same ref; everything else rides the lifecycle
// machine's entry hooks.
watch(open, (o) => {
if (o) {
openTime.value = new Date();
hasBeenOpened.value = true;
lifecycle.send('OPEN');
}
else {
emitClose();
globalThis.setTimeout(() => {
if (snapPoints.value)
activeSnapPoint.value = snapPoints.value[0];
}, TRANSITIONS.DURATION * 1000);
lifecycle.send('CLOSE');
}
});
function onNestedOpenChange(o: boolean) {
const scale = o ? (window.innerWidth - NESTED_DISPLACEMENT) / window.innerWidth : 1;
const scale = o ? (windowWidth.value - NESTED_DISPLACEMENT) / windowWidth.value : 1;
const y = o ? -NESTED_DISPLACEMENT : 0;
if (nestedOpenChangeTimer.value)
globalThis.clearTimeout(nestedOpenChangeTimer.value);
clearTimeout(nestedOpenChangeTimer.value);
setStyle(drawerRef.value, {
transition: `transform ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
@@ -562,13 +792,11 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
});
if (!o && drawerRef.value) {
nestedOpenChangeTimer.value = globalThis.setTimeout(() => {
nestedOpenChangeTimer.value = setTimeout(() => {
const translateValue = getTranslate(drawerRef.value!, isVertical(direction.value) ? 'y' : 'x');
setStyle(drawerRef.value, {
transition: 'none',
transform: isVertical(direction.value)
? `translate3d(0, ${translateValue}px, 0)`
: `translate3d(${translateValue}px, 0, 0)`,
transform: translate3d(direction.value, translateValue ?? 0),
});
}, 500);
}
@@ -578,21 +806,25 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
if (percentageDragged < 0)
return;
const initialDim = isVertical(direction.value) ? window.innerHeight : window.innerWidth;
const el = drawerRef.value;
if (!el)
return;
const initialDim = isVertical(direction.value) ? windowHeight.value : windowWidth.value;
const initialScale = (initialDim - NESTED_DISPLACEMENT) / initialDim;
const newScale = initialScale + percentageDragged * (1 - initialScale);
const newTranslate = -NESTED_DISPLACEMENT + percentageDragged * NESTED_DISPLACEMENT;
setStyle(drawerRef.value, {
transform: isVertical(direction.value)
? `scale(${newScale}) translate3d(0, ${newTranslate}px, 0)`
: `scale(${newScale}) translate3d(${newTranslate}px, 0, 0)`,
transition: 'none',
});
// Per-frame path (driven by the child's drag) — direct writes, no setStyle.
el.style.transform = isVertical(direction.value)
? `scale(${newScale}) translate3d(0, ${newTranslate}px, 0)`
: `scale(${newScale}) translate3d(${newTranslate}px, 0, 0)`;
el.style.transition = 'none';
}
function onNestedRelease(o: boolean) {
const dim = isVertical(direction.value) ? window.innerHeight : window.innerWidth;
const dim = isVertical(direction.value) ? windowHeight.value : windowWidth.value;
const scale = o ? (dim - NESTED_DISPLACEMENT) / dim : 1;
const translate = o ? -NESTED_DISPLACEMENT : 0;
@@ -609,6 +841,10 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
return {
open,
isOpen: open,
phase: lifecycle.state,
notifySettled: () => {
lifecycle.send('SETTLE');
},
modal,
keyboardIsOpen,
hasBeenOpened,
@@ -633,7 +869,10 @@ export function useDrawer(props: UseDrawerProps & DialogEmitHandlers): DrawerRoo
onPress,
onDrag,
onRelease,
onCancel,
closeDrawer,
armReason,
pendingReason,
onNestedDrag,
onNestedRelease,
onNestedOpenChange,
@@ -0,0 +1,178 @@
import { clamp } from '@robonen/stdlib';
import type { DrawerDirection } from './types';
import {
MAX_VELOCITY_AGE,
MIN_SETTLE_DURATION,
MIN_VELOCITY_DT,
REVERSE_CANCEL_ARM_DISTANCE,
REVERSE_CANCEL_THRESHOLD,
SETTLE_VELOCITY_THRESHOLD,
TRANSITIONS,
} from './constants';
/** The client-coordinate axis a drawer drags along. */
export type GestureAxis = 'x' | 'y';
export interface VelocityTracker {
/** Record a pointer sample (client coordinate along the axis + event timeStamp). */
add: (position: number, time: number) => void;
/**
* Instantaneous velocity (px/ms) over the two newest samples. Returns 0 when
* the last sample is older than {@link MAX_VELOCITY_AGE} — the pointer paused
* before release, so no fling momentum should apply.
*/
read: (now: number) => number;
reset: () => void;
}
/**
* Instantaneous release velocity from the trailing pair of pointer samples,
* instead of averaging the whole gesture: "slow pull, then flick" reads as a
* flick, and "fast start, stop, release" reads as a stop.
*/
export function createVelocityTracker(): VelocityTracker {
let lastPosition = 0;
let lastTime = Number.NaN;
let velocity = 0;
return {
add(position, time) {
if (!Number.isNaN(lastTime) && time > lastTime) {
// Clamp dt so same-frame event bursts don't produce huge spikes.
const dt = Math.max(time - lastTime, MIN_VELOCITY_DT);
velocity = (position - lastPosition) / dt;
}
lastPosition = position;
lastTime = time;
},
read(now) {
if (Number.isNaN(lastTime) || now - lastTime > MAX_VELOCITY_AGE)
return 0;
return velocity;
},
reset() {
lastPosition = 0;
lastTime = Number.NaN;
velocity = 0;
},
};
}
export interface ReverseCancelTracker {
/** Feed the current dismiss-positive displacement (px). */
update: (displacement: number) => void;
/** Whether the gesture pulled back far enough to cancel the dismiss. */
readonly cancelled: boolean;
reset: () => void;
}
/**
* Detects the "changed my mind" gesture: once the drawer has been dragged at
* least {@link REVERSE_CANCEL_ARM_DISTANCE} toward dismiss, pulling back by
* {@link REVERSE_CANCEL_THRESHOLD} from the furthest point cancels the dismiss
* even if the release still sits past the close threshold. Dragging past the
* previous furthest point re-arms the dismiss (renewed intent).
*/
export function createReverseCancelTracker(): ReverseCancelTracker {
let max = 0;
let cancelled = false;
return {
update(displacement) {
if (displacement >= max) {
max = displacement;
cancelled = false;
return;
}
if (max > REVERSE_CANCEL_ARM_DISTANCE && max - displacement > REVERSE_CANCEL_THRESHOLD)
cancelled = true;
},
get cancelled() {
return cancelled;
},
reset() {
max = 0;
cancelled = false;
},
};
}
/**
* Settle duration (in seconds) scaled by the release velocity: a hard flick
* over a short remaining distance settles in as little as
* {@link MIN_SETTLE_DURATION}ms, while a gentle release keeps the default
* {@link TRANSITIONS} duration. Never returns a duration longer than the default.
*/
export function computeSettleDuration(remainingDistance: number, velocity: number): number {
const fallback = TRANSITIONS.DURATION;
if (!Number.isFinite(remainingDistance) || remainingDistance <= 0)
return fallback;
const speed = Math.abs(velocity);
if (!Number.isFinite(speed) || speed < SETTLE_VELOCITY_THRESHOLD)
return fallback;
return clamp(remainingDistance / speed, MIN_SETTLE_DURATION, fallback * 1000) / 1000;
}
/**
* The nearest ancestor (from `start` up to and including `boundary`) that can
* scroll along `axis`. The `getComputedStyle` read runs at most once per
* candidate and only at gesture start — never per pointer move.
*/
export function findScrollableAncestor(
start: Element | null,
boundary: HTMLElement,
axis: GestureAxis,
): HTMLElement | null {
let element: Element | null = start;
while (element) {
if (element instanceof HTMLElement) {
const canScroll = axis === 'y'
? element.scrollHeight > element.clientHeight
: element.scrollWidth > element.clientWidth;
if (canScroll) {
const overflow = getComputedStyle(element)[axis === 'y' ? 'overflowY' : 'overflowX'];
if (overflow === 'auto' || overflow === 'scroll')
return element;
}
}
if (element === boundary)
break;
element = element.parentElement;
}
return null;
}
/**
* Whether a scroll container sits at the edge the dismiss gesture pulls away
* from — only then may a drag that starts inside it become a drawer gesture;
* otherwise the user is scrolling, not dismissing:
* - `bottom` drawer dismisses downward → the scroller must be at its top;
* - `top` drawer dismisses upward → at its bottom;
* - `right` drawer dismisses rightward → at its left edge;
* - `left` drawer dismisses leftward → at its right edge.
*/
export function isAtScrollEdge(scroller: HTMLElement, direction: DrawerDirection): boolean {
switch (direction) {
case 'bottom':
return scroller.scrollTop <= 0;
case 'top':
return scroller.scrollTop >= scroller.scrollHeight - scroller.clientHeight;
case 'right':
return scroller.scrollLeft <= 0;
case 'left':
return scroller.scrollLeft >= scroller.scrollWidth - scroller.clientWidth;
}
}
@@ -1,4 +1,5 @@
import type { DrawerDirection } from './types';
import { WINDOW_TOP_OFFSET } from './constants';
/**
* Whether a direction runs along the vertical axis (`top`/`bottom`) as opposed
@@ -25,3 +26,39 @@ export function dampenValue(v: number): number {
export function getDrawerWrapper(): HTMLElement | null {
return document.querySelector<HTMLElement>('[data-drawer-wrapper]');
}
/**
* The background-scale factor for a given window width (the stacked-card look
* leaves {@link WINDOW_TOP_OFFSET}px of the page peeking out).
*/
export function getScaleFactor(windowWidth: number): number {
return (windowWidth - WINDOW_TOP_OFFSET) / windowWidth;
}
/**
* A GPU-friendly translate along an axis, from a pre-resolved axis flag — the
* drag hot path variant: no direction-string comparisons per frame.
*/
export function translateAxis(vertical: boolean, value: number): string {
return vertical
? `translate3d(0, ${value}px, 0)`
: `translate3d(${value}px, 0, 0)`;
}
/**
* {@link translateAxis} keyed by direction, for cold paths that hold the
* direction string rather than a gesture snapshot.
*/
export function translate3d(direction: DrawerDirection, value: number): string {
return translateAxis(isVertical(direction), value);
}
/**
* Per-frame single-property transform write for the drag hot path. Unlike
* `setStyle` this allocates nothing (no patch object, no `Object.entries`, no
* restore snapshot) — restoration is handled wholesale on release.
*/
export function writeTransform(element: HTMLElement | undefined | null, value: string): void {
if (element)
element.style.transform = value;
}
+5 -5
View File
@@ -3,11 +3,15 @@ export { default as DrawerRootNested } from './DrawerRootNested.vue';
export { default as DrawerContent } from './DrawerContent.vue';
export { default as DrawerOverlay } from './DrawerOverlay.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 { DrawerContentEmits, DrawerContentProps } from './DrawerContent.vue';
export type { DrawerOverlayProps } from './DrawerOverlay.vue';
export type { DrawerDirection, SnapPoint } from './types';
export type { DrawerTriggerProps } from './DrawerTrigger.vue';
export type { DrawerCloseProps } from './DrawerClose.vue';
export type { DrawerDirection, DrawerOpenChangeDetails, DrawerOpenChangeReason } from './types';
export { injectDrawerRootContext, provideDrawerRootContext } from './context';
export type { DrawerRootContext } from './context';
@@ -15,17 +19,13 @@ export type { DrawerRootContext } from './context';
// Parts with no drawer-specific behaviour reuse Dialog directly, re-exported
// under Drawer names so consumers stay within one namespace.
export {
DialogClose as DrawerClose,
DialogDescription as DrawerDescription,
DialogPortal as DrawerPortal,
DialogTitle as DrawerTitle,
DialogTrigger as DrawerTrigger,
} from '../dialog';
export type {
DialogCloseProps as DrawerCloseProps,
DialogDescriptionProps as DrawerDescriptionProps,
DialogPortalProps as DrawerPortalProps,
DialogTitleProps as DrawerTitleProps,
DialogTriggerProps as DrawerTriggerProps,
} from '../dialog';
@@ -0,0 +1,184 @@
import { clamp } from '@robonen/stdlib';
import type { DrawerDirection } from './types';
import { MAX_SNAP_VELOCITY, SNAP_VELOCITY_MULTIPLIER, SNAP_VELOCITY_THRESHOLD } from './constants';
const PX_RE = /^-?(?:\d+(?:\.\d+)?|\.\d+)px$/;
const REM_RE = /^-?(?:\d+(?:\.\d+)?|\.\d+)rem$/;
/**
* Resolve a snap point to the visible size (px) it gives the drawer along the
* drag axis:
* - a number in (0, 1] is a fraction of the window dimension;
* - a number above 1 is pixels;
* - `'Npx'` / `'Nrem'` strings are pixels (rem scaled by the root font size).
*
* Unknown units (`'50%'`, `'10vh'`) and non-finite/non-positive results are
* unsupported and resolve to `null` so they never reach the geometry as `NaN`.
*/
export function resolveSnapPointSize(
point: number | string,
windowSize: number,
rootFontSize: number,
): number | null {
let size: number | null = null;
if (typeof point === 'number')
size = point > 1 ? point : point * windowSize;
else if (PX_RE.test(point))
size = Number.parseFloat(point);
else if (REM_RE.test(point))
size = Number.parseFloat(point) * rootFontSize;
if (size === null || !Number.isFinite(size) || size <= 0)
return null;
return Math.round(size);
}
/**
* The inline translate (px, signed the way the drawer's transform is written)
* that shows exactly `point` worth of the drawer: positive toward the
* bottom/right edge, negative toward the top/left edge, clamped so a snap point
* larger than the window rests at fully open. Unresolvable points map to `NaN`
* — callers must `Number.isFinite`-guard before using an offset.
*/
export function resolveSnapPointOffset(
point: number | string,
direction: DrawerDirection,
windowSize: number,
rootFontSize: number,
): number {
const size = resolveSnapPointSize(point, windowSize, rootFontSize);
if (size === null)
return Number.NaN;
const distance = Math.max(Math.round(windowSize - size), 0);
return direction === 'bottom' || direction === 'right' ? distance : -distance;
}
/**
* Index of the active snap point: matched by identity first, then by resolved
* size within a 1px tolerance, so a controlled drawer may use interchangeable
* representations (`0.5` vs `'360px'` on a 720px window). Returns `null` when
* nothing matches.
*/
export function findSnapPointIndex(
snapPoints: Array<number | string>,
active: number | string | null | undefined,
windowSize: number,
rootFontSize: number,
): number | null {
if (active === null || active === undefined)
return null;
const byIdentity = snapPoints.indexOf(active);
if (byIdentity !== -1)
return byIdentity;
const activeSize = resolveSnapPointSize(active, windowSize, rootFontSize);
if (activeSize === null)
return null;
const bySize = snapPoints.findIndex((point) => {
const size = resolveSnapPointSize(point, windowSize, rootFontSize);
return size !== null && Math.abs(size - activeSize) <= 1;
});
return bySize === -1 ? null : bySize;
}
export interface SnapReleaseInput {
/**
* Snap offsets in dismiss-positive space: 0 is fully open, larger is more
* hidden. `NaN` entries (unresolvable points) are skipped.
*/
offsets: number[];
activeIndex: number | null;
/** Drag distance since press, positive toward open/expand. */
draggedDistance: number;
/** Instantaneous release velocity, positive toward dismiss (px/ms). */
velocity: number;
/** Drawer size (px) along the drag axis — the fully-closed offset. */
drawerSize: number;
dismissible: boolean;
/** Step at most one snap point per gesture instead of jumping to the nearest. */
sequential: boolean;
}
export type SnapReleaseResult = { type: 'close' } | { type: 'snap'; index: number };
/**
* Where a snap-point drawer settles on release: the drag target is projected
* ahead along the release velocity (a fling crosses points a slow drag would
* not), then the nearest snap point wins — or the drawer closes when the
* projection lands strictly closer to fully-closed and the drawer is
* dismissible. In `sequential` mode the result is clamped to the snap point
* adjacent to the active one.
*/
export function projectSnapRelease(input: SnapReleaseInput): SnapReleaseResult {
const { offsets, activeIndex, draggedDistance, velocity, drawerSize, dismissible, sequential } = input;
const active = activeIndex !== null && Number.isFinite(offsets[activeIndex])
? offsets[activeIndex]
: 0;
// Where the drag alone left the drawer, clamped to its travel range.
const dragTarget = clamp(active - draggedDistance, 0, drawerSize);
let target = dragTarget;
if (Math.abs(velocity) >= SNAP_VELOCITY_THRESHOLD)
target = dragTarget + clamp(velocity, -MAX_SNAP_VELOCITY, MAX_SNAP_VELOCITY) * SNAP_VELOCITY_MULTIPLIER;
let closestIndex = -1;
let closestDistance = Number.POSITIVE_INFINITY;
for (const [index, offset] of offsets.entries()) {
if (!Number.isFinite(offset))
continue;
const distance = Math.abs(target - offset);
if (distance < closestDistance) {
closestIndex = index;
closestDistance = distance;
}
}
if (closestIndex === -1)
return { type: 'snap', index: activeIndex ?? 0 };
if (dismissible && Math.abs(target - drawerSize) < closestDistance)
return { type: 'close' };
if (!sequential || activeIndex === null)
return { type: 'snap', index: closestIndex };
// Sequential mode: rank the usable points by offset and move at most one
// step toward the drag; a fast fling or a physical crossing of the adjacent
// point advances, anything else stays.
const stepDirection = Math.sign(dragTarget - active);
if (stepDirection === 0)
return { type: 'snap', index: activeIndex };
const order = offsets
.map((offset, index) => ({ offset, index }))
.filter(entry => Number.isFinite(entry.offset))
.sort((a, b) => a.offset - b.offset);
const rank = order.findIndex(entry => entry.index === activeIndex);
if (rank === -1)
return { type: 'snap', index: closestIndex };
const adjacent = order[clamp(rank + stepDirection, 0, order.length - 1)];
const flungPast = Math.sign(velocity) === stepDirection && Math.abs(velocity) >= SNAP_VELOCITY_THRESHOLD;
const crossed = stepDirection > 0 ? target > adjacent.offset : target < adjacent.offset;
return { type: 'snap', index: flungPast || crossed ? adjacent.index : activeIndex };
}
+29 -1
View File
@@ -8,7 +8,35 @@
* The selectors here mirror the `data-drawer-*` attributes set in the component
* templates and {@link ./controls} — keep them in sync.
*/
export const DRAWER_STYLE_ID = 'robonen-drawer';
export const DRAWER_STYLE_ID = 'drawer';
let cssPropertiesRegistered = false;
/**
* Registers the drawer's animated custom properties with `inherits: false`, so
* a per-frame write of `--snap-point-height` on the content invalidates only
* that element instead of cascading a var recompute over its whole subtree.
* `--initial-transform` is deliberately NOT registered: consumers may set it on
* an ancestor and rely on inheritance. No-op where the API is missing.
*/
export function registerDrawerCssProperties(): void {
if (cssPropertiesRegistered || typeof CSS === 'undefined' || !CSS.registerProperty)
return;
cssPropertiesRegistered = true;
try {
CSS.registerProperty({
name: '--snap-point-height',
syntax: '<length>',
inherits: false,
initialValue: '0px',
});
}
catch {
// Older engines without @property support simply keep var inheritance.
}
}
export const DRAWER_STYLES = `
[data-drawer] {
+20 -5
View File
@@ -4,10 +4,25 @@
export type DrawerDirection = 'top' | 'bottom' | 'left' | 'right';
/**
* A resolved snap point: the original `fraction` (01 of the screen, or a raw
* px value) paired with its computed pixel `height`.
* Lifecycle phase of the drawer. `opening`/`closing` last for the duration of
* the enter/exit animation; the settle signal (animation end or its fallback
* timeout) advances them to `open`/`closed`.
*/
export interface SnapPoint {
fraction: number;
height: number;
export type DrawerPhase = 'closed' | 'opening' | 'open' | 'closing';
/**
* 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);
globalThis.requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (preventScrollRestoration.value && activeUrl.value !== globalThis.location.href) {
activeUrl.value = globalThis.location.href;
return;
@@ -2,8 +2,8 @@ import { onWatcherCleanup, ref, watchEffect } from 'vue';
import { isClient } from '@robonen/platform/multi';
import { assignStyle } from '@robonen/platform/browsers';
import { injectDrawerRootContext } from './context';
import { getDrawerWrapper, isVertical } from './helpers';
import { BORDER_RADIUS, TRANSITIONS, WINDOW_TOP_OFFSET } from './constants';
import { getDrawerWrapper, getScaleFactor, isVertical } from './helpers';
import { BORDER_RADIUS, TRANSITIONS } from './constants';
/**
* Scales the page background down behind the drawer (the stacked-card effect),
@@ -16,10 +16,6 @@ export function useScaleBackground() {
const timeoutIdRef = ref<number | null>(null);
const initialBackgroundColor = ref(typeof document !== 'undefined' ? document.body.style.backgroundColor : '');
function getScale() {
return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth;
}
watchEffect(() => {
// `flush: 'pre'` watchers run during SSR; this effect touches document/window,
// so it must stay client-only.
@@ -42,17 +38,18 @@ export function useScaleBackground() {
transitionTimingFunction: `cubic-bezier(${TRANSITIONS.EASE.join(',')})`,
});
const scale = getScaleFactor(window.innerWidth);
const wrapperStylesCleanup = assignStyle(wrapper, {
borderRadius: `${BORDER_RADIUS}px`,
overflow: 'hidden',
...(isVertical(direction.value)
? { transform: `scale(${getScale()}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)` }
: { transform: `scale(${getScale()}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)` }),
? { transform: `scale(${scale}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)` }
: { transform: `scale(${scale}) translate3d(calc(env(safe-area-inset-top) + 14px), 0, 0)` }),
});
onWatcherCleanup(() => {
wrapperStylesCleanup();
timeoutIdRef.value = globalThis.setTimeout(() => {
timeoutIdRef.value = setTimeout(() => {
if (initialBackgroundColor.value)
document.body.style.background = initialBackgroundColor.value;
else
@@ -1,9 +1,10 @@
import type { Ref } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { computed, nextTick, watch } from 'vue';
import { setStyle } from '@robonen/platform/browsers';
import { useEventListener } from '@robonen/vue';
import { isVertical } from './helpers';
import { TRANSITIONS, VELOCITY_THRESHOLD } from './constants';
import { isVertical, translateAxis, writeTransform } from './helpers';
import { TRANSITIONS } from './constants';
import { computeSettleDuration } from './gesture';
import { findSnapPointIndex, projectSnapRelease, resolveSnapPointOffset } from './snapping';
import type { DrawerDirection } from './types';
interface UseSnapPointsProps {
@@ -14,16 +15,23 @@ interface UseSnapPointsProps {
overlayRef: Ref<HTMLElement | undefined>;
onSnapPointChange: (activeSnapPointIndex: number, snapPointsOffset: number[]) => void;
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') =>
`${property} ${TRANSITIONS.DURATION}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
const transition = (property: 'transform' | 'opacity', duration: number = TRANSITIONS.DURATION) =>
`${property} ${duration}s cubic-bezier(${TRANSITIONS.EASE.join(',')})`;
function readRootFontSize(): number {
return Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
}
/**
* Drag/release maths for drawers configured with snap points: resolves each
* snap point to a pixel offset, animates the drawer between them, and decides
* which point to settle on (or whether to close) based on drag distance and
* velocity.
* snap point to a pixel offset, animates the drawer between them, and settles
* on release by projecting the drag target along the fling velocity.
*/
export function useSnapPoints({
activeSnapPoint,
@@ -33,26 +41,64 @@ export function useSnapPoints({
fadeFromIndex,
onSnapPointChange,
direction,
snapToSequentialPoints,
windowWidth,
windowHeight,
}: UseSnapPointsProps) {
const windowDimensions = ref(globalThis.window !== undefined
? { innerWidth: window.innerWidth, innerHeight: window.innerHeight }
: undefined);
// Direction resolved once per change instead of string-comparing per move.
const verticalAxis = computed(() => isVertical(direction.value));
const dismissMultiplier = computed<1 | -1>(() =>
direction.value === 'bottom' || direction.value === 'right' ? 1 : -1,
);
function onResize() {
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 };
function windowSizeFor(dir: DrawerDirection): number {
return isVertical(dir) ? windowHeight.value : windowWidth.value;
}
// Defaults to `defaultWindow` (SSR-safe) and auto-removes on scope dispose.
useEventListener('resize', onResize);
let warnedInvalid = false;
/**
* 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(
() => (snapPoints.value && activeSnapPoint.value === snapPoints.value[snapPoints.value.length - 1]) ?? null,
() => (snapPoints.value && activeSnapPointIndex.value === snapPoints.value.length - 1) ?? null,
);
const shouldFade = computed(
@@ -65,58 +111,25 @@ export function useSnapPoints({
|| !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(() =>
activeSnapPointIndex.value !== null ? snapPointsOffset.value?.[activeSnapPointIndex.value] : null,
);
function snapToPoint(dimension: number) {
function snapToPoint(dimension: number, options?: { velocity?: number; from?: number | null }) {
if (!Number.isFinite(dimension))
return;
const newSnapPointIndex = snapPointsOffset.value?.indexOf(dimension) ?? null;
const 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.
nextTick(() => {
onSnapPointChange(newSnapPointIndex, snapPointsOffset.value);
setStyle(drawerRef.value, {
transition: transition('transform'),
transform: isVertical(direction.value) ? `translate3d(0, ${dimension}px, 0)` : `translate3d(${dimension}px, 0, 0)`,
transition: transition('transform', duration),
transform: translateAxis(verticalAxis.value, dimension),
});
});
@@ -125,22 +138,30 @@ export function useSnapPoints({
&& newSnapPointIndex !== snapPointsOffset.value.length - 1
&& newSnapPointIndex !== fadeFromIndex?.value
) {
setStyle(overlayRef.value, { transition: transition('opacity'), opacity: '0' });
setStyle(overlayRef.value, { transition: transition('opacity', duration), opacity: '0' });
}
else {
setStyle(overlayRef.value, { transition: transition('opacity'), opacity: '1' });
setStyle(overlayRef.value, { transition: transition('opacity', duration), opacity: '1' });
}
activeSnapPoint.value = newSnapPointIndex !== null ? snapPoints.value?.[newSnapPointIndex] ?? null : null;
}
/** 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(
[activeSnapPoint, snapPointsOffset, snapPoints],
() => {
if (activeSnapPoint.value) {
const newIndex = snapPoints.value?.indexOf(activeSnapPoint.value) ?? -1;
const newIndex = activeSnapPointIndex.value ?? -1;
if (snapPointsOffset.value && newIndex !== -1 && typeof snapPointsOffset.value[newIndex] === 'number')
if (snapPointsOffset.value && newIndex !== -1 && Number.isFinite(snapPointsOffset.value[newIndex]))
snapToPoint(snapPointsOffset.value[newIndex]);
}
},
@@ -152,89 +173,66 @@ export function useSnapPoints({
closeDrawer,
velocity,
dismissible,
drawerSize,
}: {
/** Drag distance since press, positive toward open/expand. */
draggedDistance: number;
closeDrawer: () => void;
/** Instantaneous release velocity, positive toward dismiss (px/ms). */
velocity: number;
dismissible: boolean;
/** Drawer size (px) along the drag axis. */
drawerSize: number;
}) {
if (fadeFromIndex.value === undefined)
return;
const currentPosition
= direction.value === 'bottom' || direction.value === 'right'
? (activeSnapPointOffset.value ?? 0) - draggedDistance
: (activeSnapPointOffset.value ?? 0) + draggedDistance;
const multiplier = dismissMultiplier.value;
const offsets = snapPointsOffset.value.map(offset => offset * multiplier);
const isOverlaySnapPoint = activeSnapPointIndex.value === fadeFromIndex.value - 1;
const isFirst = activeSnapPointIndex.value === 0;
const hasDraggedUp = draggedDistance > 0;
if (isOverlaySnapPoint)
setStyle(overlayRef.value, { transition: transition('opacity') });
if (velocity > 2 && !hasDraggedUp) {
if (dismissible)
closeDrawer();
else
snapToPoint(snapPointsOffset.value[0]); // snap to initial point
return;
}
if (velocity > 2 && hasDraggedUp && snapPointsOffset.value && snapPoints.value) {
snapToPoint(snapPointsOffset.value[snapPoints.value.length - 1]);
return;
}
// Settle on the snap point closest to where the drag ended.
const closestSnapPoint = snapPointsOffset.value?.reduce((prev, curr) => {
if (typeof prev !== 'number' || typeof curr !== 'number')
return prev;
return Math.abs(curr - currentPosition) < Math.abs(prev - currentPosition) ? curr : prev;
const result = projectSnapRelease({
offsets,
activeIndex: activeSnapPointIndex.value,
draggedDistance,
velocity,
drawerSize,
dismissible,
sequential: snapToSequentialPoints.value,
});
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]);
if (result.type === 'close') {
closeDrawer();
return;
}
snapToPoint(closestSnapPoint);
const target = snapPointsOffset.value[result.index];
const from = (activeSnapPointOffset.value ?? 0) - draggedDistance * multiplier;
snapToPoint(target, { velocity, from });
}
function onDrag({ draggedDistance }: { draggedDistance: number }) {
if (activeSnapPointOffset.value === null)
return;
function onDrag({ draggedDistance }: { draggedDistance: number }): number | null {
const activeOffset = activeSnapPointOffset.value;
const newValue
= direction.value === 'bottom' || direction.value === 'right'
? (activeSnapPointOffset.value ?? 0) - draggedDistance
: (activeSnapPointOffset.value ?? 0) + draggedDistance;
if (activeOffset === null || activeOffset === undefined || !Number.isFinite(activeOffset))
return null;
const positive = dismissMultiplier.value === 1;
const newValue = positive ? activeOffset - draggedDistance : activeOffset + draggedDistance;
const offsets = snapPointsOffset.value;
const lastOffset = offsets[offsets.length - 1];
// Don't drag past the last (largest) snap point.
if ((direction.value === 'bottom' || direction.value === 'right') && newValue < snapPointsOffset.value[snapPointsOffset.value.length - 1])
return;
if (Number.isFinite(lastOffset) && (positive ? newValue < lastOffset : newValue > lastOffset))
return null;
if ((direction.value === 'top' || direction.value === 'left') && newValue > snapPointsOffset.value[snapPointsOffset.value.length - 1])
return;
writeTransform(drawerRef.value, translateAxis(verticalAxis.value, newValue));
setStyle(drawerRef.value, {
transform: isVertical(direction.value) ? `translate3d(0, ${newValue}px, 0)` : `translate3d(${newValue}px, 0, 0)`,
});
return newValue;
}
function getPercentageDragged(absDraggedDistance: number, isDraggingDown: boolean) {
@@ -278,6 +276,7 @@ export function useSnapPoints({
activeSnapPointIndex,
onRelease,
onDrag,
restoreActiveSnapPoint,
snapPointsOffset,
};
}
@@ -28,6 +28,11 @@ import { useSelectRootContext } from './context';
import SelectContentImpl from './SelectContentImpl.vue';
import SelectProvider from './SelectProvider.vue';
// Neither branch below is a single element root (`Presence` wraps the panel,
// the closed branch is a `Teleport`), so Vue cannot inherit `class`/`style` or
// any other attribute automatically — they are forwarded onto the panel itself.
defineOptions({ inheritAttrs: false });
const props = defineProps<SelectContentProps>();
const emit = defineEmits<SelectContentEmits>();
const rootCtx = useSelectRootContext();
@@ -57,7 +62,7 @@ onMounted(() => {
:present="present"
>
<SelectContentImpl
v-bind="props"
v-bind="{ ...props, ...$attrs }"
@close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@@ -63,8 +63,11 @@ const selectedItemTextRef = rootCtx.selectedItemTextRef;
const firstValidItemFoundRef = ref(false);
// Recompute the selected/first-valid item afresh for this open cycle.
// Recompute the selected/first-valid item afresh for this open cycle. The text
// node is reset alongside it: the item-aligned positioner reads the two as a
// pair, so a stale text node would pair with a fresh item and skew placement.
selectedItemRef.value = undefined;
selectedItemTextRef.value = undefined;
// Resolve the actual listbox content element. The item-aligned strategy renders
// a positioning wrapper whose first child is the listbox; the popper strategy
@@ -47,6 +47,46 @@ const shouldExpandOnScrollRef = ref(false);
const shouldRepositionRef = ref(true);
const contentZIndex = ref('');
// When nothing is selected the content adopts the first valid item as the
// alignment anchor, but only that item is registered — its text node registers
// solely for the *selected* value. Recover it from the item's own label
// association instead of demanding a second registration, which would mean
// writing to the anchor refs from inside the item's own tracking effect.
function itemTextOf(item: HTMLElement | undefined): HTMLElement | undefined {
const id = item?.getAttribute('aria-labelledby');
return id ? item?.ownerDocument.getElementById(id) ?? undefined : undefined;
}
/**
* Inline styles the wrapper is positioned with. Written as one object and
* committed in a single pass: every geometry read below happens before the
* first write, so the browser performs one layout for the whole placement
* instead of one per interleaved read.
*
* Both edges of each axis are always present. A resize can flip the vertical
* branch, and leaving the previous edge behind would over-constrain the box.
*/
interface WrapperPlacement {
minWidth: string;
left: string;
right: string;
top: string;
bottom: string;
height: string;
minHeight: string;
maxHeight: string;
margin: string;
}
const EMPTY_PLACEMENT: WrapperPlacement = {
minWidth: '', left: '', right: '', top: '', bottom: '',
height: '', minHeight: '', maxHeight: '', margin: '',
};
function commit(wrapper: HTMLElement, placement: Partial<WrapperPlacement>) {
Object.assign(wrapper.style, EMPTY_PLACEMENT, placement);
}
function position() {
const trigger = rootCtx.triggerElement.value;
const valueNode = rootCtx.valueElement.value;
@@ -54,20 +94,61 @@ function position() {
const content = contentElement.value;
const viewport = contentCtx.viewportRef.value;
const selectedItem = contentCtx.selectedItemRef.value;
const selectedItemText = contentCtx.selectedItemTextRef.value;
const selectedItemText = contentCtx.selectedItemTextRef.value ?? itemTextOf(selectedItem);
if (!trigger || !valueNode || !wrapper || !content || !viewport || !selectedItem || !selectedItemText) {
if (!trigger || !wrapper || !content || !viewport) {
emit('placed');
return;
}
const triggerRect = trigger.getBoundingClientRect();
// Item-aligned placement centres the panel on the selected item, so without
// one there is nothing to align to — an empty option list, or items that have
// not registered yet. Drop the panel under the trigger instead of returning:
// the wrapper is `position: fixed`, so leaving it unplaced pins it to the
// viewport origin, where it reads as "the dropdown does not open".
if (!valueNode || !selectedItem || !selectedItemText) {
const rect = trigger.getBoundingClientRect();
const rightEdge = window.innerWidth - CONTENT_MARGIN;
commit(wrapper, {
minWidth: `${rect.width}px`,
left: `${clamp(rect.left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - rect.width))}px`,
top: `${rect.bottom}px`,
maxHeight: `${Math.max(0, window.innerHeight - rect.bottom - CONTENT_MARGIN)}px`,
});
emit('placed');
return;
}
// --- Horizontal positioning ---
// --- Measure: every layout read lives here, before the first write ---
const triggerRect = trigger.getBoundingClientRect();
const contentRect = content.getBoundingClientRect();
const valueNodeRect = valueNode.getBoundingClientRect();
const itemTextRect = selectedItemText.getBoundingClientRect();
const items = Array.from(
viewport.querySelectorAll<HTMLElement>('[data-primitives-select-item]'),
);
const itemsHeight = viewport.scrollHeight;
const viewportOffsetTop = viewport.offsetTop;
const viewportOffsetHeight = viewport.offsetHeight;
const contentClientHeight = content.clientHeight;
const selectedItemHeight = selectedItem.offsetHeight;
const selectedItemOffsetTop = selectedItem.offsetTop;
const contentStyles = globalThis.getComputedStyle(content);
const contentBorderTopWidth = Number.parseInt(contentStyles.borderTopWidth, 10) || 0;
const contentPaddingTop = Number.parseInt(contentStyles.paddingTop, 10) || 0;
const contentBorderBottomWidth = Number.parseInt(contentStyles.borderBottomWidth, 10) || 0;
const contentPaddingBottom = Number.parseInt(contentStyles.paddingBottom, 10) || 0;
const viewportStyles = globalThis.getComputedStyle(viewport);
const viewportPaddingTop = Number.parseInt(viewportStyles.paddingTop, 10) || 0;
const viewportPaddingBottom = Number.parseInt(viewportStyles.paddingBottom, 10) || 0;
// --- Compute ---
const placement: Partial<WrapperPlacement> = {};
const availableHeight = window.innerHeight - CONTENT_MARGIN * 2;
if (rootCtx.dir.value !== 'rtl') {
const itemTextOffset = itemTextRect.left - contentRect.left;
const left = valueNodeRect.left - itemTextOffset;
@@ -75,10 +156,9 @@ function position() {
const minContentWidth = triggerRect.width + leftDelta;
const contentWidth = Math.max(minContentWidth, contentRect.width);
const rightEdge = window.innerWidth - CONTENT_MARGIN;
const clampedLeft = clamp(left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - contentWidth));
wrapper.style.minWidth = `${minContentWidth}px`;
wrapper.style.left = `${clampedLeft}px`;
placement.minWidth = `${minContentWidth}px`;
placement.left = `${clamp(left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - contentWidth))}px`;
}
else {
const itemTextOffset = contentRect.right - itemTextRect.right;
@@ -87,67 +167,52 @@ function position() {
const minContentWidth = triggerRect.width + rightDelta;
const contentWidth = Math.max(minContentWidth, contentRect.width);
const leftEdge = window.innerWidth - CONTENT_MARGIN;
const clampedRight = clamp(right, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, leftEdge - contentWidth));
wrapper.style.minWidth = `${minContentWidth}px`;
wrapper.style.right = `${clampedRight}px`;
placement.minWidth = `${minContentWidth}px`;
placement.right = `${clamp(right, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, leftEdge - contentWidth))}px`;
}
// --- Vertical positioning ---
const items = Array.from(
viewport.querySelectorAll<HTMLElement>('[data-primitives-select-item]'),
);
const availableHeight = window.innerHeight - CONTENT_MARGIN * 2;
const itemsHeight = viewport.scrollHeight;
const contentStyles = globalThis.getComputedStyle(content);
const contentBorderTopWidth = Number.parseInt(contentStyles.borderTopWidth, 10) || 0;
const contentPaddingTop = Number.parseInt(contentStyles.paddingTop, 10) || 0;
const contentBorderBottomWidth = Number.parseInt(contentStyles.borderBottomWidth, 10) || 0;
const contentPaddingBottom = Number.parseInt(contentStyles.paddingBottom, 10) || 0;
const fullContentHeight = contentBorderTopWidth + contentPaddingTop + itemsHeight + contentPaddingBottom + contentBorderBottomWidth;
const minContentHeight = Math.min(selectedItem.offsetHeight * 5, fullContentHeight);
const viewportStyles = globalThis.getComputedStyle(viewport);
const viewportPaddingTop = Number.parseInt(viewportStyles.paddingTop, 10) || 0;
const viewportPaddingBottom = Number.parseInt(viewportStyles.paddingBottom, 10) || 0;
const topEdgeToTriggerMiddle = triggerRect.top + triggerRect.height / 2 - CONTENT_MARGIN;
const triggerMiddleToBottomEdge = availableHeight - topEdgeToTriggerMiddle;
const selectedItemHalfHeight = selectedItem.offsetHeight / 2;
const itemOffsetMiddle = selectedItem.offsetTop + selectedItemHalfHeight;
const selectedItemHalfHeight = selectedItemHeight / 2;
const itemOffsetMiddle = selectedItemOffsetTop + selectedItemHalfHeight;
const contentTopToItemMiddle = contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle;
const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle;
const willAlignWithoutTopOverflow = contentTopToItemMiddle <= topEdgeToTriggerMiddle;
let scrollTop: number | undefined;
if (willAlignWithoutTopOverflow) {
if (contentTopToItemMiddle <= topEdgeToTriggerMiddle) {
const isLastItem = selectedItem === items.at(-1);
wrapper.style.bottom = '0px';
const viewportOffsetBottom = content.clientHeight - viewport.offsetTop - viewport.offsetHeight;
const viewportOffsetBottom = contentClientHeight - viewportOffsetTop - viewportOffsetHeight;
const clampedTriggerMiddleToBottomEdge = Math.max(
triggerMiddleToBottomEdge,
selectedItemHalfHeight + (isLastItem ? viewportPaddingBottom : 0) + viewportOffsetBottom + contentBorderBottomWidth,
);
const height = contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge;
wrapper.style.height = `${height}px`;
placement.bottom = '0px';
placement.height = `${contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge}px`;
}
else {
const isFirstItem = selectedItem === items[0];
wrapper.style.top = '0px';
const clampedTopEdgeToTriggerMiddle = Math.max(
topEdgeToTriggerMiddle,
contentBorderTopWidth + viewport.offsetTop + (isFirstItem ? viewportPaddingTop : 0) + selectedItemHalfHeight,
contentBorderTopWidth + viewportOffsetTop + (isFirstItem ? viewportPaddingTop : 0) + selectedItemHalfHeight,
);
const height = clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom;
wrapper.style.height = `${height}px`;
viewport.scrollTop = contentTopToItemMiddle - topEdgeToTriggerMiddle + viewport.offsetTop;
placement.top = '0px';
placement.height = `${clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom}px`;
scrollTop = contentTopToItemMiddle - topEdgeToTriggerMiddle + viewportOffsetTop;
}
wrapper.style.margin = `${CONTENT_MARGIN}px 0`;
wrapper.style.minHeight = `${minContentHeight}px`;
wrapper.style.maxHeight = `${availableHeight}px`;
placement.margin = `${CONTENT_MARGIN}px 0`;
placement.minHeight = `${Math.min(selectedItemHeight * 5, fullContentHeight)}px`;
placement.maxHeight = `${availableHeight}px`;
// --- Commit ---
commit(wrapper, placement);
if (scrollTop !== undefined) viewport.scrollTop = scrollTop;
emit('placed');
requestAnimationFrame(() => (shouldExpandOnScrollRef.value = true));
@@ -2,6 +2,12 @@
import type { Direction } from '../../utilities/config-provider';
import type { AcceptableValue } from './utils';
/**
* Shape of the select's model value: an array of `T` in multiple mode, a bare
* `T` otherwise. Keeps `v-model` narrow on both sides of the binding.
*/
export type SelectModelValue<T extends AcceptableValue, Multiple extends boolean> = Multiple extends true ? T[] : T;
/**
* A custom, fully stylable replacement for the native `<select>` element: a
* trigger button that opens a floating listbox of options, with full keyboard
@@ -16,7 +22,9 @@ import type { AcceptableValue } from './utils';
* (compared via `by`). Compose it from a `SelectTrigger` (with
* `SelectValue`/`SelectIcon`) plus a portalled `SelectContent` of `SelectItem`s.
*/
export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> {
export interface SelectRootProps<T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false> {
/** Controlled value. Bind with `v-model`. */
modelValue?: SelectModelValue<T, Multiple>;
/** Reading direction. Falls back to ConfigProvider. */
dir?: Direction;
/** Disable the whole select. */
@@ -26,11 +34,11 @@ export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> {
/** Native input name for form submission. */
name?: string;
/** Uncontrolled default value. */
defaultValue?: T | T[];
defaultValue?: SelectModelValue<T, Multiple>;
/** Uncontrolled default open state. */
defaultOpen?: boolean;
/** Allow selecting multiple options; the model becomes an array. */
multiple?: boolean;
multiple?: Multiple;
/**
* Compare object values by a property key or a custom comparator. Omitted →
* `===` for primitives / structural deep-equality for objects.
@@ -40,13 +48,20 @@ export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> {
autocomplete?: string;
}
export interface SelectRootEmits<T extends AcceptableValue = AcceptableValue> {
'update:modelValue': [value: T | T[] | undefined];
export interface SelectRootEmits<T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false> {
'update:modelValue': [value: SelectModelValue<T, Multiple>];
'update:open': [open: boolean];
}
/**
* The subset `defineEmits` declares. `update:open` comes from `defineModel`;
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
type SelectRootOwnEmits<T extends AcceptableValue, Multiple extends boolean> = Omit<SelectRootEmits<T, Multiple>, 'update:open'>;
</script>
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false">
import type { Ref } from 'vue';
import { computed, ref, shallowRef, toRef, watch } from 'vue';
@@ -60,6 +75,7 @@ import { compare, shouldShowPlaceholder } from './utils';
defineOptions({ inheritAttrs: false });
const {
modelValue,
dir,
disabled = false,
required = false,
@@ -69,11 +85,13 @@ const {
multiple = false,
by,
autocomplete,
} = defineProps<SelectRootProps<T>>();
} = defineProps<SelectRootProps<T, Multiple>>();
const emit = defineEmits<SelectRootOwnEmits<T, Multiple>>();
defineSlots<{
default?: (props: {
modelValue: T | T[] | undefined;
modelValue: SelectModelValue<T, Multiple> | undefined;
open: boolean;
}) => unknown;
}>();
@@ -88,16 +106,26 @@ const open = defineModel<boolean>('open', {
},
});
const localValue = ref<T | T[] | undefined>(defaultValue ?? (multiple ? ([] as T[]) : undefined)) as Ref<T | T[] | undefined>;
const value = defineModel<T | T[] | undefined>('modelValue', {
default: undefined,
get: v => (v ?? localValue.value),
type ModelValue = SelectModelValue<T, Multiple>;
// `defineModel` would type `update:modelValue` as `ModelValue | undefined`,
// forcing every consumer's `v-model` target to accept `undefined` even though
// a selection is never cleared. The prop and the emit are declared separately
// so the emitted payload stays exactly `ModelValue` (see AGENTS §3.2.3).
const localValue = ref(defaultValue ?? (multiple ? [] : undefined)) as Ref<ModelValue | undefined>;
const value = computed<ModelValue | undefined>({
get: () => modelValue ?? localValue.value,
set: (v) => {
localValue.value = v;
return v;
emit('update:modelValue', v as ModelValue);
},
});
// The public model type is conditional on `Multiple`, which TypeScript cannot
// narrow inside the component; the internal logic reads and writes the union
// through this widened alias instead.
const model = value as unknown as Ref<T | T[] | undefined>;
const contentId = useId(undefined, 'select-content');
const dirRef = toRef(() => dir);
const disabledRef = toRef(() => disabled);
@@ -119,7 +147,7 @@ const displayValue = ref<string | undefined>(undefined);
const rawOptions = new Set<SelectOption>();
const optionsSet = shallowRef(new Set<SelectOption>());
const isEmptyModelValue = computed(() => shouldShowPlaceholder(value.value));
const isEmptyModelValue = computed(() => shouldShowPlaceholder(model.value));
function getOptionFrom(source: Iterable<SelectOption>, v: AcceptableValue): SelectOption | undefined {
for (const option of source) {
@@ -143,8 +171,8 @@ function onOptionRemove(option: SelectOption) {
}
// Persist a single-value label for the legacy `displayValue` slot path.
watch([optionsSet, value], () => {
const current = value.value;
watch([optionsSet, model], () => {
const current = model.value;
if (current === undefined || Array.isArray(current)) return;
const text = getOptionFrom(optionsSet.value, current)?.textContent;
if (text !== undefined) displayValue.value = text;
@@ -152,21 +180,21 @@ watch([optionsSet, value], () => {
function handleValueChange(newValue: AcceptableValue) {
if (multiple) {
const array = Array.isArray(value.value) ? [...value.value] : [];
const array = Array.isArray(model.value) ? [...model.value] : [];
const index = array.findIndex(v => compare(v as T, newValue as T, by as never));
if (index === -1) array.push(newValue as T);
else array.splice(index, 1);
value.value = [...array] as T[];
model.value = [...array] as T[];
}
else {
value.value = newValue as T;
model.value = newValue as T;
displayValue.value = getOptionFrom(rawOptions, newValue)?.textContent;
open.value = false;
}
}
function isSelectedValue(itemValue: AcceptableValue): boolean {
const current = value.value;
const current = model.value;
if (current === undefined) return false;
if (Array.isArray(current)) {
for (const v of current) {
@@ -197,7 +225,7 @@ const isFormControl = computed(() => {
});
provideSelectRootContext({
value,
value: model,
onValueChange: handleValueChange,
open,
onOpenChange: (v) => { open.value = v; },
@@ -237,7 +265,7 @@ provideSelectRootContext({
:disabled="disabled"
:multiple="multiple"
:options="nativeOptions"
:value="value"
:value="model"
@change="handleValueChange"
/>
@@ -245,7 +273,7 @@ provideSelectRootContext({
v-else-if="name"
type="hidden"
:name="name"
:value="Array.isArray(value) ? '' : (value ?? '')"
:value="Array.isArray(model) ? '' : (model ?? '')"
:required="required"
:disabled="disabled"
:autocomplete="autocomplete"
@@ -20,11 +20,11 @@ export interface SelectViewportProps extends PrimitiveProps {
<script setup lang="ts">
import { ref, toRef, watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { useForwardExpose, useStyleTag } from '@robonen/vue';
import { useNonce } from '../../utilities/config-provider';
import { Primitive } from '../../internal/primitive';
import { useSelectContentContext, useSelectItemAlignedPositionContext } from './context';
import { CONTENT_MARGIN } from './utils';
import { CONTENT_MARGIN, VIEWPORT_SCROLLBAR_CSS } from './utils';
const { as = 'div', nonce: propNonce } = defineProps<SelectViewportProps>();
@@ -32,6 +32,11 @@ const { forwardRef, currentElement } = useForwardExpose();
const contentCtx = useSelectContentContext();
const nonce = useNonce(toRef(() => propNonce));
// Injected into `<head>` (one reference-counted tag per document) rather than
// rendered as a sibling `<style>`: a second root node would turn this component
// into a fragment, and Vue cannot inherit a consumer's `class` onto a fragment.
useStyleTag(VIEWPORT_SCROLLBAR_CSS, { id: 'primitives-select-viewport', nonce: nonce.value });
const alignedCtx = contentCtx.position === 'item-aligned'
? useSelectItemAlignedPositionContext(null as never)
: undefined;
@@ -82,8 +87,4 @@ function handleScroll(event: Event) {
>
<slot />
</Primitive>
<Primitive as="style" :nonce="nonce">
[data-primitives-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}
[data-primitives-select-viewport]::-webkit-scrollbar{display:none;}
</Primitive>
</template>
@@ -385,3 +385,133 @@ describe('Select — native form submission', () => {
w.unmount();
});
});
describe('Select — attribute forwarding on the panel', () => {
function mountStyled() {
return track(mount(
defineComponent({
setup() {
return () => h(
SelectRoot,
{ defaultOpen: true },
{
default: () => [
h(SelectTrigger, { id: 'styled-trigger', 'aria-label': 'Fruit' }, {
default: () => h(SelectValue, { placeholder: 'Pick one' }),
}),
h(SelectPortal, null, {
default: () => h(SelectContent, { class: 'panel', 'data-panel': 'yes' }, {
default: () => h(SelectViewport, { class: 'viewport' }, {
default: () => h(SelectItem, { value: 'apple' }, {
default: () => h(SelectItemText, null, { default: () => 'Apple' }),
}),
}),
}),
}),
],
},
);
},
}),
{ attachTo: document.body },
));
}
it('forwards class and data attributes from SelectContent to the panel element', async () => {
const w = mountStyled();
await flush();
const panel = document.querySelector('[data-primitives-select-content]') as HTMLElement | null;
expect(panel).toBeTruthy();
expect(panel!.classList.contains('panel')).toBe(true);
expect(panel!.getAttribute('data-panel')).toBe('yes');
w.unmount();
});
it('forwards class from SelectViewport to the viewport element', async () => {
const w = mountStyled();
await flush();
const viewport = document.querySelector('[data-primitives-select-viewport]') as HTMLElement | null;
expect(viewport).toBeTruthy();
expect(viewport!.classList.contains('viewport')).toBe(true);
w.unmount();
});
it('keeps the trigger a single root that accepts native attributes', async () => {
const w = mountStyled();
await flush();
const trigger = getTrigger();
expect(trigger.id).toBe('styled-trigger');
expect(trigger.getAttribute('aria-label')).toBe('Fruit');
w.unmount();
});
it('injects the scrollbar-hiding stylesheet into head instead of a sibling style node', async () => {
const w = mountStyled();
await flush();
const injected = document.head.querySelector('#primitives-select-viewport');
expect(injected).toBeTruthy();
expect(injected!.textContent).toContain('[data-primitives-select-viewport]');
const panel = document.querySelector('[data-primitives-select-content]') as HTMLElement;
expect(panel.querySelector('style')).toBeNull();
w.unmount();
});
});
describe('Select — panel placement without a selection', () => {
function mountUnmatched(options: Opt[]) {
return track(mount(
defineComponent({
setup() {
// A model value that matches no option — a stale id, a deleted user,
// a directory that has not loaded yet.
return () => h(
SelectRoot,
{ defaultOpen: true, modelValue: 'gone' as never },
{
default: () => [
h(SelectTrigger, null, { default: () => h(SelectValue, { placeholder: 'Pick one' }) }),
h(SelectPortal, null, {
default: () => h(SelectContent, null, {
default: () => h(SelectViewport, null, {
default: () => options.map(opt =>
h(SelectItem, { key: String(opt.value), value: opt.value as never }, {
default: () => h(SelectItemText, null, { default: () => opt.label }),
}),
),
}),
}),
}),
],
},
);
},
}),
{ attachTo: document.body },
));
}
it('aligns on the first valid item when the model matches nothing', async () => {
const w = mountUnmatched([{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }]);
await flush();
const wrapper = document.querySelector('[data-primitives-select-content-wrapper]') as HTMLElement | null;
expect(wrapper).toBeTruthy();
// Item-aligned placement sets all three; bailing out leaves them empty and
// the fixed wrapper pinned to the viewport origin.
expect(wrapper!.style.minWidth).not.toBe('');
expect(wrapper!.style.height).not.toBe('');
expect(wrapper!.style.left || wrapper!.style.right).not.toBe('');
w.unmount();
});
it('places the panel instead of leaving it pinned to the viewport origin', async () => {
const w = mountUnmatched([]);
await flush();
const wrapper = document.querySelector('[data-primitives-select-content-wrapper]') as HTMLElement | null;
expect(wrapper).toBeTruthy();
// With no items at all there is nothing to align to; the fallback still has
// to give the wrapper explicit coordinates.
expect(wrapper!.style.top).not.toBe('');
expect(wrapper!.style.left).not.toBe('');
w.unmount();
});
});
@@ -4,13 +4,6 @@ import type { AcceptableValue } from './utils';
import { useContextFactory } from '@robonen/vue';
/**
* @deprecated Kept for backward compatibility. The select now accepts any
* {@link AcceptableValue} (string/number/boolean/object). `SelectValue` remains
* a string alias so existing `string`-typed consumers keep compiling.
*/
export type SelectValue = string;
export interface SelectOption {
value: AcceptableValue;
disabled?: boolean;
+1 -2
View File
@@ -29,7 +29,6 @@ export {
} from './context';
export type {
SelectValue,
SelectOption,
SelectRootContext,
SelectContentContext,
@@ -38,7 +37,7 @@ export type {
SelectItemContext,
} from './context';
export type { AcceptableValue as SelectAcceptableValue } from './utils';
export type { SelectRootProps, SelectRootEmits } from './SelectRoot.vue';
export type { SelectModelValue, SelectRootProps, SelectRootEmits } from './SelectRoot.vue';
export type { SelectTriggerProps } from './SelectTrigger.vue';
export type { SelectValueProps } from './SelectValue.vue';
export type { SelectIconProps } from './SelectIcon.vue';
@@ -9,6 +9,11 @@ export const OPEN_KEYS = [' ', 'Enter', 'ArrowUp', 'ArrowDown'];
export const SELECTION_KEYS = [' ', 'Enter'];
export const CONTENT_MARGIN = 10;
/** Hides the viewport's scrollbar across engines while keeping it scrollable. */
export const VIEWPORT_SCROLLBAR_CSS
= '[data-primitives-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}'
+ '[data-primitives-select-viewport]::-webkit-scrollbar{display:none;}';
export function getOpenState(open: boolean): 'open' | 'closed' {
return open ? 'open' : 'closed';
}
@@ -45,6 +45,14 @@ export interface RovingFocusGroupEmits {
'update:currentTabStopId': [value: string | null | undefined];
}
/**
* The subset `defineEmits` declares. `update:currentTabStopId` comes from
* `defineModel`; passing a model key through `defineEmits` as well erases its
* payload type from the generated declarations, leaving consumers with
* `unknown`.
*/
type RovingFocusGroupOwnEmits = Omit<RovingFocusGroupEmits, 'update:currentTabStopId'>;
export interface RovingFocusGroupContext {
orientation: Ref<Orientation | undefined>;
dir: Ref<Direction>;
@@ -77,7 +85,7 @@ const {
as = 'div',
} = defineProps<RovingFocusGroupProps>();
const emit = defineEmits<RovingFocusGroupEmits>();
const emit = defineEmits<RovingFocusGroupOwnEmits>();
const config = useConfig();
// `dir` falls back to the provider's configured direction when not given as prop.
+6 -6
View File
@@ -19,12 +19,12 @@
"devDependencies": {
"@robonen/eslint": "workspace:*",
"@robonen/tsconfig": "workspace:*",
"@storybook/addon-a11y": "^10.4.6",
"@storybook/addon-docs": "^10.4.6",
"@storybook/vue3-vite": "^10.4.6",
"@vitejs/plugin-vue": "^6.0.7",
"@storybook/addon-a11y": "^10.5.5",
"@storybook/addon-docs": "^10.5.5",
"@storybook/vue3-vite": "^10.5.5",
"@vitejs/plugin-vue": "^6.0.8",
"eslint": "catalog:",
"storybook": "^10.4.6",
"vite": "^8.0.16"
"storybook": "^10.5.5",
"vite": "^8.1.5"
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@robonen/vue",
"version": "0.1.0",
"version": "0.2.0",
"license": "Apache-2.0",
"description": "Collection of powerful tools for Vue",
"keywords": [
@@ -16,7 +16,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "vue/toolkit"
},
"packageManager": "pnpm@11.7.0",
"packageManager": "pnpm@11.18.0",
"engines": {
"node": ">=24.16.0"
},
@@ -4,6 +4,7 @@ import { computed } from 'vue';
import type { MaybeComputedElementRef } from '@/composables/component/unrefElement';
import { unrefElement } from '@/composables/component/unrefElement';
import { useEventListener } from '@/composables/browser/useEventListener';
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
const DEFAULT_DELAY = 500;
const DEFAULT_THRESHOLD = 10;
@@ -220,6 +221,11 @@ export function onLongPress(
useEventListener(elementRef, ['pointerup', 'pointerleave'], onRelease, listenerOptions),
];
// The listeners above self-dispose with the scope, but a delay timer armed
// by a press that never released would outlive the component and fire the
// handler against a dead scope.
tryOnScopeDispose(clear);
return (): void => {
clear();
cleanups.forEach(stop => stop());
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { createApp, inject, onUnmounted, reactive, ref } from 'vue';
import type { InjectionKey } from 'vue';
import { runWithApp } from './index';
interface Settings {
volume: number;
}
const SettingsKey: InjectionKey<Settings> = Symbol('DemoSettings');
// Imagine this is your main.ts: the app provides DI values and is registered
// once with `app.use(activeAppPlugin)`. The demo keeps a standalone app and
// passes it explicitly so it does not touch the docs application.
const app = createApp({ render: () => null });
const settings = reactive<Settings>({ volume: 50 });
app.provide(SettingsKey, settings);
onUnmounted(() => app.unmount());
// A plain module-level function — no setup, no injection context. With
// `runWithApp` it can still resolve `inject()` against the app.
function readVolumeFromOutside() {
return runWithApp(() => inject(SettingsKey)!.volume, app);
}
const snapshot = ref<number>();
</script>
<template>
<div class="demo-stack max-w-sm">
<div class="demo-card p-4 flex flex-col gap-3">
<span class="demo-label">App-provided state</span>
<label class="flex items-center gap-3 text-sm text-fg">
<span class="text-xs text-fg-muted w-14">Volume</span>
<input
v-model.number="settings.volume"
type="range"
min="0"
max="100"
class="flex-1 accent-accent cursor-pointer"
>
<span class="font-mono text-xs tabular-nums text-fg-muted w-8 text-right">{{ settings.volume }}</span>
</label>
</div>
<div class="demo-card p-4 flex flex-col gap-3">
<span class="demo-label">Plain function, outside any component</span>
<button
type="button"
class="inline-flex items-center justify-center gap-1.5 rounded-lg border border-transparent bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg transition hover:bg-accent-hover active:scale-[0.98] cursor-pointer"
@click="snapshot = readVolumeFromOutside()"
>
runWithApp(() =&gt; inject(SettingsKey))
</button>
<p class="font-mono text-xs tabular-nums text-fg-muted">
{{ snapshot === undefined ? 'not read yet' : `injected volume: ${snapshot}` }}
</p>
</div>
<p class="text-xs text-fg-subtle">
The function reading the value has no injection context of its own
<span class="font-mono text-fg-muted">runWithApp</span> wraps it in
<span class="font-mono text-fg-muted">app.runWithContext</span> so
<span class="font-mono text-fg-muted">inject()</span> resolves app-level provides.
</p>
</div>
</template>
@@ -0,0 +1,176 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createApp, defineComponent, h, inject, provide } from 'vue';
import type { App, InjectionKey } from 'vue';
import { activeAppPlugin, getActiveApp, injectWithApp, runWithApp, setActiveApp } from '.';
import { VueToolsError } from '@/utils';
const key: InjectionKey<string> = Symbol('TestKey');
function makeApp(setup?: () => void) {
return createApp(defineComponent({
setup() {
setup?.();
return () => h('div');
},
}));
}
function mountApp(app: App) {
app.mount(document.createElement('div'));
return app;
}
beforeEach(() => {
setActiveApp(undefined);
});
describe(setActiveApp, () => {
it('registers the app and returns it for chaining', () => {
const app = makeApp();
expect(getActiveApp()).toBeUndefined();
expect(setActiveApp(app)).toBe(app);
expect(getActiveApp()).toBe(app);
});
it('clears the registration with undefined', () => {
setActiveApp(makeApp());
setActiveApp(undefined);
expect(getActiveApp()).toBeUndefined();
});
});
describe(getActiveApp, () => {
it('prefers the current instance app over the registered one', () => {
const other = makeApp();
setActiveApp(other);
let captured: App | undefined;
const app = mountApp(makeApp(() => {
captured = getActiveApp();
}));
expect(captured).toBe(app);
expect(captured).not.toBe(other);
app.unmount();
});
});
describe(runWithApp, () => {
it('resolves app-level provides through the active app', () => {
const app = makeApp();
app.provide(key, 'from app');
setActiveApp(app);
expect(runWithApp(() => inject(key))).toBe('from app');
});
it('uses an explicitly passed app over the active one', () => {
const active = makeApp();
active.provide(key, 'active');
setActiveApp(active);
const explicit = makeApp();
explicit.provide(key, 'explicit');
expect(runWithApp(() => inject(key), explicit)).toBe('explicit');
});
it('returns the function result', () => {
setActiveApp(makeApp());
expect(runWithApp(() => 42)).toBe(42);
});
it('throws when no app is available', () => {
expect(() => runWithApp(() => inject(key))).toThrow(VueToolsError);
});
});
describe(injectWithApp, () => {
it('resolves app-level provides outside of setup', () => {
const app = makeApp();
app.provide(key, 'from app');
setActiveApp(app);
expect(injectWithApp(key)).toBe('from app');
});
it('behaves like inject inside setup, component provides win', () => {
const app = makeApp();
app.provide(key, 'app level');
setActiveApp(app);
let fromParent: string | undefined;
const Child = defineComponent({
setup() {
fromParent = injectWithApp(key);
return () => h('div');
},
});
const host = createApp(defineComponent({
setup() {
provide(key, 'component level');
return () => h(Child);
},
}));
host.provide(key, 'host app level');
mountApp(host);
expect(fromParent).toBe('component level');
host.unmount();
});
it('falls back to the default value when the key is not provided', () => {
setActiveApp(makeApp());
expect(injectWithApp(key, 'fallback')).toBe('fallback');
expect(injectWithApp(key, () => 'factory', true)).toBe('factory');
});
it('returns the default value when no app is available at all', () => {
expect(injectWithApp(key, 'fallback')).toBe('fallback');
expect(injectWithApp(key, () => 'factory', true)).toBe('factory');
});
it('does not call a function default unless treated as factory', () => {
const fn = vi.fn(() => 'value');
const injected = injectWithApp<() => string>(Symbol('FnKey'), fn);
expect(injected).toBe(fn);
expect(fn).not.toHaveBeenCalled();
});
it('throws when there is no context, no app and no default', () => {
expect(() => injectWithApp(key)).toThrow(VueToolsError);
});
});
describe(activeAppPlugin, () => {
it('registers the app on install', () => {
const app = makeApp().use(activeAppPlugin);
expect(getActiveApp()).toBe(app);
});
it('clears the registration when the app unmounts', () => {
const app = mountApp(makeApp().use(activeAppPlugin));
app.unmount();
expect(getActiveApp()).toBeUndefined();
});
it('keeps the registration when a stale app unmounts', () => {
const first = mountApp(makeApp().use(activeAppPlugin));
const second = makeApp().use(activeAppPlugin);
first.unmount();
expect(getActiveApp()).toBe(second);
});
});
@@ -0,0 +1,150 @@
import { getCurrentInstance, hasInjectionContext, inject } from 'vue';
import type { App, InjectionKey, Plugin } from 'vue';
import { VueToolsError } from '@/utils';
type InjectDefaults<Value> = [defaultValue?: Value | (() => Value), treatDefaultAsFactory?: boolean];
let activeApp: App | undefined;
/**
* @name setActiveApp
* @category State
* @description Registers the Vue app instance used by `getActiveApp`, `runWithApp` and `injectWithApp`
* outside of component context. Pass `undefined` to clear the registration.
*
* The registration is module-global (one slot per JS realm). On the client this is exactly
* what you want; on the server create one app per request and prefer passing the app
* explicitly to `runWithApp` instead of relying on the global slot, otherwise concurrent
* requests may observe each other's app.
*
* @param {App | undefined} app The app to register, or `undefined` to clear
* @returns {App | undefined} The same app, for chaining
*
* @example
* // main.ts
* const app = createApp(App);
* setActiveApp(app);
*
* @since 0.1.0
*/
export function setActiveApp(app: App | undefined) {
activeApp = app;
return app;
}
/**
* @name getActiveApp
* @category State
* @description Returns the closest Vue app instance: the current component's app when called
* during setup (or anywhere `getCurrentInstance` works), otherwise the app registered via
* `setActiveApp` / `activeAppPlugin`.
*
* @returns {App | undefined} The resolved app, or `undefined` when none is available
*
* @example
* const app = getActiveApp();
* app?.config.globalProperties;
*
* @since 0.1.0
*/
export function getActiveApp(): App | undefined {
return getCurrentInstance()?.appContext.app ?? activeApp;
}
/**
* @name runWithApp
* @category State
* @description Runs a function inside `app.runWithContext`, so `inject` (and everything built
* on it) resolves app-level provides even outside of component setup — in router guards,
* store actions, event handlers or timers.
*
* The app defaults to `getActiveApp()`; pass one explicitly to target a specific app
* (recommended for SSR, where apps are created per request).
*
* @param {Function} fn The function to run with the app as injection context
* @param {App} [app] The app to use instead of the active one
* @returns The return value of `fn`
* @throws {VueToolsError} when no app is registered and none is passed
*
* @example
* router.beforeEach(() => {
* const auth = runWithApp(() => inject(AuthKey));
* });
*
* @since 0.1.0
*/
export function runWithApp<Result>(fn: () => Result, app: App | undefined = getActiveApp()): Result {
if (!app)
throw new VueToolsError('runWithApp: no active Vue app, install activeAppPlugin or call setActiveApp first');
return app.runWithContext(fn);
}
/**
* @name injectWithApp
* @category State
* @description Drop-in replacement for `inject` that also works outside of component setup.
* Inside an injection context it behaves exactly like `inject` (component-level provides
* win); outside it resolves app-level provides through the active app. When no app is
* available it falls back to the provided default value, or throws if there is none.
*
* @param {InjectionKey | string} key The injection key
* @param {any} [defaultValue] The value (or factory) to fall back to when the key is not provided
* @param {boolean} [treatDefaultAsFactory] Call `defaultValue` as a factory, like `inject`
* @returns The injected value
* @throws {VueToolsError} when called with no injection context, no active app and no default value
*
* @example
* const theme = injectWithApp(ThemeKey, 'light');
*
* @since 0.1.0
*/
export function injectWithApp<Value>(key: InjectionKey<Value> | string): Value | undefined;
export function injectWithApp<Value>(key: InjectionKey<Value> | string, defaultValue: Value, treatDefaultAsFactory?: false): Value;
export function injectWithApp<Value>(key: InjectionKey<Value> | string, defaultValue: Value | (() => Value), treatDefaultAsFactory: true): Value;
export function injectWithApp<Value>(key: InjectionKey<Value> | string, ...defaults: InjectDefaults<Value>): Value | undefined {
// spread `defaults` as-is: `inject` distinguishes a missing default from an
// explicit `undefined` one via `arguments.length`
const doInject = () => (inject as (...args: [typeof key, ...InjectDefaults<Value>]) => Value | undefined)(key, ...defaults);
if (hasInjectionContext())
return doInject();
const app = getActiveApp();
if (app)
return app.runWithContext(doInject);
if (defaults.length > 0) {
const [defaultValue, treatDefaultAsFactory] = defaults;
return treatDefaultAsFactory && typeof defaultValue === 'function'
? (defaultValue as () => Value)()
: defaultValue as Value;
}
throw new VueToolsError('injectWithApp: no injection context and no active Vue app, install activeAppPlugin or call setActiveApp first');
}
/**
* @name activeAppPlugin
* @category State
* @description Vue plugin that registers the app as the active one and clears the
* registration when the app unmounts (unless another app took over in the meantime).
*
* @example
* // main.ts
* createApp(App).use(activeAppPlugin).mount('#app');
*
* @since 0.1.0
*/
export const activeAppPlugin: Plugin = {
install(app) {
setActiveApp(app);
app.onUnmount(() => {
if (activeApp === app)
setActiveApp(undefined);
});
},
};
@@ -1,3 +1,4 @@
export * from './activeApp';
export * from './createSharedComposable';
export * from './useAppSharedState';
export * from './useAsyncState';
@@ -11,6 +12,7 @@ export * from './useLastChanged';
export * from './useManualRefHistory';
export * from './useOffsetPagination';
export * from './useRefHistory';
export * from './useStateMachine';
export * from './useStepper';
export * from './useThrottledRefHistory';
export * from './useToggle';
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { useStateMachine } from './index';
// A media-player transport: the machine makes the button matrix declarative —
// what each control does (and whether it's enabled) follows from the state.
const { state, send, can, matches } = useStateMachine({
initial: 'stopped',
states: {
stopped: { on: { PLAY: 'playing' } },
playing: { on: { PAUSE: 'paused', STOP: 'stopped' } },
paused: { on: { PLAY: 'playing', STOP: 'stopped' } },
},
});
</script>
<template>
<div class="demo-stack max-w-sm">
<div class="demo-card p-4">
<p class="demo-label">
Media transport
</p>
<div class="mt-3 flex items-center gap-3">
<span
class="demo-badge"
:class="matches('playing') ? 'text-emerald-600 dark:text-emerald-400' : ''"
>
{{ matches('playing') ? '▶' : matches('paused') ? '⏸' : '⏹' }} {{ state }}
</span>
</div>
<div class="mt-3 flex gap-2">
<button
type="button"
class="demo-btn-primary flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!can('PLAY')"
@click="send('PLAY')"
>
Play
</button>
<button
type="button"
class="demo-btn flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!can('PAUSE')"
@click="send('PAUSE')"
>
Pause
</button>
<button
type="button"
class="demo-btn flex-1 disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
:disabled="!can('STOP')"
@click="send('STOP')"
>
Stop
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,184 @@
import { describe, expect, it, vi } from 'vitest';
import { computed } from 'vue';
import { useStateMachine } from './index';
function trafficLight() {
return useStateMachine({
initial: 'red',
states: {
red: { on: { NEXT: 'green' } },
green: { on: { NEXT: 'yellow' } },
yellow: { on: { NEXT: 'red' } },
},
});
}
describe(useStateMachine, () => {
it('starts in the initial state', () => {
const { state, matches } = trafficLight();
expect(state.value).toBe('red');
expect(matches('red')).toBeTruthy();
expect(matches('green')).toBeFalsy();
});
it('transitions on send and mirrors the state into the ref', () => {
const { state, send } = trafficLight();
expect(send('NEXT')).toBe('green');
expect(state.value).toBe('green');
send('NEXT');
expect(state.value).toBe('yellow');
});
it('ignores events without a matching transition', () => {
const { state, send } = useStateMachine({
initial: 'idle',
states: {
idle: { on: { START: 'running' } },
running: {},
},
});
send('START');
expect(send('START')).toBe('running');
expect(state.value).toBe('running');
});
it('is reactive: computeds tracking state/matches/can re-evaluate', () => {
const { send, matches, can, state } = trafficLight();
const isRed = computed(() => matches('red'));
const label = computed(() => state.value.toUpperCase());
const canAdvance = computed(() => can('NEXT'));
expect(isRed.value).toBeTruthy();
expect(label.value).toBe('RED');
expect(canAdvance.value).toBeTruthy();
send('NEXT');
expect(isRed.value).toBeFalsy();
expect(label.value).toBe('GREEN');
});
it('respects guards and exposes them through can()', () => {
const { state, send, can } = useStateMachine({
initial: 'locked',
context: { coins: 0 },
states: {
locked: {
on: {
PUSH: { target: 'open', guard: ctx => ctx.coins > 0 },
COIN: { target: 'locked', action: (ctx) => { ctx.coins++; } },
},
},
open: {},
},
});
expect(can('PUSH')).toBeFalsy();
send('PUSH');
expect(state.value).toBe('locked');
send('COIN');
expect(can('PUSH')).toBeTruthy();
send('PUSH');
expect(state.value).toBe('open');
});
it('runs action, exit, and entry hooks in order', () => {
const order: string[] = [];
const { send } = useStateMachine({
initial: 'a',
states: {
a: {
exit: () => order.push('exit:a'),
on: { GO: { target: 'b', action: () => order.push('action') } },
},
b: {
entry: () => order.push('entry:b'),
},
},
});
send('GO');
expect(order).toEqual(['action', 'exit:a', 'entry:b']);
});
it('settles on the final state when hooks send follow-up events', () => {
const machine = useStateMachine({
initial: 'idle',
states: {
idle: { on: { START: 'transient' } },
transient: {
entry: () => machine.send('CONTINUE'),
on: { CONTINUE: 'done' },
},
done: {},
},
});
expect(machine.send('START')).toBe('done');
expect(machine.state.value).toBe('done');
});
it('keeps can() reactive across context-mutating self-transitions', () => {
const { send, can } = useStateMachine({
initial: 'locked',
context: { coins: 0 },
states: {
locked: {
on: {
PUSH: { target: 'open', guard: ctx => ctx.coins > 0 },
COIN: { target: 'locked', action: (ctx) => { ctx.coins++; } },
},
},
open: {},
},
});
const canPush = computed(() => can('PUSH'));
expect(canPush.value).toBeFalsy();
// Self-transition: the state string does not change, only the context.
send('COIN');
expect(canPush.value).toBeTruthy();
});
it('keeps the state ref in sync when a hook throws', () => {
const { state, send } = useStateMachine({
initial: 'a',
states: {
a: { on: { GO: 'b' } },
b: { entry: () => { throw new Error('boom'); } },
},
});
expect(() => send('GO')).toThrow('boom');
expect(state.value).toBe('b');
});
it('exposes the raw machine with its context', () => {
const onEnter = vi.fn();
const { machine, send } = useStateMachine({
initial: 'off',
context: { toggles: 0 },
states: {
off: { on: { TOGGLE: { target: 'on', action: (ctx) => { ctx.toggles++; } } } },
on: { entry: onEnter },
},
});
send('TOGGLE');
expect(machine.context.toggles).toBe(1);
expect(machine.current).toBe('on');
expect(machine.matches('on')).toBeTruthy();
expect(onEnter).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,122 @@
import { shallowRef } from 'vue';
import { StateMachine } from '@robonen/stdlib';
import type { ExtractEvents, ExtractStates, SyncStateNodeConfig } from '@robonen/stdlib';
import type { ShallowRef } from 'vue';
export interface UseStateMachineReturn<
States extends string,
Events extends string,
Context,
> {
/** Reactive current state of the machine. */
state: Readonly<ShallowRef<States>>;
/**
* Send an event to the machine, potentially causing a transition.
* Returns the state the machine settled on (entry/exit hooks may themselves
* send events; the returned state is the final one).
*/
send: (event: Events) => States;
/** Reactive check: is the machine currently in `state`? */
matches: (state: States) => boolean;
/** Reactive check: can `event` cause a transition from the current state? */
can: (event: Events) => boolean;
/** The underlying stdlib machine (context access, non-reactive escape hatch). */
machine: StateMachine<States, Events, Context>;
}
/**
* @name useStateMachine
* @category State
* @description Reactive wrapper around the stdlib `StateMachine`: a type-safe
* finite state machine whose current state is exposed as a shallow ref, so
* templates and computeds can branch on `state`/`matches`/`can`.
*
* States, events, guards, and entry/exit hooks follow the stdlib
* `createMachine` config verbatim — this composable only adds reactivity.
*
* @param {object} config Machine config: `initial`, optional `context`, and `states`
* @returns {UseStateMachineReturn} Reactive state plus `send`/`matches`/`can` and the raw machine
*
* @example
* const { state, send, can } = useStateMachine({
* initial: 'idle',
* states: {
* idle: { on: { FETCH: 'loading' } },
* loading: { on: { RESOLVE: 'idle', REJECT: 'failed' } },
* failed: { on: { RETRY: 'loading' } },
* },
* });
*
* send('FETCH'); // state.value === 'loading'
* can('RETRY'); // false — reactive, usable in computeds/templates
*
* @since 0.2.0
*/
export function useStateMachine<
const States extends Record<string, SyncStateNodeConfig<Context>>,
Context,
>(config: {
initial: NoInfer<ExtractStates<States>>;
context: Context;
states: States;
}): UseStateMachineReturn<ExtractStates<States>, ExtractEvents<States>, Context>;
export function useStateMachine<
const States extends Record<string, SyncStateNodeConfig<undefined>>,
>(config: {
initial: NoInfer<ExtractStates<States>>;
states: States;
}): UseStateMachineReturn<ExtractStates<States>, ExtractEvents<States>, undefined>;
export function useStateMachine(config: {
initial: string;
context?: unknown;
// Overload-implementation signature (mirrors stdlib `createMachine`): `any`
// accepts every concrete `SyncStateNodeConfig<C>` — contravariant in `C` —
// and `Context = undefined` keeps the invariant `StateMachine<..., Context>`
// comparable with both public overloads.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
states: Record<string, SyncStateNodeConfig<any>>;
}): UseStateMachineReturn<string, string, undefined> {
const machine = new StateMachine(config.initial, config.states, config.context as undefined);
const state = shallowRef(machine.current);
// Bumped on EVERY send: a self-transition leaves the state string unchanged
// (so `state` doesn't trigger) yet its action may mutate the context that
// `can()` guards read.
const epoch = shallowRef(0);
function send(event: string): string {
// Mirror the settled state (not send's return value — entry/exit hooks may
// send follow-up events) even when a hook throws: the machine has already
// advanced by the time hooks run.
try {
machine.send(event);
}
finally {
state.value = machine.current;
epoch.value++;
}
return machine.current;
}
function matches(value: string): boolean {
return state.value === value;
}
function can(event: string): boolean {
// Track the send epoch (it covers state changes too) so callers re-evaluate
// after every transition, including context-mutating self-transitions.
void epoch.value;
return machine.can(event);
}
return { state, send, matches, can, machine };
}
@@ -28,7 +28,8 @@ function toggle(key: (typeof categories)[number]['key']) {
consent.value = { ...consent.value, [key]: !consent.value[key] };
}
const supportsCookieStore = typeof window !== 'undefined' && 'cookieStore' in window;
// Probing `globalThis` keeps this SSR-safe without a browser-only guard.
const supportsCookieStore = 'cookieStore' in globalThis;
// Show the raw cookie as the browser stores it.
const rawCookie = ref('');
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@robonen/writekit",
"version": "0.0.1",
"version": "0.0.4",
"license": "Apache-2.0",
"description": "Headless block-based rich-text writekit for Vue with a registry-driven schema and pluggable CRDT",
"keywords": [
@@ -19,7 +19,7 @@
"url": "git+https://github.com/robonen/tools.git",
"directory": "vue/writekit"
},
"packageManager": "pnpm@11.7.0",
"packageManager": "pnpm@11.18.0",
"engines": {
"node": ">=24.16.0"
},
@@ -63,16 +63,16 @@
"@robonen/tsconfig": "workspace:*",
"@robonen/tsdown": "workspace:*",
"@vitest/browser": "catalog:",
"@vitest/browser-playwright": "^4.1.9",
"@vitest/browser-playwright": "^4.1.10",
"@vue/test-utils": "catalog:",
"eslint": "catalog:",
"jsdom": "catalog:",
"playwright": "^1.61.0",
"playwright": "^1.62.0",
"tsdown": "catalog:",
"unplugin-vue": "^7.2.0",
"vitest-browser-vue": "^2.1.0",
"vue": "catalog:",
"vue-tsc": "^3.3.5"
"vue-tsc": "^3.3.8"
},
"dependencies": {
"@robonen/crdt": "workspace:*",
+3 -3
View File
@@ -16,8 +16,8 @@
},
"devDependencies": {
"@robonen/tsconfig": "workspace:*",
"@vitejs/plugin-vue": "^6.0.7",
"vite": "^8.0.16",
"vue-tsc": "^3.3.5"
"@vitejs/plugin-vue": "^6.0.8",
"vite": "^8.1.5",
"vue-tsc": "^3.3.8"
}
}
+1 -1
View File
@@ -9,5 +9,5 @@ export const blockquote = defineBlock({
parseDOM: [{ tag: 'blockquote' }],
},
inputRules: [{ match: /^>\s$/ }],
meta: { title: 'Quote', icon: 'quote', keywords: ['quote', 'blockquote', 'citation'], group: 'basic' },
meta: { title: 'Quote', icon: 'quote', keywords: ['quote', 'blockquote', 'citation'], group: 'basic', description: 'Set a passage apart from the narration.' },
});
+1 -1
View File
@@ -13,5 +13,5 @@ export const callout = defineBlock({
getAttrs: (el: HTMLElement) => ({ variant: el.getAttribute('data-callout') ?? 'info' }),
}],
},
meta: { title: 'Callout', icon: 'info', keywords: ['callout', 'note', 'info', 'warning'], group: 'basic' },
meta: { title: 'Callout', icon: 'info', keywords: ['callout', 'note', 'info', 'warning'], group: 'basic', description: 'A highlighted note the eye cannot miss.' },
});

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