6 Commits

Author SHA1 Message Date
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
51 changed files with 1533 additions and 153 deletions
+1 -1
View File
@@ -67,7 +67,7 @@
"tsdown": "catalog:"
},
"peerDependencies": {
"eslint": ">=10.8.1"
"eslint": ">=9.39.4"
},
"publishConfig": {
"access": "public"
+1 -1
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": [
+1
View File
@@ -8,6 +8,7 @@
"vueCompilerOptions": {
"strictTemplates": true,
"fallthroughAttributes": true,
"htmlAttributes": ["aria-*", "data-*"],
"inferTemplateDollarAttrs": true,
"inferTemplateDollarEl": true,
"inferTemplateDollarRefs": true
+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;
}
+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.2",
"version": "0.0.5",
"exports": "./src/index.ts"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@robonen/primitives",
"version": "0.0.2",
"version": "0.0.5",
"license": "Apache-2.0",
"description": "Collection of UI primitives",
"keywords": [
@@ -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();
@@ -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.
+9 -1
View File
@@ -4,6 +4,9 @@ import type { NodeSpec } from '../schema';
import type { CommandFactory } from '../state/command';
import type { InputRuleSpec } from './input-rule';
/** A lazy block component: resolved by the view on first render. */
export type BlockComponentLoader = () => Promise<Component | { default: Component }>;
/** Props passed to an atom/void block's Vue `component`. */
export interface BlockComponentProps {
/** The block's model node (read its `attrs`). */
@@ -36,11 +39,16 @@ export interface BlockBehavior {
* A block definition: schema contribution + behavior + an opaque Vue component.
* Non-view layers treat `component` as an opaque value; only the view resolves
* it. The type is `Component` purely for authoring ergonomics (type-only import).
*
* `component` may be a lazy loader (`() => import('./Card.vue')`): a registry
* imported for its SCHEMA a codec, a test, a server-side normalizer then
* carries no view graph at all, and the view resolves the loader on first
* render exactly like any async component.
*/
export interface BlockDefinition {
readonly type: string;
readonly spec: NodeSpec;
readonly component?: Component;
readonly component?: Component | BlockComponentLoader;
readonly meta?: BlockMeta;
readonly behavior?: BlockBehavior;
readonly commands?: Record<string, CommandFactory>;
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest';
import { normalizeDocument } from '../normalize';
import { createSchema } from '../schema';
const schema = createSchema({
nodes: new Map([
['paragraph', {
content: { kind: 'text' as const },
attrs: {
condition: { default: null },
level: { default: 1, validate: (v: unknown) => typeof v === 'number' && v >= 1 && v <= 6 },
},
}],
['bare', { content: { kind: 'atom' as const } }],
]),
marks: new Map(),
});
describe('attr coercion', () => {
it('keeps unknown attributes instead of erasing them', () => {
// Coercion is not a whitelist: a document must round-trip through an
// editor whose schema does not know every field — dropping them silently
// erased consumer data, and the loss was autosaved before anyone saw it.
const attrs = schema.coerceAttrs('paragraph', {
condition: { op: 'flag', key: 'met' },
futureField: 'still here',
});
expect(attrs.futureField).toBe('still here');
expect(attrs.condition).toEqual({ op: 'flag', key: 'met' });
expect(attrs.level).toBe(1);
});
it('keeps attrs even when the spec declares none', () => {
expect(schema.coerceAttrs('bare', { anything: 1 })).toEqual({ anything: 1 });
});
it('runs validate and falls back to the default on a rejected value', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
// `validate` looked like enforcement and never ran; an out-of-range level
// normalized cleanly and rendered <h99>.
const attrs = schema.coerceAttrs('paragraph', { level: 99 });
expect(attrs.level).toBe(1);
expect(warn).toHaveBeenCalledOnce();
warn.mockRestore();
});
it('accepts a value validate approves', () => {
expect(schema.coerceAttrs('paragraph', { level: 3 }).level).toBe(3);
});
it('is idempotent — a second pass changes nothing', () => {
const once = schema.coerceAttrs('paragraph', { level: 2, custom: [1, 2] });
const twice = schema.coerceAttrs('paragraph', once);
expect(twice).toEqual(once);
});
it('carries unknown attrs through normalizeDocument', () => {
const doc = {
content: [{
id: 'b1',
type: 'paragraph',
attrs: { condition: { op: 'flag', key: 'met' }, futureField: true },
content: [{ text: 'hi', marks: [] }],
}],
};
const normalized = normalizeDocument(doc as never, schema);
expect(normalized.content[0]!.attrs.futureField).toBe(true);
expect(normalized.content[0]!.attrs.condition).toEqual({ op: 'flag', key: 'met' });
});
});
+41 -7
View File
@@ -14,27 +14,61 @@ export interface Schema {
markSpec: (type: string) => MarkSpec | undefined;
/** Default attrs for a block type (all defaults applied). */
defaultAttrs: (type: string) => Attrs;
/** Fill defaults and drop unknown keys for a block type. */
/** Fill defaults, run `validate`, keep unknown keys for a block type. */
coerceAttrs: (type: string, attrs?: Attrs) => Attrs;
/** Default attrs for a mark type. */
defaultMarkAttrs: (type: string) => Attrs;
/** Fill defaults and drop unknown keys for a mark type. */
/** Fill defaults, run `validate`, keep unknown keys for a mark type. */
coerceMarkAttrs: (type: string, attrs?: Attrs) => Attrs;
}
/**
* Coercion fills defaults and enforces `validate`; it is NOT a whitelist.
*
* Unknown keys pass through verbatim: a document round-tripping through the
* editor must never lose fields this schema version does not know about
* dropping them silently erased consumer data (a `condition` attribute the
* spec forgot to declare disappeared on the first normalization pass and the
* loss was autosaved). Parse rules build attrs explicitly, so pasted markup
* cannot smuggle arbitrary keys through this path.
*
* A provided value failing its `validate` falls back to the declared default:
* deterministic for CRDT replicas (given one spec), loud in dev, and never a
* silently-kept invalid value.
*/
function coerceWithSpec(spec: AttrsSpec | undefined, attrs?: Attrs): Attrs {
if (!spec)
return {};
if (!spec) {
return attrs ? { ...attrs } : {};
}
const result: Record<string, AttrValue> = {};
if (attrs) {
for (const key in attrs) {
if (attrs[key] !== undefined && !(key in spec))
result[key] = attrs[key]!;
}
}
for (const key in spec) {
const attr = spec[key]!;
const provided = attrs?.[key];
if (provided !== undefined)
if (provided !== undefined) {
if (attr.validate && !attr.validate(provided)) {
if (__DEV__)
console.warn(`[writekit] Attr "${key}" rejected by validate(); falling back to its default.`, provided);
if (attr.default !== undefined)
result[key] = attr.default;
}
else {
result[key] = provided;
else if (spec[key]!.default !== undefined)
result[key] = spec[key]!.default!;
}
}
else if (attr.default !== undefined) {
result[key] = attr.default;
}
}
return result;
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { HistoryEntry } from '../history';
import type { Step } from '../step';
import { createHistory } from '../history';
const caret = { type: 'text', anchor: { blockId: 'b1', offset: 0 }, focus: { blockId: 'b1', offset: 0 } } as never;
function typing(blockId: string, text: string): HistoryEntry {
return {
steps: [{ type: 'insertInline', blockId, offset: 0, content: [{ text, marks: [] }] } as Step],
inverted: [{ type: 'deleteText', blockId, from: 0, to: text.length } as Step],
selectionBefore: caret,
selectionAfter: caret,
};
}
function structural(blockId: string): HistoryEntry {
return {
steps: [{ type: 'removeBlock', blockId } as Step],
inverted: [{ type: 'insertBlock', node: { id: blockId, type: 'paragraph', attrs: {}, content: [] }, index: 0 } as never],
selectionBefore: caret,
selectionAfter: caret,
};
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe('history coalescing', () => {
it('merges a typing burst in one block into one undo press', () => {
const history = createHistory();
for (const ch of ['h', 'e', 'l', 'l', 'o']) {
history.record(typing('b1', ch));
vi.advanceTimersByTime(100);
}
const entry = history.undo()!;
expect(entry.steps).toHaveLength(5);
expect(history.canUndo()).toBe(false);
});
it('keeps the replay order: later keystrokes undo first', () => {
const history = createHistory();
history.record(typing('b1', 'a'));
history.record(typing('b1', 'b'));
const entry = history.undo()!;
// `inverted` stays in application order; undo replays it reversed, so the
// inverse of "b" must sit AFTER the inverse of "a".
expect(entry.inverted.map(step => (step as { to: number }).to)).toEqual([1, 1]);
expect(entry.steps.map(step => (step as { content: Array<{ text: string }> }).content[0]!.text)).toEqual(['a', 'b']);
});
it('starts a new group after the time window', () => {
const history = createHistory({ coalesceMs: 500 });
history.record(typing('b1', 'a'));
vi.advanceTimersByTime(600);
history.record(typing('b1', 'b'));
history.undo();
expect(history.canUndo()).toBe(true);
});
it('never merges across blocks', () => {
const history = createHistory();
history.record(typing('b1', 'a'));
history.record(typing('b2', 'b'));
history.undo();
expect(history.canUndo()).toBe(true);
});
it('never merges structural changes', () => {
const history = createHistory();
history.record(typing('b1', 'a'));
history.record(structural('b1'));
history.record(typing('b1', 'b'));
expect(history.undo()!.steps).toHaveLength(1);
expect(history.undo()!.steps).toHaveLength(1);
expect(history.undo()!.steps).toHaveLength(1);
});
it('breaks the chain on interrupt — a foreign transaction is a boundary', () => {
// A remote setDoc or an undo between keystrokes must not be spliced into
// one undo press with them.
const history = createHistory();
history.record(typing('b1', 'a'));
history.interrupt();
history.record(typing('b1', 'b'));
history.undo();
expect(history.canUndo()).toBe(true);
});
it('counts groups, not keystrokes, against maxSize', () => {
const history = createHistory({ maxSize: 2 });
// Two bursts of three keystrokes: two groups — both must survive.
for (const ch of ['a', 'b', 'c'])
history.record(typing('b1', ch));
vi.advanceTimersByTime(1000);
for (const ch of ['d', 'e', 'f'])
history.record(typing('b1', ch));
expect(history.undo()!.steps).toHaveLength(3);
expect(history.undo()!.steps).toHaveLength(3);
expect(history.canUndo()).toBe(false);
});
it('can be disabled outright', () => {
const history = createHistory({ coalesceMs: 0 });
history.record(typing('b1', 'a'));
history.record(typing('b1', 'b'));
history.undo();
expect(history.canUndo()).toBe(true);
});
});
+73
View File
@@ -15,6 +15,14 @@ export interface HistoryEntry {
export interface HistoryOptions {
/** Maximum number of undo entries to retain (default 200). */
readonly maxSize?: number;
/**
* Coalesce a new entry into the previous one when both are plain typing in
* the same block and land within this window (ms). One keystroke per
* transaction otherwise makes Ctrl+Z a character-by-character crawl, and a
* short paragraph evicts the whole earlier history through `maxSize`.
* `0` disables coalescing. @default 500
*/
readonly coalesceMs?: number;
}
/**
@@ -26,6 +34,13 @@ export interface HistoryOptions {
export interface History {
/** Record a new edit, clearing the redo stack. */
record: (entry: HistoryEntry) => void;
/**
* Break the coalescing chain: the next recorded entry starts its own group.
* Called for anything that lands between recordings (a remote change, an
* undo/redo, a selection jump) merging across such a boundary would splice
* foreign state into one undo press.
*/
interrupt: () => void;
/** Pop the latest undo entry (and push it onto the redo stack). */
undo: () => HistoryEntry | undefined;
/** Pop the latest redo entry (and push it back onto the undo stack). */
@@ -35,18 +50,75 @@ export interface History {
clear: () => void;
}
/** Plain typing: text-only steps confined to a single block. */
function typingBlockOf(steps: readonly Step[]): string | null {
let block: string | null = null;
for (const step of steps) {
if (step.type !== 'insertInline' && step.type !== 'deleteText' && step.type !== 'replaceInline')
return null;
if (block === null)
block = step.blockId;
else if (block !== step.blockId)
return null;
}
return block;
}
export function createHistory(options: HistoryOptions = {}): History {
const maxSize = options.maxSize ?? 200;
const coalesceMs = options.coalesceMs ?? 500;
const undoStack: HistoryEntry[] = [];
const redoStack: HistoryEntry[] = [];
let lastRecordAt = 0;
let lastTypingBlock: string | null = null;
/**
* Concatenation preserves the replay invariant: `inverted` is stored in
* application order and replayed reversed, so a merged entry undoes the
* later keystrokes first exactly as separate entries would, in one press.
*/
function coalesce(top: HistoryEntry, entry: HistoryEntry): HistoryEntry {
return {
steps: [...top.steps, ...entry.steps],
inverted: [...top.inverted, ...entry.inverted],
selectionBefore: top.selectionBefore,
selectionAfter: entry.selectionAfter,
};
}
return {
record(entry) {
const now = Date.now();
const block = typingBlockOf(entry.steps);
const top = undoStack[undoStack.length - 1];
const mergeable
= coalesceMs > 0
&& top !== undefined
&& block !== null
&& block === lastTypingBlock
&& now - lastRecordAt <= coalesceMs;
if (mergeable) {
undoStack[undoStack.length - 1] = coalesce(top, entry);
}
else {
undoStack.push(entry);
if (undoStack.length > maxSize)
undoStack.shift();
}
lastRecordAt = now;
lastTypingBlock = block;
redoStack.length = 0;
},
interrupt() {
lastTypingBlock = null;
},
undo() {
const entry = undoStack.pop();
if (entry)
@@ -64,6 +136,7 @@ export function createHistory(options: HistoryOptions = {}): History {
clear() {
undoStack.length = 0;
redoStack.length = 0;
lastTypingBlock = null;
},
};
}
+5
View File
@@ -63,6 +63,11 @@ export function createWritekit(options: CreateWritekitOptions): Writekit {
selectionAfter: next.selection,
});
}
else {
// Anything that lands between recordings — a remote setDoc, undo/redo, a
// selection-only move — is a boundary the coalescer must not merge over.
history.interrupt();
}
bus.emit('transaction', tr, next, prev);
if (next.doc !== prev.doc)
+27 -3
View File
@@ -3,8 +3,9 @@ import type { Attrs, Node } from '../model';
</script>
<script setup lang="ts">
import type { IntrinsicElementAttributes } from 'vue';
import { computed } from 'vue';
import type { Component, IntrinsicElementAttributes } from 'vue';
import type { BlockDefinition } from '../registry';
import { computed, defineAsyncComponent } from 'vue';
import { nodeSelection } from '../model';
import { createTransaction } from '../state';
import { Primitive } from './primitive';
@@ -21,7 +22,30 @@ const ctx = useWritekitContext();
const def = computed(() => ctx.registry.getBlock(block.type));
const wrapperTag = computed<keyof IntrinsicElementAttributes>(() => (def.value?.as ?? 'div') as keyof IntrinsicElementAttributes);
const isText = computed(() => def.value?.spec.content.kind === 'text');
const atomComponent = computed(() => def.value?.component);
/**
* A function-shaped `component` is a lazy loader; wrap it once per definition
* so repeated renders reuse the same async component (and its resolved state)
* instead of re-importing per block instance.
*/
const asyncCache = new WeakMap<() => Promise<unknown>, Component>();
function resolveComponent(raw: BlockDefinition['component']): Component | undefined {
if (typeof raw !== 'function' || (raw as Component & { render?: unknown }).render || (raw as { setup?: unknown }).setup)
return raw as Component | undefined;
const loader = raw as () => Promise<Component | { default: Component }>;
let wrapped = asyncCache.get(loader);
if (!wrapped) {
wrapped = defineAsyncComponent(() =>
loader().then(m => ('default' in m ? m.default : m) as Component));
asyncCache.set(loader, wrapped);
}
return wrapped;
}
const atomComponent = computed(() => resolveComponent(def.value?.component));
const isSelected = computed(() => {
const sel = ctx.state.value.selection;
return sel.kind === 'node' && sel.ids.includes(block.id);