9 Commits

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

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

writekit 0.0.4.

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

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

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

writekit 0.0.3.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:14:44 +07:00
70 changed files with 1914 additions and 235 deletions
+1 -1
View File
@@ -67,7 +67,7 @@
"tsdown": "catalog:" "tsdown": "catalog:"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": ">=10.8.1" "eslint": ">=9.39.4"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/tsconfig", "name": "@robonen/tsconfig",
"version": "0.1.0", "version": "0.1.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Base typescript configuration for projects", "description": "Base typescript configuration for projects",
"keywords": [ "keywords": [
+1
View File
@@ -8,6 +8,7 @@
"vueCompilerOptions": { "vueCompilerOptions": {
"strictTemplates": true, "strictTemplates": true,
"fallthroughAttributes": true, "fallthroughAttributes": true,
"htmlAttributes": ["aria-*", "data-*"],
"inferTemplateDollarAttrs": true, "inferTemplateDollarAttrs": true,
"inferTemplateDollarEl": true, "inferTemplateDollarEl": true,
"inferTemplateDollarRefs": true "inferTemplateDollarRefs": true
+6 -1
View File
@@ -89,7 +89,12 @@ const roleColor: Record<string, string> = {
<DocsEmitsTable :emits="part.emits" /> <DocsEmitsTable :emits="part.emits" />
</div> </div>
<p v-if="part.props.length === 0 && part.emits.length === 0" class="text-sm text-fg-subtle italic"> <div v-if="part.exposes?.length" class="mb-3">
<div class="text-[11px] font-semibold uppercase tracking-wider text-fg-subtle mb-2">Exposes (template ref)</div>
<DocsExposesTable :exposes="part.exposes" />
</div>
<p v-if="part.props.length === 0 && part.emits.length === 0 && !part.exposes?.length" class="text-sm text-fg-subtle italic">
No props or events renders its element and forwards attributes. No props or events renders its element and forwards attributes.
</p> </p>
</div> </div>
+5
View File
@@ -12,6 +12,7 @@ defineProps<{
<tr class="bg-bg-subtle text-left"> <tr class="bg-bg-subtle text-left">
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Event</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Event</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Payload</th> <th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Payload</th>
<th class="py-2.5 px-4 font-medium text-fg-muted text-xs uppercase tracking-wider">Description</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -22,6 +23,10 @@ defineProps<{
<td class="py-2.5 px-4"> <td class="py-2.5 px-4">
<code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ e.payload }}</code> <code class="text-xs font-mono text-fg-muted bg-bg-inset px-1.5 py-0.5 rounded border border-border wrap-break-word">{{ e.payload }}</code>
</td> </td>
<td class="py-2.5 px-4 text-fg-muted min-w-48">
<DocsText v-if="e.description" :text="e.description" />
<span v-else></span>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
+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 { basename, dirname, relative, resolve } from 'node:path';
import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { Node, Project, SyntaxKind } from 'ts-morph'; import { Node, Project, SyntaxKind, ts } from 'ts-morph';
import type { ClassDeclaration, FunctionDeclaration, InterfaceDeclaration, JSDoc, JSDocTag, MethodDeclaration, PropertyDeclaration, PropertySignature, SourceFile, TypeAliasDeclaration, VariableDeclaration } from 'ts-morph'; import type { ClassDeclaration, FunctionDeclaration, InterfaceDeclaration, JSDoc, JSDocTag, MethodDeclaration, PropertyDeclaration, PropertySignature, SourceFile, TypeAliasDeclaration, VariableDeclaration } from 'ts-morph';
import type { import type {
CategoryMeta, CategoryMeta,
@@ -858,6 +858,144 @@ function extractScriptBlock(sfc: string, setup: boolean): string {
return ''; return '';
} }
// ── SFC type project ─────────────────────────────────────────────────────────
/**
* One type-checking project per components package: every real `src/**\/*.ts`
* file plus, for each SFC part, a virtual `<file>.vue.ts` mirror holding its
* two script blocks. TS resolves a `./X.vue` specifier by appending `.ts`, so
* the mirrors make cross-file shapes resolve for real — `defineEmits<XEmits>()`
* where the interface lives in another block, a sibling `.ts` or another SFC,
* and `defineExpose({ ...api })` where the spread's type is a composable's
* return. The per-part regexes never saw any of those, which is exactly how
* half of Flow's API ended up invisible in the docs.
*/
function buildSfcProject(pkgDir: string): Project {
const srcDir = resolve(pkgDir, 'src');
const tsconfigPath = resolve(pkgDir, 'tsconfig.json');
const project = new Project({
tsConfigFilePath: existsSync(tsconfigPath) ? tsconfigPath : undefined,
skipAddingFilesFromTsConfig: true,
});
project.addSourceFilesAtPaths([`${srcDir}/**/*.ts`, `!${srcDir}/**/__test__/**`]);
for (const entry of readdirSync(srcDir, { recursive: true, withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.vue') || entry.name === 'demo.vue') continue;
const full = resolve(entry.parentPath, entry.name);
if (full.includes('__test__')) continue;
const sfc = readFileSync(full, 'utf-8');
const script = `${extractScriptBlock(sfc, false)}\n${extractScriptBlock(sfc, true)}`;
if (script.trim()) project.createSourceFile(`${full}.ts`, script, { overwrite: true });
}
return project;
}
/** Type display: keep alias names (`Ref<T>`, not its expansion), never truncate. */
const TYPE_TEXT_FLAGS = ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope | ts.TypeFormatFlags.NoTruncation;
/** JSDoc description of a declaration; a const's doc sits on its statement. */
function describeDecl(node: Node | undefined): string {
if (!node) return '';
const holder = Node.isVariableDeclaration(node) ? node.getVariableStatement() ?? node : node;
if (!Node.isJSDocable(holder)) return '';
const jsdocs = holder.getJsDocs();
return getDescription(jsdocs, getJsDocTags(jsdocs));
}
/**
* Emits through the checker's view of `defineEmits<T>()`: the inline literal
* AND a named interface (same block, sibling `.ts`, another SFC via the
* mirrors), `extends` chains included — with each member's JSDoc.
*/
function extractEmitsFrom(sf: SourceFile): EmitMeta[] {
const call = sf.getDescendantsOfKind(SyntaxKind.CallExpression)
.find(c => c.getExpression().getText() === 'defineEmits');
const typeArg = call?.getTypeArguments()[0];
if (!call || !typeArg) return [];
const emits: EmitMeta[] = [];
for (const prop of typeArg.getType().getProperties()) {
const decl = prop.getDeclarations()[0];
const written = decl && Node.isPropertySignature(decl) ? decl.getTypeNode()?.getText() : undefined;
emits.push({
name: prop.getName(),
payload: cleanType(written ?? prop.getTypeAtLocation(call).getText(call, TYPE_TEXT_FLAGS)),
description: describeDecl(decl),
});
}
return emits;
}
/**
* `defineExpose({ … })` → the template-ref surface. Spreads expand through the
* checker (`...api` lists every member of the composable's return type with its
* JSDoc), so the docs show the full instance API instead of nothing at all.
*/
function extractExposesFrom(sf: SourceFile): PropertyMeta[] {
const call = sf.getDescendantsOfKind(SyntaxKind.CallExpression)
.find(c => c.getExpression().getText() === 'defineExpose');
const arg = call?.getArguments()[0];
if (!arg || !Node.isObjectLiteralExpression(arg)) return [];
const out: PropertyMeta[] = [];
const push = (name: string, type: string, description: string, optional = false) => {
if (!out.some(p => p.name === name))
out.push({ name, type: cleanType(type), description, optional, defaultValue: null, readonly: false });
};
for (const member of arg.getProperties()) {
if (Node.isSpreadAssignment(member)) {
const spreadType = member.getExpression().getType();
const props = spreadType.getProperties();
// Unresolvable spread — surface it verbatim rather than dropping it.
if (spreadType.isAny() || props.length === 0) {
push(member.getText(), '', '');
continue;
}
for (const prop of props) {
const decl = prop.getDeclarations()[0];
const written = decl && Node.isPropertySignature(decl) ? decl.getTypeNode()?.getText() : undefined;
push(
prop.getName(),
written ?? prop.getTypeAtLocation(member).getText(member, TYPE_TEXT_FLAGS),
describeDecl(decl),
decl !== undefined && Node.isQuestionTokenable(decl) && decl.hasQuestionToken(),
);
}
}
else if (Node.isShorthandPropertyAssignment(member)) {
const local = sf.getProject().getTypeChecker().getShorthandAssignmentValueSymbol(member);
push(
member.getName(),
member.getType().getText(member, TYPE_TEXT_FLAGS),
describeDecl(local?.getDeclarations()[0]),
);
}
else if (Node.isPropertyAssignment(member)) {
const init = member.getInitializer();
const initDecl = init && Node.isIdentifier(init) ? init.getSymbol()?.getDeclarations()[0] : undefined;
push(
member.getName().replaceAll(/^['"]|['"]$/g, ''),
(init ?? member).getType().getText(member, TYPE_TEXT_FLAGS),
describeDecl(member) || describeDecl(initDecl),
);
}
else if (Node.isMethodDeclaration(member)) {
push(member.getName(), member.getType().getText(member, TYPE_TEXT_FLAGS), describeDecl(member));
}
}
return out;
}
/** Parse `defineEmits<{ 'a': [x: T]; b: [] }>()` from a setup block. */ /** Parse `defineEmits<{ 'a': [x: T]; b: [] }>()` from a setup block. */
function extractEmits(setupScript: string): EmitMeta[] { function extractEmits(setupScript: string): EmitMeta[] {
const m = setupScript.match(/defineEmits<\{([\s\S]*?)\}>\s*\(\s*\)/); const m = setupScript.match(/defineEmits<\{([\s\S]*?)\}>\s*\(\s*\)/);
@@ -907,7 +1045,7 @@ function extractModels(setupScript: string): { props: PropertyMeta[]; emits: Emi
defaultValue: null, defaultValue: null,
readonly: false, readonly: false,
}); });
emits.push({ name: `update:${name}`, payload: `[value: ${type}]`, description: '' }); emits.push({ name: `update:${name}`, payload: `[value: ${type}]`, description: `Emitted when \`v-model${name === 'modelValue' ? '' : `:${name}`}\` updates.` });
} }
return { props, emits }; return { props, emits };
@@ -968,7 +1106,7 @@ function roleFromName(componentName: string, base: string): string {
* not a component group (no `.vue`). `category` is the display label; `entryPoint` * not a component group (no `.vue`). `category` is the display label; `entryPoint`
* is the package subpath (e.g. `./forms/checkbox`). * is the package subpath (e.g. `./forms/checkbox`).
*/ */
function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string): ComponentMeta | null { function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string, sfcProject?: Project): ComponentMeta | null {
// A component group is any dir that ships at least one .vue file. // A component group is any dir that ships at least one .vue file.
const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue')); const vueFiles = readdirSync(dir).filter(f => f.endsWith('.vue'));
if (vueFiles.length === 0) return null; if (vueFiles.length === 0) return null;
@@ -1001,16 +1139,22 @@ function buildComponentAt(dir: string, slug: string, category: string, entryPoin
const role = roleFromName(name, base); const role = roleFromName(name, base);
if (role === 'Root' && description && !groupDescription) groupDescription = description; if (role === 'Root' && description && !groupDescription) groupDescription = description;
// Emits/exposes come from the typed SFC project when it has this part;
// the regex parser stays as the fallback for inline-literal emits.
const virtual = sfcProject?.getSourceFile(`${resolve(dir, file)}.ts`);
let emits = virtual ? extractEmitsFrom(virtual) : [];
if (emits.length === 0) emits = extractEmits(setup);
const exposes = virtual ? extractExposesFrom(virtual) : [];
// Merge in `defineModel` v-model props/emits (invisible to the interface/ // Merge in `defineModel` v-model props/emits (invisible to the interface/
// defineEmits parsers), de-duping against any explicitly-declared ones. // defineEmits parsers), de-duping against any explicitly-declared ones.
const models = extractModels(setup); const models = extractModels(setup);
const emits = extractEmits(setup);
for (const mp of models.props) for (const mp of models.props)
if (!props.some(p => p.name === mp.name)) props.push(mp); if (!props.some(p => p.name === mp.name)) props.push(mp);
for (const me of models.emits) for (const me of models.emits)
if (!emits.some(e => e.name === me.name)) emits.push(me); if (!emits.some(e => e.name === me.name)) emits.push(me);
parts.push({ name, role, description, props, emits }); parts.push({ name, role, description, props, emits, exposes });
} }
return { return {
@@ -1030,6 +1174,7 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
const srcDir = resolve(pkgDir, 'src'); const srcDir = resolve(pkgDir, 'src');
if (!existsSync(srcDir)) return []; if (!existsSync(srcDir)) return [];
const sfcProject = buildSfcProject(pkgDir);
const components: ComponentMeta[] = []; const components: ComponentMeta[] = [];
// Components live one level deep, in category folders: src/<category>/<component>/. // Components live one level deep, in category folders: src/<category>/<component>/.
@@ -1048,13 +1193,14 @@ function buildComponents(pkgDir: string): ComponentMeta[] {
compEntry.name, compEntry.name,
label, label,
`./${catEntry.name}/${compEntry.name}`, `./${catEntry.name}/${compEntry.name}`,
sfcProject,
); );
if (c) components.push(c); if (c) components.push(c);
} }
} }
else { else {
// Backward-compat: a flat component dir directly under src. // Backward-compat: a flat component dir directly under src.
const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`); const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`, sfcProject);
if (c) components.push(c); if (c) components.push(c);
} }
} }
+5
View File
@@ -142,6 +142,11 @@ export interface ComponentPartMeta {
props: PropertyMeta[]; props: PropertyMeta[];
/** Emitted events parsed from `defineEmits` */ /** Emitted events parsed from `defineEmits` */
emits: EmitMeta[]; emits: EmitMeta[];
/**
* The template-ref surface parsed from `defineExpose`, spreads expanded
* through the type checker (`...api` lists the composable's whole return).
*/
exposes?: PropertyMeta[];
} }
export interface EmitMeta { export interface EmitMeta {
+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)]); const rows = part.emits.map(e => [cell(e.name), cell(`\`${e.payload}\``), cell(e.description)]);
out.push('#### Emits', '', table(['Event', 'Payload', 'Description'], rows), ''); out.push('#### Emits', '', table(['Event', 'Payload', 'Description'], rows), '');
} }
if (part.exposes && part.exposes.length > 0) {
const rows = part.exposes.map(x => [cell(x.name), cell(`\`${x.type}\``), cell(x.description)]);
out.push('#### Exposes (template ref)', '', table(['Name', 'Type', 'Description'], rows), '');
}
return out; return out;
} }
+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", "$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@robonen/primitives", "name": "@robonen/primitives",
"license": "Apache-2.0", "license": "Apache-2.0",
"version": "0.0.2", "version": "0.0.6",
"exports": "./src/index.ts" "exports": "./src/index.ts"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/primitives", "name": "@robonen/primitives",
"version": "0.0.2", "version": "0.0.6",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Collection of UI primitives", "description": "Collection of UI primitives",
"keywords": [ "keywords": [
@@ -54,7 +54,7 @@ const linePath = computed(() => {
<svg <svg
data-flow-background="" data-flow-background=""
:data-variant="variant" :data-variant="variant"
:style="{ position: 'absolute', inset: '0', width: '100%', height: '100%', pointerEvents: 'none', color }" :style="{ position: 'absolute', inset: '0', width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0, color }"
> >
<pattern <pattern
:id="patternId" :id="patternId"
+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; if (event.button !== 0 || edge.value?.selectable === false || !ctx.elementsSelectable.value) return;
event.stopPropagation(); event.stopPropagation();
ctx.selectEdge(id, event.shiftKey || event.metaKey || event.ctrlKey); ctx.selectEdge(id, event.shiftKey || event.metaKey || event.ctrlKey);
ctx.emitEdgeClick(id, event);
} }
</script> </script>
<template> <template>
<g <g
v-if="endpoints" v-if="endpoints"
v-memo="[path[0], selected, edge?.animated, edge?.selectable, edge?.data, markerStartRef, markerEndRef]" v-memo="[path[0], selected, edge?.animated, edge?.selectable, edge?.data, edge?.label, markerStartRef, markerEndRef]"
data-flow-edge="" data-flow-edge=""
:data-id="id" :data-id="id"
:data-type="resolvedType" :data-type="resolvedType"
@@ -176,6 +177,21 @@ function onPointerdown(event: PointerEvent): void {
:style="interactionPathStyle" :style="interactionPathStyle"
@pointerdown="onPointerdown" @pointerdown="onPointerdown"
/> />
<!-- The halo (paint-order + stroke) keeps the text legible over the
path and the background without the consumer styling anything. -->
<text
v-if="edge?.label"
data-flow-edge-label=""
:x="path[1]"
:y="path[2]"
text-anchor="middle"
dominant-baseline="middle"
fill="currentColor"
stroke="var(--flow-edge-label-halo, white)"
stroke-width="3"
paint-order="stroke"
:style="{ pointerEvents: 'none', fontSize: '12px' }"
>{{ edge.label }}</text>
</template> </template>
</g> </g>
</template> </template>
+13 -2
View File
@@ -65,8 +65,10 @@ useKeyboard(currentElement, ctx, useViewportApi(ctx));
useEventListener(currentElement, 'click', (event: MouseEvent) => { useEventListener(currentElement, 'click', (event: MouseEvent) => {
const target = event.target as Element | null; const target = event.target as Element | null;
if (target && !target.closest('[data-flow-node],[data-flow-edge]')) if (target && !target.closest('[data-flow-node],[data-flow-edge]')) {
ctx.clearSelection(); ctx.clearSelection();
ctx.emitPaneClick(event as PointerEvent);
}
}); });
</script> </script>
@@ -79,7 +81,16 @@ useEventListener(currentElement, 'click', (event: MouseEvent) => {
:data-interactive="ctx.interactive.value ? '' : undefined" :data-interactive="ctx.interactive.value ? '' : undefined"
:role="ctx.disableKeyboardA11y.value ? undefined : 'application'" :role="ctx.disableKeyboardA11y.value ? undefined : 'application'"
:tabindex="ctx.disableKeyboardA11y.value ? undefined : 0" :tabindex="ctx.disableKeyboardA11y.value ? undefined : 0"
:style="{ position: 'relative', overflow: 'hidden', touchAction: 'none' }" :style="{
position: 'relative',
overflow: 'hidden',
touchAction: 'none',
// Everything inside is absolutely positioned, so content-sizing always
// collapsed to 0×N and the graph rendered into an invisible strip.
// Vue merges a consumer's style attr over this, so it stays overridable.
width: '100%',
height: '100%',
}"
> >
<slot /> <slot />
+3 -1
View File
@@ -28,7 +28,9 @@ const { forwardRef } = useForwardExpose();
const style = computed<CSSProperties>(() => { const style = computed<CSSProperties>(() => {
const [v, h] = position.split('-') as ['top' | 'bottom', 'left' | 'center' | 'right']; const [v, h] = position.split('-') as ['top' | 'bottom', 'left' | 'center' | 'right'];
const s: CSSProperties = { position: 'absolute', pointerEvents: 'all' }; // Above the viewport's explicit layer (zIndex 1): a positioned sibling
// with z-index auto would otherwise paint underneath the graph.
const s: CSSProperties = { position: 'absolute', pointerEvents: 'all', zIndex: 2 };
s[v] = '0'; s[v] = '0';
if (h === 'center') { if (h === 'center') {
s.left = '50%'; s.left = '50%';
+76 -1
View File
@@ -73,26 +73,46 @@ export interface FlowRootProps extends PrimitiveProps {
isValidConnection?: IsValidConnection; isValidConnection?: IsValidConnection;
/** Cull nodes/edges outside the viewport — for large graphs. @default false */ /** Cull nodes/edges outside the viewport — for large graphs. @default false */
onlyRenderVisibleElements?: boolean; onlyRenderVisibleElements?: boolean;
/**
* Frame the whole graph once after the initial nodes are measured. Skipped
* when an explicit `viewport` / `defaultViewport` is provided a restored
* viewport must not be stomped by a fit. With virtualization the fit uses
* whatever is measured plus declared node sizes; fully unmeasured nodes are
* framed by position alone. @default false
*/
fitViewOnMount?: boolean | FitViewParams;
/** Extra px kept rendered around the viewport when virtualizing. @default 200 */ /** Extra px kept rendered around the viewport when virtualizing. @default 200 */
virtualizationBuffer?: number; virtualizationBuffer?: number;
} }
export interface FlowRootEmits { export interface FlowRootEmits {
/** Granular node mutations (position, selection, removal) — apply them to your controlled state. */
nodesChange: [changes: NodeChange[]]; nodesChange: [changes: NodeChange[]];
/** Granular edge mutations (selection, removal). */
edgesChange: [changes: EdgeChange[]]; edgesChange: [changes: EdgeChange[]];
/** A connection gesture completed between two handles. */
connect: [connection: Connection]; connect: [connection: Connection];
/** A connection gesture started from a handle. */
connectStart: [payload: { nodeId: string; handleId: string | null; handleType: HandleType }]; connectStart: [payload: { nodeId: string; handleId: string | null; handleType: HandleType }];
/** The connection gesture ended, successfully or not. */
connectEnd: []; connectEnd: [];
/** A node drag finished; ids of every node that moved. */
nodeDragStop: [ids: string[]]; nodeDragStop: [ids: string[]];
/** The set of selected nodes/edges changed. */
selectionChange: [selection: { nodes: string[]; edges: string[] }]; selectionChange: [selection: { nodes: string[]; edges: string[] }];
/** A click landed on the empty pane — not on a node or an edge. */
paneClick: [event: PointerEvent]; paneClick: [event: PointerEvent];
/** A settled click on a node (a drag that never started moving). */
nodeClick: [id: string, event: PointerEvent]; nodeClick: [id: string, event: PointerEvent];
/** Two settled clicks on the same node within the double-click interval. */
nodeDoubleClick: [id: string, event: PointerEvent];
/** A click on an edge path. */
edgeClick: [id: string, event: PointerEvent]; edgeClick: [id: string, event: PointerEvent];
} }
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import { computed, shallowRef, toRef, triggerRef, useSlots, watch } from 'vue'; import { computed, getCurrentInstance, shallowRef, toRef, triggerRef, useSlots, watch } from 'vue';
import { useId } from '@robonen/vue'; import { useId } from '@robonen/vue';
import FlowPane from './FlowPane.vue'; import FlowPane from './FlowPane.vue';
import FlowViewport from './FlowViewport.vue'; import FlowViewport from './FlowViewport.vue';
@@ -124,6 +144,7 @@ const {
disableKeyboardA11y = false, disableKeyboardA11y = false,
isValidConnection, isValidConnection,
onlyRenderVisibleElements = false, onlyRenderVisibleElements = false,
fitViewOnMount = false,
virtualizationBuffer = 200, virtualizationBuffer = 200,
as = 'div', as = 'div',
} = defineProps<FlowRootProps>(); } = defineProps<FlowRootProps>();
@@ -135,6 +156,7 @@ const flowId = useId(undefined, 'flow').value;
// models (controlled + uncontrolled) // models (controlled + uncontrolled)
const localNodes = shallowRef<FlowNode[]>(defaultNodes ? defaultNodes.slice() : []); const localNodes = shallowRef<FlowNode[]>(defaultNodes ? defaultNodes.slice() : []);
/** Current nodes (controlled `v-model:nodes` or internal state). */
const nodes = defineModel<FlowNode[]>('nodes', { const nodes = defineModel<FlowNode[]>('nodes', {
get: external => external ?? localNodes.value, get: external => external ?? localNodes.value,
set: (value) => { set: (value) => {
@@ -144,6 +166,7 @@ const nodes = defineModel<FlowNode[]>('nodes', {
}); });
const localEdges = shallowRef<FlowEdge[]>(defaultEdges ? defaultEdges.slice() : []); const localEdges = shallowRef<FlowEdge[]>(defaultEdges ? defaultEdges.slice() : []);
/** Current edges (controlled `v-model:edges` or internal state). */
const edges = defineModel<FlowEdge[]>('edges', { const edges = defineModel<FlowEdge[]>('edges', {
get: external => external ?? localEdges.value, get: external => external ?? localEdges.value,
set: (value) => { set: (value) => {
@@ -153,6 +176,7 @@ const edges = defineModel<FlowEdge[]>('edges', {
}); });
const localViewport = shallowRef<Viewport>(defaultViewport ?? { x: 0, y: 0, zoom: 1 }); const localViewport = shallowRef<Viewport>(defaultViewport ?? { x: 0, y: 0, zoom: 1 });
/** Current viewport (controlled `v-model:viewport` or internal state). */
const viewport = defineModel<Viewport>('viewport', { const viewport = defineModel<Viewport>('viewport', {
get: external => external ?? localViewport.value, get: external => external ?? localViewport.value,
set: (value) => { set: (value) => {
@@ -168,6 +192,7 @@ const viewport = defineModel<Viewport>('viewport', {
// would never visually update). // would never visually update).
const nodeLookup = shallowRef(new Map<string, InternalNode>()); const nodeLookup = shallowRef(new Map<string, InternalNode>());
const edgeLookup = shallowRef(new Map<string, FlowEdge>()); const edgeLookup = shallowRef(new Map<string, FlowEdge>());
/** Selected node/edge id sets. */
const selection = shallowRef<FlowSelection>({ nodes: new Set(), edges: new Set() }); const selection = shallowRef<FlowSelection>({ nodes: new Set(), edges: new Set() });
const paneRect = shallowRef({ left: 0, top: 0, width: 0, height: 0 }); const paneRect = shallowRef({ left: 0, top: 0, width: 0, height: 0 });
const isDragging = shallowRef(false); const isDragging = shallowRef(false);
@@ -330,6 +355,7 @@ function setNodeMeasured(id: string, size: Dimensions, handleBounds: InternalNod
// pick up the fresh measurement / handle geometry. // pick up the fresh measurement / handle geometry.
map.set(id, { ...n, measured: sizeChanged ? size : n.measured, handleBounds }); map.set(id, { ...n, measured: sizeChanged ? size : n.measured, handleBounds });
triggerRef(nodeLookup); triggerRef(nodeLookup);
maybeFitOnMount();
} }
function updateNode(id: string, patch: Partial<FlowNode>): void { function updateNode(id: string, patch: Partial<FlowNode>): void {
@@ -346,6 +372,7 @@ function emitSelection(): void {
emit('selectionChange', { nodes: [...selection.value.nodes], edges: [...selection.value.edges] }); emit('selectionChange', { nodes: [...selection.value.nodes], edges: [...selection.value.edges] });
} }
/** Select a node — replacing the selection, or adding to it. */
function selectNode(id: string, additive = false): void { function selectNode(id: string, additive = false): void {
if (!elementsSelectable) return; if (!elementsSelectable) return;
const sel = selection.value; const sel = selection.value;
@@ -357,6 +384,7 @@ function selectNode(id: string, additive = false): void {
emitSelection(); emitSelection();
} }
/** Select an edge — replacing the selection, or adding to it. */
function selectEdge(id: string, additive = false): void { function selectEdge(id: string, additive = false): void {
if (!elementsSelectable) return; if (!elementsSelectable) return;
const sel = selection.value; const sel = selection.value;
@@ -368,17 +396,20 @@ function selectEdge(id: string, additive = false): void {
emitSelection(); emitSelection();
} }
/** Replace the selection with exactly these nodes and edges. */
function setSelection(nodeIds: string[], edgeIds: string[]): void { function setSelection(nodeIds: string[], edgeIds: string[]): void {
selection.value = { nodes: new Set(nodeIds), edges: new Set(edgeIds) }; selection.value = { nodes: new Set(nodeIds), edges: new Set(edgeIds) };
emitSelection(); emitSelection();
} }
/** Deselect everything. */
function clearSelection(): void { function clearSelection(): void {
if (selection.value.nodes.size === 0 && selection.value.edges.size === 0) return; if (selection.value.nodes.size === 0 && selection.value.edges.size === 0) return;
selection.value = { nodes: new Set(), edges: new Set() }; selection.value = { nodes: new Set(), edges: new Set() };
emitSelection(); emitSelection();
} }
/** Remove every selected node (with its edges) and selected edge. */
function removeSelected(): void { function removeSelected(): void {
const sel = selection.value; const sel = selection.value;
if (sel.nodes.size === 0 && sel.edges.size === 0) return; if (sel.nodes.size === 0 && sel.edges.size === 0) return;
@@ -515,12 +546,56 @@ const context: FlowContext = {
endConnection, endConnection,
emitNodesChange: changes => emit('nodesChange', changes), emitNodesChange: changes => emit('nodesChange', changes),
emitEdgesChange: changes => emit('edgesChange', changes), emitEdgesChange: changes => emit('edgesChange', changes),
emitNodeClick: (id, event) => emit('nodeClick', id, event),
emitNodeDoubleClick: (id, event) => emit('nodeDoubleClick', id, event),
emitEdgeClick: (id, event) => emit('edgeClick', id, event),
emitPaneClick: event => emit('paneClick', event),
}; };
provideFlowContext(context); provideFlowContext(context);
// Imperative API, also exposed so consumers can drive the flow via a template ref. // Imperative API, also exposed so consumers can drive the flow via a template ref.
const api = useViewportApi(context); const api = useViewportApi(context);
// fitViewOnMount
// A viewport the consumer controls (v-model:viewport) or seeds
// (defaultViewport) is restored state; a fit must never stomp it. Model
// getters fall back to a local default, so controlledness is read off the
// vnode, not the value.
const vnodeProps = getCurrentInstance()?.vnode.props ?? {};
let fitOnMountPending = fitViewOnMount !== false
&& defaultViewport === undefined
&& !('viewport' in vnodeProps)
&& !('onUpdate:viewport' in vnodeProps);
/**
* Armed until it fires once: waits for every RENDERED node to report a
* measurement fitting to unmeasured nodes fits to nothing. Under
* virtualization only the rendered subset ever measures; the rest contribute
* their declared or positional bounds through `fitView` itself.
*/
function maybeFitOnMount(): void {
if (!fitOnMountPending) return;
// Nodes can finish measuring before the pane has a size (or the reverse);
// the shot must not burn against a 0×0 container, so both gates hold it and
// the pane-rect watcher below re-arms the attempt.
const rect = paneRect.value;
if (rect.width === 0 || rect.height === 0) return;
const map = nodeLookup.value;
if (map.size === 0) return;
for (const id of visibleNodeIds.value) {
const n = map.get(id);
if (n && n.measured.width === 0 && n.measured.height === 0) return;
}
fitOnMountPending = false;
api.fitView(typeof fitViewOnMount === 'object' ? fitViewOnMount : undefined);
}
watch(paneRect, maybeFitOnMount);
const nodeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'node' || n.startsWith('node-'))); const nodeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'node' || n.startsWith('node-')));
const edgeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'edge' || n.startsWith('edge-'))); const edgeSlotNames = computed(() => Object.keys(slots).filter(n => n === 'edge' || n.startsWith('edge-')));
@@ -42,6 +42,9 @@ const transform = computed(() => {
left: '0', left: '0',
width: '100%', width: '100%',
height: '100%', height: '100%',
// The slot (background, panels) renders after this element; explicit
// layers keep the graph above the background and below the chrome.
zIndex: 1,
transformOrigin: '0 0', transformOrigin: '0 0',
transform, transform,
willChange: ctx.isInteracting.value ? 'transform' : undefined, willChange: ctx.isInteracting.value ? 'transform' : undefined,
@@ -0,0 +1,191 @@
import type { VueWrapper } from '@vue/test-utils';
import type { FlowEdge, FlowNode } from '../index';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { h, nextTick } from 'vue';
import { FlowBackground, FlowPanel, FlowRoot } from '../index';
/**
* Regressions found by building a real story-map consumer: the pane rendered
* into zero area, the background painted over the graph, edge labels never
* rendered, the declared click emits never fired, and dblclick on a node
* zoomed the canvas. Each test pins the fixed contract.
*/
const wrappers: Array<VueWrapper<any>> = [];
afterEach(() => {
while (wrappers.length) wrappers.pop()!.unmount();
document.body.innerHTML = '';
});
function track<T extends VueWrapper<any>>(w: T): T {
wrappers.push(w);
return w;
}
const nodes: FlowNode[] = [
{ id: 'a', position: { x: 0, y: 0 } },
{ id: 'b', position: { x: 300, y: 200 } },
];
function pointer(el: Element, type: string, x = 10, y = 10) {
el.dispatchEvent(new PointerEvent(type, { button: 0, pointerId: 1, clientX: x, clientY: y, bubbles: true, cancelable: true }));
}
/** The pane sizes to its parent; give the test-utils wrapper a real box. */
function sizeWrapper(w: VueWrapper<any>, width = 600, height = 400) {
const el = w.element as HTMLElement;
el.style.width = `${width}px`;
el.style.height = `${height}px`;
}
const edges: FlowEdge[] = [
{ id: 'a-b', source: 'a', target: 'b', label: 'take me' },
];
function flow(props: Record<string, unknown> = {}, slots: Record<string, unknown> = {}) {
return track(mount(FlowRoot, {
attachTo: document.body,
props: { defaultNodes: nodes, defaultEdges: edges, ...props },
slots: { 'node-default': () => h('div', { style: 'width:120px;height:40px' }, 'n'), ...slots },
}));
}
describe('pane sizing', () => {
it('fills its parent instead of collapsing to zero height', () => {
const w = flow();
sizeWrapper(w);
const pane = w.find('[data-flow-pane]').element as HTMLElement;
// All pane content is absolutely positioned; without an own height the
// whole graph rendered inside an invisible 0px strip.
expect(pane.clientHeight).toBe(400);
});
});
describe('stacking', () => {
it('layers background under the graph and panels above it', () => {
const w = flow({}, {
default: () => [h(FlowBackground), h(FlowPanel, { position: 'top-right' }, () => 'p')],
});
const viewport = (w.find('[data-flow-viewport]').element as HTMLElement).style.zIndex;
const background = (w.find('[data-flow-background]').element as HTMLElement).style.zIndex;
const panel = (w.find('[data-flow-panel]').element as HTMLElement).style.zIndex;
// The slot chrome renders AFTER the viewport in DOM order; without these
// layers the background dots painted over every node.
expect(Number(background)).toBeLessThan(Number(viewport));
expect(Number(panel)).toBeGreaterThan(Number(viewport));
});
});
describe('edge labels', () => {
it('renders the label the type always promised', async () => {
const w = flow();
await nextTick();
const label = w.find('[data-flow-edge-label]');
expect(label.exists()).toBe(true);
expect(label.text()).toBe('take me');
});
it('renders no label element when there is none', async () => {
const w = flow({ defaultEdges: [{ id: 'a-b', source: 'a', target: 'b' }] });
await nextTick();
expect(w.find('[data-flow-edge-label]').exists()).toBe(false);
});
});
describe('the click family', () => {
async function settle(w: VueWrapper<any>, selector: string, times = 1, gap = 50) {
const el = w.find(selector).element;
for (let index = 0; index < times; index++) {
pointer(el, 'pointerdown');
pointer(el, 'pointerup');
await nextTick();
if (gap)
await new Promise(resolve => setTimeout(resolve, gap));
}
}
it('emits nodeClick for a settled click', async () => {
const w = flow();
await nextTick();
await settle(w, '[data-flow-node][data-id="a"]');
expect(w.emitted('nodeClick')?.[0]?.[0]).toBe('a');
});
it('pairs two settled clicks into nodeDoubleClick', async () => {
const w = flow();
await nextTick();
await settle(w, '[data-flow-node][data-id="a"]', 2, 40);
expect(w.emitted('nodeDoubleClick')?.[0]?.[0]).toBe('a');
});
it('emits paneClick only for background clicks', async () => {
const w = flow();
await nextTick();
await w.find('[data-flow-pane]').trigger('click');
expect(w.emitted('paneClick')).toHaveLength(1);
await w.find('[data-flow-node][data-id="a"] div').trigger('click');
expect(w.emitted('paneClick')).toHaveLength(1);
});
it('emits edgeClick when the edge is picked', async () => {
const w = flow();
await nextTick();
pointer(w.findAll('[data-flow-edge] path')[1]!.element, 'pointerdown');
await nextTick();
expect(w.emitted('edgeClick')?.[0]?.[0]).toBe('a-b');
});
it('does not zoom on a node double click', async () => {
const w = flow();
await nextTick();
const before = (w.find('[data-flow-viewport]').element as HTMLElement).style.transform;
await w.find('[data-flow-node][data-id="a"]').trigger('dblclick');
await nextTick();
// The gesture belongs to the node (nodeDoubleClick), not the camera.
expect((w.find('[data-flow-viewport]').element as HTMLElement).style.transform).toBe(before);
});
});
describe('fitViewOnMount', () => {
it('frames the graph once nodes are measured', async () => {
const w = flow({ fitViewOnMount: true });
sizeWrapper(w);
await vi.waitFor(() => {
const t = (w.find('[data-flow-viewport]').element as HTMLElement).style.transform;
expect(t).not.toBe('translate(0px, 0px) scale(1)');
});
});
it('never stomps a consumer-controlled viewport', async () => {
const w = flow({
fitViewOnMount: true,
viewport: { x: 17, y: 23, zoom: 1.5 },
'onUpdate:viewport': () => {},
});
await new Promise(resolve => setTimeout(resolve, 120));
// A bound viewport is restored state; the fit must skip it entirely.
expect((w.find('[data-flow-viewport]').element as HTMLElement).style.transform)
.toBe('translate(17px, 23px) scale(1.5)');
});
});
@@ -35,6 +35,9 @@ export interface NodeDragOptions {
/** Elements inside a node that must not initiate a drag. */ /** Elements inside a node that must not initiate a drag. */
const NO_DRAG_SELECTOR = 'input, textarea, select, button, [contenteditable="true"], [data-handleid], .nodrag'; const NO_DRAG_SELECTOR = 'input, textarea, select, button, [contenteditable="true"], [data-handleid], .nodrag';
/** Two settled clicks within this window read as a double click. */
const DOUBLE_CLICK_MS = 350;
/** /**
* Pointer-capture node drag. Moves the node (and every co-selected node) by the * Pointer-capture node drag. Moves the node (and every co-selected node) by the
* pointer delta converted to flow space (`delta / zoom`), optionally snapped to * pointer delta converted to flow space (`delta / zoom`), optionally snapped to
@@ -57,6 +60,7 @@ export function useNodeDrag(
let startX = 0; let startX = 0;
let startY = 0; let startY = 0;
let started = false; let started = false;
let lastClickAt = 0;
let lastX = 0; let lastX = 0;
let lastY = 0; let lastY = 0;
let rafId: number | null = null; let rafId: number | null = null;
@@ -150,6 +154,26 @@ export function useNodeDrag(
if (started) { if (started) {
flush(); flush();
ctx.commitNodeDrag(); ctx.commitNodeDrag();
lastClickAt = 0;
}
else if (snapshot.size > 0) {
// The pointer never crossed the drag threshold: this is a click. The
// pane cannot see it (propagation stopped on pointerdown), so the node
// is the only place that can report it — and pair two settled clicks
// into a double click.
const id = toValue(nodeId);
ctx.emitNodeClick(id, event);
const now = event.timeStamp;
if (now - lastClickAt <= DOUBLE_CLICK_MS) {
ctx.emitNodeDoubleClick(id, event);
lastClickAt = 0;
}
else {
lastClickAt = now;
}
} }
pointerId = -1; pointerId = -1;
started = false; started = false;
@@ -158,7 +158,9 @@ export function usePanZoom(
// ── double-click zoom ────────────────────────────────────────────────────── // ── double-click zoom ──────────────────────────────────────────────────────
useEventListener(target, 'dblclick', (event: MouseEvent) => { useEventListener(target, 'dblclick', (event: MouseEvent) => {
if (!zoomOnDoubleClick || !ctx.interactive.value) return; if (!zoomOnDoubleClick || !ctx.interactive.value) return;
if (event.target instanceof Element && event.target.closest('.nopan')) return; // A double click on a node belongs to the node (nodeDoubleClick), not
// to the zoom gesture.
if (event.target instanceof Element && event.target.closest('.nopan, [data-flow-node]')) return;
const vp = current(); const vp = current();
const newZoom = clampZoom(vp.zoom * doubleClickZoomFactor, ctx.minZoom.value, ctx.maxZoom.value); const newZoom = clampZoom(vp.zoom * doubleClickZoomFactor, ctx.minZoom.value, ctx.maxZoom.value);
if (newZoom === vp.zoom) return; if (newZoom === vp.zoom) return;
@@ -121,6 +121,10 @@ export interface FlowContext {
// ── change emission ────────────────────────────────────────────────────── // ── change emission ──────────────────────────────────────────────────────
emitNodesChange: (changes: NodeChange[]) => void; emitNodesChange: (changes: NodeChange[]) => void;
emitEdgesChange: (changes: EdgeChange[]) => void; emitEdgesChange: (changes: EdgeChange[]) => void;
emitNodeClick: (id: string, event: PointerEvent) => void;
emitNodeDoubleClick: (id: string, event: PointerEvent) => void;
emitEdgeClick: (id: string, event: PointerEvent) => void;
emitPaneClick: (event: PointerEvent) => void;
} }
const flow = useContextFactory<FlowContext>('FlowContext'); const flow = useContextFactory<FlowContext>('FlowContext');
@@ -14,6 +14,9 @@ import type { RovingDirection } from '../../internal/utils/roving-focus';
export type AccordionType = 'single' | 'multiple'; export type AccordionType = 'single' | 'multiple';
export interface AccordionRootProps extends PrimitiveProps { export interface AccordionRootProps extends PrimitiveProps {
/** Controlled open value(s). Bind with `v-model`. */
modelValue?: string | string[];
/** Initial value(s) for uncontrolled mode. */ /** Initial value(s) for uncontrolled mode. */
defaultValue?: string | string[]; defaultValue?: string | string[];
@@ -51,6 +54,10 @@ export interface AccordionRootProps extends PrimitiveProps {
/** /**
* Emit contract for `AccordionRoot`. The payload narrows with `Type`: a single * Emit contract for `AccordionRoot`. The payload narrows with `Type`: a single
* accordion emits `string | undefined`, a multiple accordion emits `string[]`. * accordion emits `string | undefined`, a multiple accordion emits `string[]`.
*
* The event itself is declared by `defineModel`: passing a model key through
* `defineEmits` as well erases its payload type from the generated
* declarations, leaving consumers with `unknown`.
*/ */
export interface AccordionRootEmits<Type extends AccordionType = AccordionType> { export interface AccordionRootEmits<Type extends AccordionType = AccordionType> {
'update:modelValue': [value: (Type extends 'single' ? string : string[]) | undefined]; 'update:modelValue': [value: (Type extends 'single' ? string : string[]) | undefined];
@@ -79,8 +86,6 @@ const {
as = 'div', as = 'div',
} = defineProps<AccordionRootProps>(); } = defineProps<AccordionRootProps>();
defineEmits<AccordionRootEmits>();
defineSlots<{ defineSlots<{
default?: (props: { default?: (props: {
/** Current open value(s): a `string | undefined` in single mode, `string[]` in multiple. */ /** Current open value(s): a `string | undefined` in single mode, `string[]` in multiple. */
+25 -15
View File
@@ -13,11 +13,11 @@ import type { TabsValue } from './context';
* via `defaultValue`), orientation, keyboard roving focus across triggers, and * via `defaultValue`), orientation, keyboard roving focus across triggers, and
* provides context to every `TabsList`, `TabsTrigger`, and `TabsContent`. * provides context to every `TabsList`, `TabsTrigger`, and `TabsContent`.
*/ */
export interface TabsRootProps extends PrimitiveProps { export interface TabsRootProps<Value extends TabsValue = TabsValue> extends PrimitiveProps {
/** Controlled selected value. Bind with `v-model`. */ /** Controlled selected value. Bind with `v-model`. */
modelValue?: TabsValue; modelValue?: Value;
/** Uncontrolled initial value. */ /** Uncontrolled initial value. */
defaultValue?: TabsValue; defaultValue?: Value;
/** Orientation of the tab list. @default 'horizontal' */ /** Orientation of the tab list. @default 'horizontal' */
orientation?: 'horizontal' | 'vertical'; orientation?: 'horizontal' | 'vertical';
/** /**
@@ -40,13 +40,14 @@ export interface TabsRootProps extends PrimitiveProps {
unmountOnHide?: boolean; unmountOnHide?: boolean;
} }
export interface TabsRootEmits { export interface TabsRootEmits<Value extends TabsValue = TabsValue> {
/** Fired when the selected value changes. */ /** Fired when the selected value changes. */
'update:modelValue': [value: TabsValue | undefined]; 'update:modelValue': [value: Value];
} }
</script> </script>
<script setup lang="ts"> <script setup lang="ts" generic="Value extends TabsValue = TabsValue">
import type { Ref } from 'vue';
import { computed, ref, shallowRef, toRef } from 'vue'; import { computed, ref, shallowRef, toRef } from 'vue';
import { resolveNextIndex, rovingKeyToAction } from '../../internal/utils/roving-focus'; import { resolveNextIndex, rovingKeyToAction } from '../../internal/utils/roving-focus';
import { useCollectionProvider } from '../../utilities/collection'; import { useCollectionProvider } from '../../utilities/collection';
@@ -63,15 +64,16 @@ const {
activationMode = 'automatic', activationMode = 'automatic',
unmountOnHide = true, unmountOnHide = true,
defaultValue, defaultValue,
modelValue,
as = 'div', as = 'div',
} = defineProps<TabsRootProps>(); } = defineProps<TabsRootProps<Value>>();
defineEmits<TabsRootEmits>(); const emit = defineEmits<TabsRootEmits<Value>>();
defineSlots<{ defineSlots<{
default?: (props: { default?: (props: {
/** Current selected value. */ /** Current selected value. */
value: TabsValue | undefined; value: Value | undefined;
}) => unknown; }) => unknown;
}>(); }>();
@@ -79,16 +81,24 @@ const { forwardRef } = useForwardExpose();
const direction = useDirection(() => dir); const direction = useDirection(() => dir);
const localValue = ref<TabsValue | undefined>(defaultValue); // `defineModel` would type `update:modelValue` as `TabsValue | undefined`,
// forcing every consumer's `v-model` target to accept `undefined` even though
// a tab is never deselected. The prop and the emit are declared separately so
// the emitted payload stays exactly `TabsValue` (see AGENTS §3.2.3).
const localValue = ref<Value | undefined>(defaultValue) as Ref<Value | undefined>;
const value = defineModel<TabsValue | undefined>({ const value = computed<Value | undefined>({
get: v => v ?? localValue.value, get: () => modelValue ?? localValue.value,
set: (v) => { set: (v) => {
localValue.value = v; localValue.value = v;
return v; if (v !== undefined) emit('update:modelValue', v);
}, },
}); });
// The tab parts read and write plain `TabsValue`s through the context; the
// narrowed `Value` only exists to keep the consumer's `v-model` typed.
const contextValue = value as unknown as Ref<TabsValue | undefined>;
const baseId = useId(undefined, 'tabs'); const baseId = useId(undefined, 'tabs');
const tabsListElement = shallowRef<HTMLElement>(); const tabsListElement = shallowRef<HTMLElement>();
@@ -116,7 +126,7 @@ function unregisterContent(v: TabsValue): void {
function select(v: TabsValue): void { function select(v: TabsValue): void {
if (disabled) return; if (disabled) return;
value.value = v; contextValue.value = v;
} }
// DOM-order tabs via Collection primitive survives `v-for` reorders and // DOM-order tabs via Collection primitive survives `v-for` reorders and
@@ -161,7 +171,7 @@ function onTriggerKeyDown(event: KeyboardEvent, el: HTMLElement): void {
} }
provideTabsContext({ provideTabsContext({
value, value: contextValue,
// Identity passthroughs via `toRef` reactive without `computed`'s effect/cache. // Identity passthroughs via `toRef` reactive without `computed`'s effect/cache.
orientation: toRef(() => orientation), orientation: toRef(() => orientation),
direction, direction,
@@ -66,6 +66,11 @@ export interface CalendarRootProps extends PrimitiveProps {
dateAdapter?: DateAdapter<Date>; dateAdapter?: DateAdapter<Date>;
} }
/**
* Emit contract for `CalendarRoot`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface CalendarRootEmits { export interface CalendarRootEmits {
'update:modelValue': [date: Date | Date[] | undefined]; 'update:modelValue': [date: Date | Date[] | undefined];
'update:placeholder': [date: Date]; 'update:placeholder': [date: Date];
@@ -106,8 +111,6 @@ const {
dateAdapter, dateAdapter,
} = defineProps<CalendarRootProps>(); } = defineProps<CalendarRootProps>();
defineEmits<CalendarRootEmits>();
defineSlots<{ defineSlots<{
default?: (props: { default?: (props: {
date: Date; date: Date;
@@ -40,6 +40,11 @@ export interface DatePickerRootProps extends PrimitiveProps,
hourCycle?: HourCycle; hourCycle?: HourCycle;
} }
/**
* Emit contract for `DatePickerRoot`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface DatePickerRootEmits { export interface DatePickerRootEmits {
'update:modelValue': [date: Date | undefined]; 'update:modelValue': [date: Date | undefined];
'update:placeholder': [date: Date]; 'update:placeholder': [date: Date];
@@ -95,8 +100,6 @@ const {
dateAdapter, dateAdapter,
} = defineProps<DatePickerRootProps>(); } = defineProps<DatePickerRootProps>();
defineEmits<DatePickerRootEmits>();
const { forwardRef, currentElement: parentElement } = useForwardExpose(); const { forwardRef, currentElement: parentElement } = useForwardExpose();
// Resolve the effective date backend: per-instance prop wins over the global // Resolve the effective date backend: per-instance prop wins over the global
@@ -37,6 +37,11 @@ export interface ProgressRootProps extends PrimitiveProps {
accessibleLabel?: string | ((value: number | null, max: number) => string | undefined); accessibleLabel?: string | ((value: number | null, max: number) => string | undefined);
} }
/**
* Emit contract for `ProgressRoot`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface ProgressRootEmits { export interface ProgressRootEmits {
/** Emitted when the value changes (after validation/clamping). */ /** Emitted when the value changes (after validation/clamping). */
'update:modelValue': [value: number | null]; 'update:modelValue': [value: number | null];
@@ -59,8 +64,6 @@ const {
as = 'div', as = 'div',
} = defineProps<ProgressRootProps>(); } = defineProps<ProgressRootProps>();
defineEmits<ProgressRootEmits>();
const { forwardRef } = useForwardExpose(); const { forwardRef } = useForwardExpose();
const localValue = ref<number | null>(null); const localValue = ref<number | null>(null);
+5 -2
View File
@@ -43,6 +43,11 @@ export interface SwitchProps<T = boolean> extends PrimitiveProps {
value?: string; value?: string;
} }
/**
* Emit contract for `Switch`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface SwitchEmits<T = boolean> { export interface SwitchEmits<T = boolean> {
/** Emitted whenever the value changes (also drives `v-model`). */ /** Emitted whenever the value changes (also drives `v-model`). */
'update:modelValue': [value: T]; 'update:modelValue': [value: T];
@@ -71,8 +76,6 @@ const {
as = 'button', as = 'button',
} = defineProps<SwitchProps<T>>(); } = defineProps<SwitchProps<T>>();
defineEmits<SwitchEmits<T>>();
const { forwardRef, currentElement } = useForwardExpose(); const { forwardRef, currentElement } = useForwardExpose();
const local = ref<T>((defaultValue ?? falsy) as T) as Ref<T>; const local = ref<T>((defaultValue ?? falsy) as T) as Ref<T>;
+5 -3
View File
@@ -4,7 +4,11 @@ import type { PrimitiveProps } from '../../internal/primitive';
/** Canonical `data-state` value reflected on the host element. */ /** Canonical `data-state` value reflected on the host element. */
export type ToggleState = 'on' | 'off'; export type ToggleState = 'on' | 'off';
/** Events emitted by `Toggle`. */ /**
* Emit contract for `Toggle`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface ToggleEmits { export interface ToggleEmits {
/** Fired when the pressed state changes. Backs `v-model:pressed`. */ /** Fired when the pressed state changes. Backs `v-model:pressed`. */
'update:pressed': [pressed: boolean]; 'update:pressed': [pressed: boolean];
@@ -58,8 +62,6 @@ const {
value = 'on', value = 'on',
} = defineProps<ToggleProps>(); } = defineProps<ToggleProps>();
defineEmits<ToggleEmits>();
const { forwardRef, currentElement } = useForwardExpose(); const { forwardRef, currentElement } = useForwardExpose();
// A standalone Toggle nested inside a ToggleGroup must not also submit its own // A standalone Toggle nested inside a ToggleGroup must not also submit its own
@@ -4,7 +4,35 @@ import { renderSlotChild } from './Slot';
type FunctionalComponentContext = Omit<SetupContext, 'expose'>; type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
export interface PrimitiveProps { type Booleanish = boolean | 'true' | 'false';
/**
* Global DOM attributes any part accepts and forwards, through `$attrs`, to the
* element it renders. They are deliberately kept out of the runtime props (the
* `@vue-ignore` marker on the heritage clause below stops the SFC compiler from
* lifting them out of `$attrs`), so this only teaches `strictTemplates` that
* they are valid the runtime behaviour is unchanged.
*/
export interface PrimitiveAttributes {
id?: string;
role?: string;
title?: string;
tabindex?: number | string;
lang?: string;
dir?: string;
hidden?: Booleanish | 'until-found' | '';
inert?: Booleanish;
autofocus?: Booleanish;
draggable?: Booleanish;
spellcheck?: Booleanish;
translate?: 'yes' | 'no';
nonce?: string;
part?: string;
slot?: string;
[key: `data-${string}` | `aria-${string}`]: unknown;
}
export interface PrimitiveProps extends /* @vue-ignore */ PrimitiveAttributes {
as?: keyof IntrinsicElementAttributes | Component; as?: keyof IntrinsicElementAttributes | Component;
} }
@@ -1,2 +1,2 @@
export { Primitive, type PrimitiveProps } from './Primitive'; export { Primitive, type PrimitiveAttributes, type PrimitiveProps } from './Primitive';
export { Slot } from './Slot'; export { Slot } from './Slot';
@@ -38,6 +38,11 @@ export interface NavigationMenuRootProps extends PrimitiveProps {
unmountOnHide?: boolean; unmountOnHide?: boolean;
} }
/**
* Emit contract for `NavigationMenuRoot`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface NavigationMenuRootEmits { export interface NavigationMenuRootEmits {
'update:modelValue': [value: string]; 'update:modelValue': [value: string];
} }
@@ -70,8 +75,6 @@ const {
as = 'nav', as = 'nav',
} = defineProps<NavigationMenuRootProps>(); } = defineProps<NavigationMenuRootProps>();
defineEmits<NavigationMenuRootEmits>();
defineSlots<{ defineSlots<{
default?: (props: { modelValue: string }) => unknown; default?: (props: { modelValue: string }) => unknown;
}>(); }>();
@@ -15,6 +15,11 @@ export interface NavigationMenuSubProps extends PrimitiveProps {
orientation?: Orientation; orientation?: Orientation;
} }
/**
* Emit contract for `NavigationMenuSub`. The model events are declared by `defineModel`:
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
export interface NavigationMenuSubEmits { export interface NavigationMenuSubEmits {
'update:modelValue': [value: string]; 'update:modelValue': [value: string];
} }
@@ -35,8 +40,6 @@ defineOptions({ inheritAttrs: false });
const { defaultValue, orientation = 'horizontal', as = 'div' } = defineProps<NavigationMenuSubProps>(); const { defaultValue, orientation = 'horizontal', as = 'div' } = defineProps<NavigationMenuSubProps>();
defineEmits<NavigationMenuSubEmits>();
defineSlots<{ defineSlots<{
default?: (props: { modelValue: string }) => unknown; default?: (props: { modelValue: string }) => unknown;
}>(); }>();
@@ -44,6 +44,14 @@ export interface ToolbarRootEmits {
/** Backs `v-model:currentTabStopId`. */ /** Backs `v-model:currentTabStopId`. */
'update:currentTabStopId': [value: string | null | undefined]; 'update:currentTabStopId': [value: string | null | undefined];
} }
/**
* The subset `defineEmits` declares. `update:currentTabStopId` comes from
* `defineModel`; passing a model key through `defineEmits` as well erases its
* payload type from the generated declarations, leaving consumers with
* `unknown`.
*/
type ToolbarRootOwnEmits = Omit<ToolbarRootEmits, 'update:currentTabStopId'>;
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
@@ -64,7 +72,7 @@ const {
as = 'div', as = 'div',
} = defineProps<ToolbarRootProps>(); } = defineProps<ToolbarRootProps>();
const emit = defineEmits<ToolbarRootEmits>(); const emit = defineEmits<ToolbarRootOwnEmits>();
const { forwardRef } = useForwardExpose(); const { forwardRef } = useForwardExpose();
@@ -28,6 +28,11 @@ import { useSelectRootContext } from './context';
import SelectContentImpl from './SelectContentImpl.vue'; import SelectContentImpl from './SelectContentImpl.vue';
import SelectProvider from './SelectProvider.vue'; import SelectProvider from './SelectProvider.vue';
// Neither branch below is a single element root (`Presence` wraps the panel,
// the closed branch is a `Teleport`), so Vue cannot inherit `class`/`style` or
// any other attribute automatically they are forwarded onto the panel itself.
defineOptions({ inheritAttrs: false });
const props = defineProps<SelectContentProps>(); const props = defineProps<SelectContentProps>();
const emit = defineEmits<SelectContentEmits>(); const emit = defineEmits<SelectContentEmits>();
const rootCtx = useSelectRootContext(); const rootCtx = useSelectRootContext();
@@ -57,7 +62,7 @@ onMounted(() => {
:present="present" :present="present"
> >
<SelectContentImpl <SelectContentImpl
v-bind="props" v-bind="{ ...props, ...$attrs }"
@close-auto-focus="emit('closeAutoFocus', $event)" @close-auto-focus="emit('closeAutoFocus', $event)"
@escape-key-down="emit('escapeKeyDown', $event)" @escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)" @pointer-down-outside="emit('pointerDownOutside', $event)"
@@ -63,8 +63,11 @@ const selectedItemTextRef = rootCtx.selectedItemTextRef;
const firstValidItemFoundRef = ref(false); const firstValidItemFoundRef = ref(false);
// Recompute the selected/first-valid item afresh for this open cycle. // Recompute the selected/first-valid item afresh for this open cycle. The text
// node is reset alongside it: the item-aligned positioner reads the two as a
// pair, so a stale text node would pair with a fresh item and skew placement.
selectedItemRef.value = undefined; selectedItemRef.value = undefined;
selectedItemTextRef.value = undefined;
// Resolve the actual listbox content element. The item-aligned strategy renders // Resolve the actual listbox content element. The item-aligned strategy renders
// a positioning wrapper whose first child is the listbox; the popper strategy // a positioning wrapper whose first child is the listbox; the popper strategy
@@ -47,6 +47,46 @@ const shouldExpandOnScrollRef = ref(false);
const shouldRepositionRef = ref(true); const shouldRepositionRef = ref(true);
const contentZIndex = ref(''); const contentZIndex = ref('');
// When nothing is selected the content adopts the first valid item as the
// alignment anchor, but only that item is registered its text node registers
// solely for the *selected* value. Recover it from the item's own label
// association instead of demanding a second registration, which would mean
// writing to the anchor refs from inside the item's own tracking effect.
function itemTextOf(item: HTMLElement | undefined): HTMLElement | undefined {
const id = item?.getAttribute('aria-labelledby');
return id ? item?.ownerDocument.getElementById(id) ?? undefined : undefined;
}
/**
* Inline styles the wrapper is positioned with. Written as one object and
* committed in a single pass: every geometry read below happens before the
* first write, so the browser performs one layout for the whole placement
* instead of one per interleaved read.
*
* Both edges of each axis are always present. A resize can flip the vertical
* branch, and leaving the previous edge behind would over-constrain the box.
*/
interface WrapperPlacement {
minWidth: string;
left: string;
right: string;
top: string;
bottom: string;
height: string;
minHeight: string;
maxHeight: string;
margin: string;
}
const EMPTY_PLACEMENT: WrapperPlacement = {
minWidth: '', left: '', right: '', top: '', bottom: '',
height: '', minHeight: '', maxHeight: '', margin: '',
};
function commit(wrapper: HTMLElement, placement: Partial<WrapperPlacement>) {
Object.assign(wrapper.style, EMPTY_PLACEMENT, placement);
}
function position() { function position() {
const trigger = rootCtx.triggerElement.value; const trigger = rootCtx.triggerElement.value;
const valueNode = rootCtx.valueElement.value; const valueNode = rootCtx.valueElement.value;
@@ -54,20 +94,61 @@ function position() {
const content = contentElement.value; const content = contentElement.value;
const viewport = contentCtx.viewportRef.value; const viewport = contentCtx.viewportRef.value;
const selectedItem = contentCtx.selectedItemRef.value; const selectedItem = contentCtx.selectedItemRef.value;
const selectedItemText = contentCtx.selectedItemTextRef.value; const selectedItemText = contentCtx.selectedItemTextRef.value ?? itemTextOf(selectedItem);
if (!trigger || !valueNode || !wrapper || !content || !viewport || !selectedItem || !selectedItemText) { if (!trigger || !wrapper || !content || !viewport) {
emit('placed'); emit('placed');
return; return;
} }
const triggerRect = trigger.getBoundingClientRect(); // Item-aligned placement centres the panel on the selected item, so without
// one there is nothing to align to an empty option list, or items that have
// not registered yet. Drop the panel under the trigger instead of returning:
// the wrapper is `position: fixed`, so leaving it unplaced pins it to the
// viewport origin, where it reads as "the dropdown does not open".
if (!valueNode || !selectedItem || !selectedItemText) {
const rect = trigger.getBoundingClientRect();
const rightEdge = window.innerWidth - CONTENT_MARGIN;
commit(wrapper, {
minWidth: `${rect.width}px`,
left: `${clamp(rect.left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - rect.width))}px`,
top: `${rect.bottom}px`,
maxHeight: `${Math.max(0, window.innerHeight - rect.bottom - CONTENT_MARGIN)}px`,
});
emit('placed');
return;
}
// --- Horizontal positioning --- // --- Measure: every layout read lives here, before the first write ---
const triggerRect = trigger.getBoundingClientRect();
const contentRect = content.getBoundingClientRect(); const contentRect = content.getBoundingClientRect();
const valueNodeRect = valueNode.getBoundingClientRect(); const valueNodeRect = valueNode.getBoundingClientRect();
const itemTextRect = selectedItemText.getBoundingClientRect(); const itemTextRect = selectedItemText.getBoundingClientRect();
const items = Array.from(
viewport.querySelectorAll<HTMLElement>('[data-primitives-select-item]'),
);
const itemsHeight = viewport.scrollHeight;
const viewportOffsetTop = viewport.offsetTop;
const viewportOffsetHeight = viewport.offsetHeight;
const contentClientHeight = content.clientHeight;
const selectedItemHeight = selectedItem.offsetHeight;
const selectedItemOffsetTop = selectedItem.offsetTop;
const contentStyles = globalThis.getComputedStyle(content);
const contentBorderTopWidth = Number.parseInt(contentStyles.borderTopWidth, 10) || 0;
const contentPaddingTop = Number.parseInt(contentStyles.paddingTop, 10) || 0;
const contentBorderBottomWidth = Number.parseInt(contentStyles.borderBottomWidth, 10) || 0;
const contentPaddingBottom = Number.parseInt(contentStyles.paddingBottom, 10) || 0;
const viewportStyles = globalThis.getComputedStyle(viewport);
const viewportPaddingTop = Number.parseInt(viewportStyles.paddingTop, 10) || 0;
const viewportPaddingBottom = Number.parseInt(viewportStyles.paddingBottom, 10) || 0;
// --- Compute ---
const placement: Partial<WrapperPlacement> = {};
const availableHeight = window.innerHeight - CONTENT_MARGIN * 2;
if (rootCtx.dir.value !== 'rtl') { if (rootCtx.dir.value !== 'rtl') {
const itemTextOffset = itemTextRect.left - contentRect.left; const itemTextOffset = itemTextRect.left - contentRect.left;
const left = valueNodeRect.left - itemTextOffset; const left = valueNodeRect.left - itemTextOffset;
@@ -75,10 +156,9 @@ function position() {
const minContentWidth = triggerRect.width + leftDelta; const minContentWidth = triggerRect.width + leftDelta;
const contentWidth = Math.max(minContentWidth, contentRect.width); const contentWidth = Math.max(minContentWidth, contentRect.width);
const rightEdge = window.innerWidth - CONTENT_MARGIN; const rightEdge = window.innerWidth - CONTENT_MARGIN;
const clampedLeft = clamp(left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - contentWidth));
wrapper.style.minWidth = `${minContentWidth}px`; placement.minWidth = `${minContentWidth}px`;
wrapper.style.left = `${clampedLeft}px`; placement.left = `${clamp(left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - contentWidth))}px`;
} }
else { else {
const itemTextOffset = contentRect.right - itemTextRect.right; const itemTextOffset = contentRect.right - itemTextRect.right;
@@ -87,67 +167,52 @@ function position() {
const minContentWidth = triggerRect.width + rightDelta; const minContentWidth = triggerRect.width + rightDelta;
const contentWidth = Math.max(minContentWidth, contentRect.width); const contentWidth = Math.max(minContentWidth, contentRect.width);
const leftEdge = window.innerWidth - CONTENT_MARGIN; const leftEdge = window.innerWidth - CONTENT_MARGIN;
const clampedRight = clamp(right, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, leftEdge - contentWidth));
wrapper.style.minWidth = `${minContentWidth}px`; placement.minWidth = `${minContentWidth}px`;
wrapper.style.right = `${clampedRight}px`; placement.right = `${clamp(right, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, leftEdge - contentWidth))}px`;
} }
// --- Vertical positioning ---
const items = Array.from(
viewport.querySelectorAll<HTMLElement>('[data-primitives-select-item]'),
);
const availableHeight = window.innerHeight - CONTENT_MARGIN * 2;
const itemsHeight = viewport.scrollHeight;
const contentStyles = globalThis.getComputedStyle(content);
const contentBorderTopWidth = Number.parseInt(contentStyles.borderTopWidth, 10) || 0;
const contentPaddingTop = Number.parseInt(contentStyles.paddingTop, 10) || 0;
const contentBorderBottomWidth = Number.parseInt(contentStyles.borderBottomWidth, 10) || 0;
const contentPaddingBottom = Number.parseInt(contentStyles.paddingBottom, 10) || 0;
const fullContentHeight = contentBorderTopWidth + contentPaddingTop + itemsHeight + contentPaddingBottom + contentBorderBottomWidth; const fullContentHeight = contentBorderTopWidth + contentPaddingTop + itemsHeight + contentPaddingBottom + contentBorderBottomWidth;
const minContentHeight = Math.min(selectedItem.offsetHeight * 5, fullContentHeight);
const viewportStyles = globalThis.getComputedStyle(viewport);
const viewportPaddingTop = Number.parseInt(viewportStyles.paddingTop, 10) || 0;
const viewportPaddingBottom = Number.parseInt(viewportStyles.paddingBottom, 10) || 0;
const topEdgeToTriggerMiddle = triggerRect.top + triggerRect.height / 2 - CONTENT_MARGIN; const topEdgeToTriggerMiddle = triggerRect.top + triggerRect.height / 2 - CONTENT_MARGIN;
const triggerMiddleToBottomEdge = availableHeight - topEdgeToTriggerMiddle; const triggerMiddleToBottomEdge = availableHeight - topEdgeToTriggerMiddle;
const selectedItemHalfHeight = selectedItem.offsetHeight / 2; const selectedItemHalfHeight = selectedItemHeight / 2;
const itemOffsetMiddle = selectedItem.offsetTop + selectedItemHalfHeight; const itemOffsetMiddle = selectedItemOffsetTop + selectedItemHalfHeight;
const contentTopToItemMiddle = contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle; const contentTopToItemMiddle = contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle;
const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle; const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle;
const willAlignWithoutTopOverflow = contentTopToItemMiddle <= topEdgeToTriggerMiddle; let scrollTop: number | undefined;
if (willAlignWithoutTopOverflow) { if (contentTopToItemMiddle <= topEdgeToTriggerMiddle) {
const isLastItem = selectedItem === items.at(-1); const isLastItem = selectedItem === items.at(-1);
wrapper.style.bottom = '0px'; const viewportOffsetBottom = contentClientHeight - viewportOffsetTop - viewportOffsetHeight;
const viewportOffsetBottom = content.clientHeight - viewport.offsetTop - viewport.offsetHeight;
const clampedTriggerMiddleToBottomEdge = Math.max( const clampedTriggerMiddleToBottomEdge = Math.max(
triggerMiddleToBottomEdge, triggerMiddleToBottomEdge,
selectedItemHalfHeight + (isLastItem ? viewportPaddingBottom : 0) + viewportOffsetBottom + contentBorderBottomWidth, selectedItemHalfHeight + (isLastItem ? viewportPaddingBottom : 0) + viewportOffsetBottom + contentBorderBottomWidth,
); );
const height = contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge;
wrapper.style.height = `${height}px`; placement.bottom = '0px';
placement.height = `${contentTopToItemMiddle + clampedTriggerMiddleToBottomEdge}px`;
} }
else { else {
const isFirstItem = selectedItem === items[0]; const isFirstItem = selectedItem === items[0];
wrapper.style.top = '0px';
const clampedTopEdgeToTriggerMiddle = Math.max( const clampedTopEdgeToTriggerMiddle = Math.max(
topEdgeToTriggerMiddle, topEdgeToTriggerMiddle,
contentBorderTopWidth + viewport.offsetTop + (isFirstItem ? viewportPaddingTop : 0) + selectedItemHalfHeight, contentBorderTopWidth + viewportOffsetTop + (isFirstItem ? viewportPaddingTop : 0) + selectedItemHalfHeight,
); );
const height = clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom;
wrapper.style.height = `${height}px`; placement.top = '0px';
viewport.scrollTop = contentTopToItemMiddle - topEdgeToTriggerMiddle + viewport.offsetTop; placement.height = `${clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom}px`;
scrollTop = contentTopToItemMiddle - topEdgeToTriggerMiddle + viewportOffsetTop;
} }
wrapper.style.margin = `${CONTENT_MARGIN}px 0`; placement.margin = `${CONTENT_MARGIN}px 0`;
wrapper.style.minHeight = `${minContentHeight}px`; placement.minHeight = `${Math.min(selectedItemHeight * 5, fullContentHeight)}px`;
wrapper.style.maxHeight = `${availableHeight}px`; placement.maxHeight = `${availableHeight}px`;
// --- Commit ---
commit(wrapper, placement);
if (scrollTop !== undefined) viewport.scrollTop = scrollTop;
emit('placed'); emit('placed');
requestAnimationFrame(() => (shouldExpandOnScrollRef.value = true)); requestAnimationFrame(() => (shouldExpandOnScrollRef.value = true));
@@ -2,6 +2,12 @@
import type { Direction } from '../../utilities/config-provider'; import type { Direction } from '../../utilities/config-provider';
import type { AcceptableValue } from './utils'; import type { AcceptableValue } from './utils';
/**
* Shape of the select's model value: an array of `T` in multiple mode, a bare
* `T` otherwise. Keeps `v-model` narrow on both sides of the binding.
*/
export type SelectModelValue<T extends AcceptableValue, Multiple extends boolean> = Multiple extends true ? T[] : T;
/** /**
* A custom, fully stylable replacement for the native `<select>` element: a * A custom, fully stylable replacement for the native `<select>` element: a
* trigger button that opens a floating listbox of options, with full keyboard * trigger button that opens a floating listbox of options, with full keyboard
@@ -16,7 +22,9 @@ import type { AcceptableValue } from './utils';
* (compared via `by`). Compose it from a `SelectTrigger` (with * (compared via `by`). Compose it from a `SelectTrigger` (with
* `SelectValue`/`SelectIcon`) plus a portalled `SelectContent` of `SelectItem`s. * `SelectValue`/`SelectIcon`) plus a portalled `SelectContent` of `SelectItem`s.
*/ */
export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> { export interface SelectRootProps<T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false> {
/** Controlled value. Bind with `v-model`. */
modelValue?: SelectModelValue<T, Multiple>;
/** Reading direction. Falls back to ConfigProvider. */ /** Reading direction. Falls back to ConfigProvider. */
dir?: Direction; dir?: Direction;
/** Disable the whole select. */ /** Disable the whole select. */
@@ -26,11 +34,11 @@ export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> {
/** Native input name for form submission. */ /** Native input name for form submission. */
name?: string; name?: string;
/** Uncontrolled default value. */ /** Uncontrolled default value. */
defaultValue?: T | T[]; defaultValue?: SelectModelValue<T, Multiple>;
/** Uncontrolled default open state. */ /** Uncontrolled default open state. */
defaultOpen?: boolean; defaultOpen?: boolean;
/** Allow selecting multiple options; the model becomes an array. */ /** Allow selecting multiple options; the model becomes an array. */
multiple?: boolean; multiple?: Multiple;
/** /**
* Compare object values by a property key or a custom comparator. Omitted * Compare object values by a property key or a custom comparator. Omitted
* `===` for primitives / structural deep-equality for objects. * `===` for primitives / structural deep-equality for objects.
@@ -40,13 +48,20 @@ export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> {
autocomplete?: string; autocomplete?: string;
} }
export interface SelectRootEmits<T extends AcceptableValue = AcceptableValue> { export interface SelectRootEmits<T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false> {
'update:modelValue': [value: T | T[] | undefined]; 'update:modelValue': [value: SelectModelValue<T, Multiple>];
'update:open': [open: boolean]; 'update:open': [open: boolean];
} }
/**
* The subset `defineEmits` declares. `update:open` comes from `defineModel`;
* passing a model key through `defineEmits` as well erases its payload type
* from the generated declarations, leaving consumers with `unknown`.
*/
type SelectRootOwnEmits<T extends AcceptableValue, Multiple extends boolean> = Omit<SelectRootEmits<T, Multiple>, 'update:open'>;
</script> </script>
<script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue"> <script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false">
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import { computed, ref, shallowRef, toRef, watch } from 'vue'; import { computed, ref, shallowRef, toRef, watch } from 'vue';
@@ -60,6 +75,7 @@ import { compare, shouldShowPlaceholder } from './utils';
defineOptions({ inheritAttrs: false }); defineOptions({ inheritAttrs: false });
const { const {
modelValue,
dir, dir,
disabled = false, disabled = false,
required = false, required = false,
@@ -69,11 +85,13 @@ const {
multiple = false, multiple = false,
by, by,
autocomplete, autocomplete,
} = defineProps<SelectRootProps<T>>(); } = defineProps<SelectRootProps<T, Multiple>>();
const emit = defineEmits<SelectRootOwnEmits<T, Multiple>>();
defineSlots<{ defineSlots<{
default?: (props: { default?: (props: {
modelValue: T | T[] | undefined; modelValue: SelectModelValue<T, Multiple> | undefined;
open: boolean; open: boolean;
}) => unknown; }) => unknown;
}>(); }>();
@@ -88,16 +106,26 @@ const open = defineModel<boolean>('open', {
}, },
}); });
const localValue = ref<T | T[] | undefined>(defaultValue ?? (multiple ? ([] as T[]) : undefined)) as Ref<T | T[] | undefined>; type ModelValue = SelectModelValue<T, Multiple>;
const value = defineModel<T | T[] | undefined>('modelValue', {
default: undefined, // `defineModel` would type `update:modelValue` as `ModelValue | undefined`,
get: v => (v ?? localValue.value), // forcing every consumer's `v-model` target to accept `undefined` even though
// a selection is never cleared. The prop and the emit are declared separately
// so the emitted payload stays exactly `ModelValue` (see AGENTS §3.2.3).
const localValue = ref(defaultValue ?? (multiple ? [] : undefined)) as Ref<ModelValue | undefined>;
const value = computed<ModelValue | undefined>({
get: () => modelValue ?? localValue.value,
set: (v) => { set: (v) => {
localValue.value = v; localValue.value = v;
return v; emit('update:modelValue', v as ModelValue);
}, },
}); });
// The public model type is conditional on `Multiple`, which TypeScript cannot
// narrow inside the component; the internal logic reads and writes the union
// through this widened alias instead.
const model = value as unknown as Ref<T | T[] | undefined>;
const contentId = useId(undefined, 'select-content'); const contentId = useId(undefined, 'select-content');
const dirRef = toRef(() => dir); const dirRef = toRef(() => dir);
const disabledRef = toRef(() => disabled); const disabledRef = toRef(() => disabled);
@@ -119,7 +147,7 @@ const displayValue = ref<string | undefined>(undefined);
const rawOptions = new Set<SelectOption>(); const rawOptions = new Set<SelectOption>();
const optionsSet = shallowRef(new Set<SelectOption>()); const optionsSet = shallowRef(new Set<SelectOption>());
const isEmptyModelValue = computed(() => shouldShowPlaceholder(value.value)); const isEmptyModelValue = computed(() => shouldShowPlaceholder(model.value));
function getOptionFrom(source: Iterable<SelectOption>, v: AcceptableValue): SelectOption | undefined { function getOptionFrom(source: Iterable<SelectOption>, v: AcceptableValue): SelectOption | undefined {
for (const option of source) { for (const option of source) {
@@ -143,8 +171,8 @@ function onOptionRemove(option: SelectOption) {
} }
// Persist a single-value label for the legacy `displayValue` slot path. // Persist a single-value label for the legacy `displayValue` slot path.
watch([optionsSet, value], () => { watch([optionsSet, model], () => {
const current = value.value; const current = model.value;
if (current === undefined || Array.isArray(current)) return; if (current === undefined || Array.isArray(current)) return;
const text = getOptionFrom(optionsSet.value, current)?.textContent; const text = getOptionFrom(optionsSet.value, current)?.textContent;
if (text !== undefined) displayValue.value = text; if (text !== undefined) displayValue.value = text;
@@ -152,21 +180,21 @@ watch([optionsSet, value], () => {
function handleValueChange(newValue: AcceptableValue) { function handleValueChange(newValue: AcceptableValue) {
if (multiple) { if (multiple) {
const array = Array.isArray(value.value) ? [...value.value] : []; const array = Array.isArray(model.value) ? [...model.value] : [];
const index = array.findIndex(v => compare(v as T, newValue as T, by as never)); const index = array.findIndex(v => compare(v as T, newValue as T, by as never));
if (index === -1) array.push(newValue as T); if (index === -1) array.push(newValue as T);
else array.splice(index, 1); else array.splice(index, 1);
value.value = [...array] as T[]; model.value = [...array] as T[];
} }
else { else {
value.value = newValue as T; model.value = newValue as T;
displayValue.value = getOptionFrom(rawOptions, newValue)?.textContent; displayValue.value = getOptionFrom(rawOptions, newValue)?.textContent;
open.value = false; open.value = false;
} }
} }
function isSelectedValue(itemValue: AcceptableValue): boolean { function isSelectedValue(itemValue: AcceptableValue): boolean {
const current = value.value; const current = model.value;
if (current === undefined) return false; if (current === undefined) return false;
if (Array.isArray(current)) { if (Array.isArray(current)) {
for (const v of current) { for (const v of current) {
@@ -197,7 +225,7 @@ const isFormControl = computed(() => {
}); });
provideSelectRootContext({ provideSelectRootContext({
value, value: model,
onValueChange: handleValueChange, onValueChange: handleValueChange,
open, open,
onOpenChange: (v) => { open.value = v; }, onOpenChange: (v) => { open.value = v; },
@@ -237,7 +265,7 @@ provideSelectRootContext({
:disabled="disabled" :disabled="disabled"
:multiple="multiple" :multiple="multiple"
:options="nativeOptions" :options="nativeOptions"
:value="value" :value="model"
@change="handleValueChange" @change="handleValueChange"
/> />
@@ -245,7 +273,7 @@ provideSelectRootContext({
v-else-if="name" v-else-if="name"
type="hidden" type="hidden"
:name="name" :name="name"
:value="Array.isArray(value) ? '' : (value ?? '')" :value="Array.isArray(model) ? '' : (model ?? '')"
:required="required" :required="required"
:disabled="disabled" :disabled="disabled"
:autocomplete="autocomplete" :autocomplete="autocomplete"
@@ -20,11 +20,11 @@ export interface SelectViewportProps extends PrimitiveProps {
<script setup lang="ts"> <script setup lang="ts">
import { ref, toRef, watchPostEffect } from 'vue'; import { ref, toRef, watchPostEffect } from 'vue';
import { useForwardExpose } from '@robonen/vue'; import { useForwardExpose, useStyleTag } from '@robonen/vue';
import { useNonce } from '../../utilities/config-provider'; import { useNonce } from '../../utilities/config-provider';
import { Primitive } from '../../internal/primitive'; import { Primitive } from '../../internal/primitive';
import { useSelectContentContext, useSelectItemAlignedPositionContext } from './context'; import { useSelectContentContext, useSelectItemAlignedPositionContext } from './context';
import { CONTENT_MARGIN } from './utils'; import { CONTENT_MARGIN, VIEWPORT_SCROLLBAR_CSS } from './utils';
const { as = 'div', nonce: propNonce } = defineProps<SelectViewportProps>(); const { as = 'div', nonce: propNonce } = defineProps<SelectViewportProps>();
@@ -32,6 +32,11 @@ const { forwardRef, currentElement } = useForwardExpose();
const contentCtx = useSelectContentContext(); const contentCtx = useSelectContentContext();
const nonce = useNonce(toRef(() => propNonce)); const nonce = useNonce(toRef(() => propNonce));
// Injected into `<head>` (one reference-counted tag per document) rather than
// rendered as a sibling `<style>`: a second root node would turn this component
// into a fragment, and Vue cannot inherit a consumer's `class` onto a fragment.
useStyleTag(VIEWPORT_SCROLLBAR_CSS, { id: 'primitives-select-viewport', nonce: nonce.value });
const alignedCtx = contentCtx.position === 'item-aligned' const alignedCtx = contentCtx.position === 'item-aligned'
? useSelectItemAlignedPositionContext(null as never) ? useSelectItemAlignedPositionContext(null as never)
: undefined; : undefined;
@@ -82,8 +87,4 @@ function handleScroll(event: Event) {
> >
<slot /> <slot />
</Primitive> </Primitive>
<Primitive as="style" :nonce="nonce">
[data-primitives-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}
[data-primitives-select-viewport]::-webkit-scrollbar{display:none;}
</Primitive>
</template> </template>
@@ -385,3 +385,133 @@ describe('Select — native form submission', () => {
w.unmount(); w.unmount();
}); });
}); });
describe('Select — attribute forwarding on the panel', () => {
function mountStyled() {
return track(mount(
defineComponent({
setup() {
return () => h(
SelectRoot,
{ defaultOpen: true },
{
default: () => [
h(SelectTrigger, { id: 'styled-trigger', 'aria-label': 'Fruit' }, {
default: () => h(SelectValue, { placeholder: 'Pick one' }),
}),
h(SelectPortal, null, {
default: () => h(SelectContent, { class: 'panel', 'data-panel': 'yes' }, {
default: () => h(SelectViewport, { class: 'viewport' }, {
default: () => h(SelectItem, { value: 'apple' }, {
default: () => h(SelectItemText, null, { default: () => 'Apple' }),
}),
}),
}),
}),
],
},
);
},
}),
{ attachTo: document.body },
));
}
it('forwards class and data attributes from SelectContent to the panel element', async () => {
const w = mountStyled();
await flush();
const panel = document.querySelector('[data-primitives-select-content]') as HTMLElement | null;
expect(panel).toBeTruthy();
expect(panel!.classList.contains('panel')).toBe(true);
expect(panel!.getAttribute('data-panel')).toBe('yes');
w.unmount();
});
it('forwards class from SelectViewport to the viewport element', async () => {
const w = mountStyled();
await flush();
const viewport = document.querySelector('[data-primitives-select-viewport]') as HTMLElement | null;
expect(viewport).toBeTruthy();
expect(viewport!.classList.contains('viewport')).toBe(true);
w.unmount();
});
it('keeps the trigger a single root that accepts native attributes', async () => {
const w = mountStyled();
await flush();
const trigger = getTrigger();
expect(trigger.id).toBe('styled-trigger');
expect(trigger.getAttribute('aria-label')).toBe('Fruit');
w.unmount();
});
it('injects the scrollbar-hiding stylesheet into head instead of a sibling style node', async () => {
const w = mountStyled();
await flush();
const injected = document.head.querySelector('#primitives-select-viewport');
expect(injected).toBeTruthy();
expect(injected!.textContent).toContain('[data-primitives-select-viewport]');
const panel = document.querySelector('[data-primitives-select-content]') as HTMLElement;
expect(panel.querySelector('style')).toBeNull();
w.unmount();
});
});
describe('Select — panel placement without a selection', () => {
function mountUnmatched(options: Opt[]) {
return track(mount(
defineComponent({
setup() {
// A model value that matches no option — a stale id, a deleted user,
// a directory that has not loaded yet.
return () => h(
SelectRoot,
{ defaultOpen: true, modelValue: 'gone' as never },
{
default: () => [
h(SelectTrigger, null, { default: () => h(SelectValue, { placeholder: 'Pick one' }) }),
h(SelectPortal, null, {
default: () => h(SelectContent, null, {
default: () => h(SelectViewport, null, {
default: () => options.map(opt =>
h(SelectItem, { key: String(opt.value), value: opt.value as never }, {
default: () => h(SelectItemText, null, { default: () => opt.label }),
}),
),
}),
}),
}),
],
},
);
},
}),
{ attachTo: document.body },
));
}
it('aligns on the first valid item when the model matches nothing', async () => {
const w = mountUnmatched([{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }]);
await flush();
const wrapper = document.querySelector('[data-primitives-select-content-wrapper]') as HTMLElement | null;
expect(wrapper).toBeTruthy();
// Item-aligned placement sets all three; bailing out leaves them empty and
// the fixed wrapper pinned to the viewport origin.
expect(wrapper!.style.minWidth).not.toBe('');
expect(wrapper!.style.height).not.toBe('');
expect(wrapper!.style.left || wrapper!.style.right).not.toBe('');
w.unmount();
});
it('places the panel instead of leaving it pinned to the viewport origin', async () => {
const w = mountUnmatched([]);
await flush();
const wrapper = document.querySelector('[data-primitives-select-content-wrapper]') as HTMLElement | null;
expect(wrapper).toBeTruthy();
// With no items at all there is nothing to align to; the fallback still has
// to give the wrapper explicit coordinates.
expect(wrapper!.style.top).not.toBe('');
expect(wrapper!.style.left).not.toBe('');
w.unmount();
});
});
@@ -4,13 +4,6 @@ import type { AcceptableValue } from './utils';
import { useContextFactory } from '@robonen/vue'; import { useContextFactory } from '@robonen/vue';
/**
* @deprecated Kept for backward compatibility. The select now accepts any
* {@link AcceptableValue} (string/number/boolean/object). `SelectValue` remains
* a string alias so existing `string`-typed consumers keep compiling.
*/
export type SelectValue = string;
export interface SelectOption { export interface SelectOption {
value: AcceptableValue; value: AcceptableValue;
disabled?: boolean; disabled?: boolean;
+1 -2
View File
@@ -29,7 +29,6 @@ export {
} from './context'; } from './context';
export type { export type {
SelectValue,
SelectOption, SelectOption,
SelectRootContext, SelectRootContext,
SelectContentContext, SelectContentContext,
@@ -38,7 +37,7 @@ export type {
SelectItemContext, SelectItemContext,
} from './context'; } from './context';
export type { AcceptableValue as SelectAcceptableValue } from './utils'; export type { AcceptableValue as SelectAcceptableValue } from './utils';
export type { SelectRootProps, SelectRootEmits } from './SelectRoot.vue'; export type { SelectModelValue, SelectRootProps, SelectRootEmits } from './SelectRoot.vue';
export type { SelectTriggerProps } from './SelectTrigger.vue'; export type { SelectTriggerProps } from './SelectTrigger.vue';
export type { SelectValueProps } from './SelectValue.vue'; export type { SelectValueProps } from './SelectValue.vue';
export type { SelectIconProps } from './SelectIcon.vue'; export type { SelectIconProps } from './SelectIcon.vue';
@@ -9,6 +9,11 @@ export const OPEN_KEYS = [' ', 'Enter', 'ArrowUp', 'ArrowDown'];
export const SELECTION_KEYS = [' ', 'Enter']; export const SELECTION_KEYS = [' ', 'Enter'];
export const CONTENT_MARGIN = 10; export const CONTENT_MARGIN = 10;
/** Hides the viewport's scrollbar across engines while keeping it scrollable. */
export const VIEWPORT_SCROLLBAR_CSS
= '[data-primitives-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}'
+ '[data-primitives-select-viewport]::-webkit-scrollbar{display:none;}';
export function getOpenState(open: boolean): 'open' | 'closed' { export function getOpenState(open: boolean): 'open' | 'closed' {
return open ? 'open' : 'closed'; return open ? 'open' : 'closed';
} }
@@ -45,6 +45,14 @@ export interface RovingFocusGroupEmits {
'update:currentTabStopId': [value: string | null | undefined]; 'update:currentTabStopId': [value: string | null | undefined];
} }
/**
* The subset `defineEmits` declares. `update:currentTabStopId` comes from
* `defineModel`; passing a model key through `defineEmits` as well erases its
* payload type from the generated declarations, leaving consumers with
* `unknown`.
*/
type RovingFocusGroupOwnEmits = Omit<RovingFocusGroupEmits, 'update:currentTabStopId'>;
export interface RovingFocusGroupContext { export interface RovingFocusGroupContext {
orientation: Ref<Orientation | undefined>; orientation: Ref<Orientation | undefined>;
dir: Ref<Direction>; dir: Ref<Direction>;
@@ -77,7 +85,7 @@ const {
as = 'div', as = 'div',
} = defineProps<RovingFocusGroupProps>(); } = defineProps<RovingFocusGroupProps>();
const emit = defineEmits<RovingFocusGroupEmits>(); const emit = defineEmits<RovingFocusGroupOwnEmits>();
const config = useConfig(); const config = useConfig();
// `dir` falls back to the provider's configured direction when not given as prop. // `dir` falls back to the provider's configured direction when not given as prop.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/writekit", "name": "@robonen/writekit",
"version": "0.0.1", "version": "0.0.4",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Headless block-based rich-text writekit for Vue with a registry-driven schema and pluggable CRDT", "description": "Headless block-based rich-text writekit for Vue with a registry-driven schema and pluggable CRDT",
"keywords": [ "keywords": [
+1 -1
View File
@@ -9,5 +9,5 @@ export const blockquote = defineBlock({
parseDOM: [{ tag: 'blockquote' }], parseDOM: [{ tag: 'blockquote' }],
}, },
inputRules: [{ match: /^>\s$/ }], inputRules: [{ match: /^>\s$/ }],
meta: { title: 'Quote', icon: 'quote', keywords: ['quote', 'blockquote', 'citation'], group: 'basic' }, meta: { title: 'Quote', icon: 'quote', keywords: ['quote', 'blockquote', 'citation'], group: 'basic', description: 'Set a passage apart from the narration.' },
}); });
+1 -1
View File
@@ -13,5 +13,5 @@ export const callout = defineBlock({
getAttrs: (el: HTMLElement) => ({ variant: el.getAttribute('data-callout') ?? 'info' }), getAttrs: (el: HTMLElement) => ({ variant: el.getAttribute('data-callout') ?? 'info' }),
}], }],
}, },
meta: { title: 'Callout', icon: 'info', keywords: ['callout', 'note', 'info', 'warning'], group: 'basic' }, meta: { title: 'Callout', icon: 'info', keywords: ['callout', 'note', 'info', 'warning'], group: 'basic', description: 'A highlighted note the eye cannot miss.' },
}); });
+1 -1
View File
@@ -12,5 +12,5 @@ export const codeBlock = defineBlock({
toDOM: (node: Node) => ['pre', { 'data-language': String(node.attrs['language'] ?? 'plain') }, 0], toDOM: (node: Node) => ['pre', { 'data-language': String(node.attrs['language'] ?? 'plain') }, 0],
parseDOM: [{ tag: 'pre' }], parseDOM: [{ tag: 'pre' }],
}, },
meta: { title: 'Code block', icon: 'code', keywords: ['code', 'pre', 'snippet'], group: 'basic' }, meta: { title: 'Code block', icon: 'code', keywords: ['code', 'pre', 'snippet'], group: 'basic', description: 'Verbatim monospaced text; Enter stays inside.' },
}); });
+1 -1
View File
@@ -10,5 +10,5 @@ export const divider = defineBlock({
parseDOM: [{ tag: 'hr' }], parseDOM: [{ tag: 'hr' }],
}, },
component: DividerBlock, component: DividerBlock,
meta: { title: 'Divider', icon: 'minus', keywords: ['divider', 'hr', 'rule', 'separator'], group: 'media' }, meta: { title: 'Divider', icon: 'minus', keywords: ['divider', 'hr', 'rule', 'separator'], group: 'media', description: 'A horizontal rule between sections.' },
}); });
+1 -1
View File
@@ -14,5 +14,5 @@ export const heading = defineBlock({
parseDOM: LEVELS.map(level => ({ tag: `h${level}`, attrs: { level } })), parseDOM: LEVELS.map(level => ({ tag: `h${level}`, attrs: { level } })),
}, },
inputRules: LEVELS.map(level => ({ match: new RegExp(`^#{${level}}\\s$`), attrs: { level } })), inputRules: LEVELS.map(level => ({ match: new RegExp(`^#{${level}}\\s$`), attrs: { level } })),
meta: { title: 'Heading', icon: 'heading', keywords: ['heading', 'title', 'h1', 'h2', 'h3'], group: 'basic' }, meta: { title: 'Heading', icon: 'heading', keywords: ['heading', 'title', 'h1', 'h2', 'h3'], group: 'basic', description: 'A section title, levels 16.' },
}); });
+1 -1
View File
@@ -23,5 +23,5 @@ export const image = defineBlock({
}], }],
}, },
component: ImageBlock, component: ImageBlock,
meta: { title: 'Image', icon: 'image', keywords: ['image', 'img', 'picture', 'photo'], group: 'media' }, meta: { title: 'Image', icon: 'image', keywords: ['image', 'img', 'picture', 'photo'], group: 'media', description: 'An image with an optional caption.' },
}); });
+11 -5
View File
@@ -14,7 +14,7 @@ function indentOf(node: Node): number {
* `checked` for to-dos). Markers/numbering and indentation are presentation * `checked` for to-dos). Markers/numbering and indentation are presentation
* (CSS), so the model stays a simple flat block list that maps cleanly to a CRDT. * (CSS), so the model stays a simple flat block list that maps cleanly to a CRDT.
*/ */
function defineListBlock(options: { type: string; listType: ListType; title: string; keywords: readonly string[] }) { function defineListBlock(options: { type: string; listType: ListType; title: string; keywords: readonly string[]; description?: string }) {
const todo = options.listType === 'todo'; const todo = options.listType === 'todo';
const attrs: AttrsSpec = { const attrs: AttrsSpec = {
@@ -43,10 +43,16 @@ function defineListBlock(options: { type: string; listType: ListType; title: str
parseDOM: [{ tag: `[data-list='${options.listType}']` }], parseDOM: [{ tag: `[data-list='${options.listType}']` }],
}, },
inputRules, inputRules,
meta: { title: options.title, icon: 'list', keywords: options.keywords, group: 'lists' }, meta: {
title: options.title,
icon: 'list',
keywords: options.keywords,
group: 'lists',
...(options.description !== undefined && { description: options.description }),
},
}); });
} }
export const bulletedList = defineListBlock({ type: 'bulleted-list', listType: 'bullet', title: 'Bulleted list', keywords: ['ul', 'bullet', 'unordered', 'list'] }); export const bulletedList = defineListBlock({ type: 'bulleted-list', listType: 'bullet', title: 'Bulleted list', keywords: ['ul', 'bullet', 'unordered', 'list'], description: 'Items marked with bullets; Tab indents.' });
export const numberedList = defineListBlock({ type: 'numbered-list', listType: 'ordered', title: 'Numbered list', keywords: ['ol', 'number', 'ordered', 'list'] }); export const numberedList = defineListBlock({ type: 'numbered-list', listType: 'ordered', title: 'Numbered list', keywords: ['ol', 'number', 'ordered', 'list'], description: 'Items numbered in order; Tab indents.' });
export const todoList = defineListBlock({ type: 'todo-list', listType: 'todo', title: 'To-do list', keywords: ['todo', 'task', 'checkbox', 'check'] }); export const todoList = defineListBlock({ type: 'todo-list', listType: 'todo', title: 'To-do list', keywords: ['todo', 'task', 'checkbox', 'check'], description: 'Checkable tasks; Enter adds the next one.' });
+1 -1
View File
@@ -9,5 +9,5 @@ export const paragraph = defineBlock({
parseDOM: [{ tag: 'p' }], parseDOM: [{ tag: 'p' }],
}, },
placeholder: 'Write something…', placeholder: 'Write something…',
meta: { title: 'Paragraph', icon: 'text', keywords: ['paragraph', 'text', 'p'], group: 'basic' }, meta: { title: 'Paragraph', icon: 'text', keywords: ['paragraph', 'text', 'p'], group: 'basic', description: 'Plain prose — the default block.' },
}); });
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { caret, createDoc, createNode, nodeInline, nodeText, textSelection } from '../../model'; import { caret, createDoc, createNode, nodeInline, nodeSelection, nodeText, textSelection } from '../../model';
import { createDefaultRegistry } from '../../preset'; import { createDefaultRegistry } from '../../preset';
import { createWritekit, createWritekitState } from '../../state'; import { createWritekit, createWritekitState } from '../../state';
import { joinBackward, splitBlock, toggleMark } from '..'; import { exitAtom, joinBackward, splitBlock, toggleMark } from '..';
function para(id: string, text: string) { function para(id: string, text: string) {
return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] }); return createNode('paragraph', { id, content: text ? [{ text, marks: [] }] : [] });
@@ -44,6 +44,31 @@ describe('commands', () => {
expect(writekit.state.doc.content.map(block => nodeText(block))).toEqual(['foobar']); expect(writekit.state.doc.content.map(block => nodeText(block))).toEqual(['foobar']);
}); });
it('exitAtom starts a paragraph below a selected atom', () => {
const registry = createDefaultRegistry();
const writekit = createWritekit({
state: createWritekitState({
registry,
doc: createDoc([para('a', 'before'), createNode('divider', { id: 'd' })]),
selection: nodeSelection(['d']),
}),
});
expect(writekit.command(exitAtom)).toBe(true);
const types = writekit.state.doc.content.map(block => block.type);
expect(types).toEqual(['paragraph', 'divider', 'paragraph']);
const sel = writekit.state.selection;
expect(sel.kind).toBe('text');
expect(sel.kind === 'text' && sel.focus.blockId).toBe(writekit.state.doc.content[2]!.id);
});
it('exitAtom is a no-op for text selections', () => {
const writekit = writekitWith([para('a', 'hello')], caret('a', 2));
expect(writekit.command(exitAtom)).toBe(false);
});
it('undo restores the document after a split', () => { it('undo restores the document after a split', () => {
const writekit = writekitWith([para('a', 'hello')], caret('a', 2)); const writekit = writekitWith([para('a', 'hello')], caret('a', 2));
writekit.command(splitBlock); writekit.command(splitBlock);
+31
View File
@@ -2,6 +2,7 @@ import type { Attrs, Node } from '../model';
import { import {
blockById, blockById,
caret, caret,
createNode,
inlineLength, inlineLength,
isAcrossBlocks, isAcrossBlocks,
isCollapsed, isCollapsed,
@@ -88,6 +89,36 @@ export const splitBlock: Command = (state, dispatch) => {
return true; return true;
}; };
/**
* Enter with an atom selected: start a paragraph right below it.
*
* An atom (image, divider, an app's card) has no text position inside it, so
* without this the only way OUT of a selected atom and the only way to write
* between two atoms, or after one that ends the document was to abandon the
* keyboard. Mirrors `createParagraphNear` in the ProseMirror tradition.
*/
export const exitAtom: Command = (state, dispatch) => {
const sel = state.selection;
if (sel.kind !== 'node' || sel.ids.length === 0 || !state.registry.hasBlock('paragraph'))
return false;
const lastId = sel.ids.at(-1)!;
const index = state.doc.content.findIndex(block => block.id === lastId);
if (index === -1)
return false;
if (dispatch) {
const paragraph = createNode('paragraph');
dispatch(createTransaction(state)
.insertBlock(paragraph, index + 1)
.setSelection(caret(paragraph.id, 0)));
}
return true;
};
/** Insert a hard line break (Shift+Enter) inside the current block. */ /** Insert a hard line break (Shift+Enter) inside the current block. */
export const insertHardBreak: Command = (state, dispatch) => { export const insertHardBreak: Command = (state, dispatch) => {
const sel = state.selection; const sel = state.selection;
+3 -1
View File
@@ -1,6 +1,7 @@
import { import {
chainCommands, chainCommands,
deleteSelection, deleteSelection,
exitAtom,
indentListItem, indentListItem,
insertHardBreak, insertHardBreak,
joinBackward, joinBackward,
@@ -36,7 +37,8 @@ export function defaultKeymap(writekit: Writekit): Keymap {
'Mod-z': undo, 'Mod-z': undo,
'Mod-Shift-z': redo, 'Mod-Shift-z': redo,
'Mod-y': redo, 'Mod-y': redo,
Enter: splitBlock, // With an atom selected, Enter starts a paragraph below it; in text it splits.
Enter: chainCommands(exitAtom, splitBlock),
'Shift-Enter': insertHardBreak, 'Shift-Enter': insertHardBreak,
Backspace: chainCommands(deleteSelection, joinBackward), Backspace: chainCommands(deleteSelection, joinBackward),
Delete: chainCommands(deleteSelection, joinForward), Delete: chainCommands(deleteSelection, joinForward),
+11 -1
View File
@@ -4,6 +4,9 @@ import type { NodeSpec } from '../schema';
import type { CommandFactory } from '../state/command'; import type { CommandFactory } from '../state/command';
import type { InputRuleSpec } from './input-rule'; 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`. */ /** Props passed to an atom/void block's Vue `component`. */
export interface BlockComponentProps { export interface BlockComponentProps {
/** The block's model node (read its `attrs`). */ /** The block's model node (read its `attrs`). */
@@ -22,6 +25,8 @@ export interface BlockMeta {
readonly icon?: string; readonly icon?: string;
readonly keywords?: readonly string[]; readonly keywords?: readonly string[];
readonly group?: string; readonly group?: string;
/** One sentence for pickers (the slash menu shows it beside the list). */
readonly description?: string;
} }
/** Optional block-specific behaviors used by core commands. */ /** Optional block-specific behaviors used by core commands. */
@@ -36,11 +41,16 @@ export interface BlockBehavior {
* A block definition: schema contribution + behavior + an opaque Vue component. * A block definition: schema contribution + behavior + an opaque Vue component.
* Non-view layers treat `component` as an opaque value; only the view resolves * 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). * 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 { export interface BlockDefinition {
readonly type: string; readonly type: string;
readonly spec: NodeSpec; readonly spec: NodeSpec;
readonly component?: Component; readonly component?: Component | BlockComponentLoader;
readonly meta?: BlockMeta; readonly meta?: BlockMeta;
readonly behavior?: BlockBehavior; readonly behavior?: BlockBehavior;
readonly commands?: Record<string, CommandFactory>; 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' });
});
});
+42 -8
View File
@@ -14,27 +14,61 @@ export interface Schema {
markSpec: (type: string) => MarkSpec | undefined; markSpec: (type: string) => MarkSpec | undefined;
/** Default attrs for a block type (all defaults applied). */ /** Default attrs for a block type (all defaults applied). */
defaultAttrs: (type: string) => Attrs; 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; coerceAttrs: (type: string, attrs?: Attrs) => Attrs;
/** Default attrs for a mark type. */ /** Default attrs for a mark type. */
defaultMarkAttrs: (type: string) => Attrs; 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; 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 { function coerceWithSpec(spec: AttrsSpec | undefined, attrs?: Attrs): Attrs {
if (!spec) if (!spec) {
return {}; return attrs ? { ...attrs } : {};
}
const result: Record<string, AttrValue> = {}; 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) { for (const key in spec) {
const attr = spec[key]!;
const provided = attrs?.[key]; const provided = attrs?.[key];
if (provided !== undefined) if (provided !== undefined) {
result[key] = provided; if (attr.validate && !attr.validate(provided)) {
else if (spec[key]!.default !== undefined) if (__DEV__)
result[key] = spec[key]!.default!; 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 (attr.default !== undefined) {
result[key] = attr.default;
}
} }
return result; 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);
});
});
+76 -3
View File
@@ -15,6 +15,14 @@ export interface HistoryEntry {
export interface HistoryOptions { export interface HistoryOptions {
/** Maximum number of undo entries to retain (default 200). */ /** Maximum number of undo entries to retain (default 200). */
readonly maxSize?: number; 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 { export interface History {
/** Record a new edit, clearing the redo stack. */ /** Record a new edit, clearing the redo stack. */
record: (entry: HistoryEntry) => void; 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). */ /** Pop the latest undo entry (and push it onto the redo stack). */
undo: () => HistoryEntry | undefined; undo: () => HistoryEntry | undefined;
/** Pop the latest redo entry (and push it back onto the undo stack). */ /** Pop the latest redo entry (and push it back onto the undo stack). */
@@ -35,18 +50,75 @@ export interface History {
clear: () => void; 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 { export function createHistory(options: HistoryOptions = {}): History {
const maxSize = options.maxSize ?? 200; const maxSize = options.maxSize ?? 200;
const coalesceMs = options.coalesceMs ?? 500;
const undoStack: HistoryEntry[] = []; const undoStack: HistoryEntry[] = [];
const redoStack: 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 { return {
record(entry) { record(entry) {
undoStack.push(entry); const now = Date.now();
if (undoStack.length > maxSize) const block = typingBlockOf(entry.steps);
undoStack.shift(); 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; redoStack.length = 0;
}, },
interrupt() {
lastTypingBlock = null;
},
undo() { undo() {
const entry = undoStack.pop(); const entry = undoStack.pop();
if (entry) if (entry)
@@ -64,6 +136,7 @@ export function createHistory(options: HistoryOptions = {}): History {
clear() { clear() {
undoStack.length = 0; undoStack.length = 0;
redoStack.length = 0; redoStack.length = 0;
lastTypingBlock = null;
}, },
}; };
} }
+5
View File
@@ -63,6 +63,11 @@ export function createWritekit(options: CreateWritekitOptions): Writekit {
selectionAfter: next.selection, 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); bus.emit('transaction', tr, next, prev);
if (next.doc !== prev.doc) if (next.doc !== prev.doc)
+27 -3
View File
@@ -3,8 +3,9 @@ import type { Attrs, Node } from '../model';
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import type { IntrinsicElementAttributes } from 'vue'; import type { Component, IntrinsicElementAttributes } from 'vue';
import { computed } from 'vue'; import type { BlockDefinition } from '../registry';
import { computed, defineAsyncComponent } from 'vue';
import { nodeSelection } from '../model'; import { nodeSelection } from '../model';
import { createTransaction } from '../state'; import { createTransaction } from '../state';
import { Primitive } from './primitive'; import { Primitive } from './primitive';
@@ -21,7 +22,30 @@ const ctx = useWritekitContext();
const def = computed(() => ctx.registry.getBlock(block.type)); const def = computed(() => ctx.registry.getBlock(block.type));
const wrapperTag = computed<keyof IntrinsicElementAttributes>(() => (def.value?.as ?? 'div') as keyof IntrinsicElementAttributes); const wrapperTag = computed<keyof IntrinsicElementAttributes>(() => (def.value?.as ?? 'div') as keyof IntrinsicElementAttributes);
const isText = computed(() => def.value?.spec.content.kind === 'text'); 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 isSelected = computed(() => {
const sel = ctx.state.value.selection; const sel = ctx.state.value.selection;
return sel.kind === 'node' && sel.ids.includes(block.id); return sel.kind === 'node' && sel.ids.includes(block.id);
+52 -2
View File
@@ -3,8 +3,8 @@ import type { PrimitiveProps } from './primitive';
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import { blockById, caret, inlineLength, isCollapsed, nodeInline } from '../model'; import { blockById, caret, createNode, inlineLength, isCollapsed, nodeInline } from '../model';
import { applyInputRule, deleteSelection, insertHardBreak, joinBackward, joinForward, splitBlock } from '../commands'; import { applyInputRule, deleteSelection, exitAtom, insertHardBreak, joinBackward, joinForward, splitBlock } from '../commands';
import { createTransaction } from '../state'; import { createTransaction } from '../state';
import { Primitive } from './primitive'; import { Primitive } from './primitive';
import { useWritekitContext } from './context'; import { useWritekitContext } from './context';
@@ -35,6 +35,20 @@ function onBeforeInput(event: InputEvent): void {
if (!type.startsWith('insert') && !type.startsWith('delete')) if (!type.startsWith('insert') && !type.startsWith('delete'))
return; return;
// With an atom selected the native range wraps the block element; letting the
// browser edit through it would rewrite DOM the model never agreed to.
const modelSel = ctx.writekit.state.selection;
if (modelSel.kind === 'node') {
event.preventDefault();
if (type.startsWith('delete'))
ctx.writekit.command(deleteSelection);
else if (type === 'insertParagraph')
ctx.writekit.command(exitAtom);
return;
}
const sel = ctx.selection.read(); const sel = ctx.selection.read();
if (!sel || sel.kind !== 'text') if (!sel || sel.kind !== 'text')
return; return;
@@ -117,6 +131,41 @@ function onInput(event?: Event): void {
ctx.writekit.command(applyInputRule); ctx.writekit.command(applyInputRule);
} }
/**
* A click on the root's own padding below the last block means "write here".
* When the document ends in an atom there is no text position to click into at
* all without this the only way to continue writing was the keyboard path
* (select the atom, press Enter). Ends-in-text just places the caret at the end.
*/
function onRootPointerDown(event: PointerEvent): void {
if (!ctx.config.editable || event.target !== ctx.contentRoot.value)
return;
const last = ctx.writekit.state.doc.content.at(-1);
if (!last)
return;
const lastEl = ctx.blockElements.get(last.id) ?? null;
if (lastEl && event.clientY <= lastEl.getBoundingClientRect().bottom)
return;
event.preventDefault();
if (ctx.writekit.state.schema.nodeSpec(last.type)?.content.kind === 'text') {
ctx.writekit.dispatch(createTransaction(ctx.writekit.state)
.setSelection(caret(last.id, inlineLength(nodeInline(last)))));
return;
}
if (!ctx.writekit.state.registry.hasBlock('paragraph'))
return;
const paragraph = createNode('paragraph');
ctx.writekit.dispatch(createTransaction(ctx.writekit.state)
.insertBlock(paragraph, ctx.writekit.state.doc.content.length)
.setSelection(caret(paragraph.id, 0)));
}
function onCompositionStart(event: CompositionEvent): void { function onCompositionStart(event: CompositionEvent): void {
if (isInteractiveTarget(event.target)) if (isInteractiveTarget(event.target))
return; return;
@@ -143,6 +192,7 @@ function onCompositionEnd(event: CompositionEvent): void {
:spellcheck="ctx.config.spellcheck" :spellcheck="ctx.config.spellcheck"
@beforeinput="onBeforeInput" @beforeinput="onBeforeInput"
@input="onInput" @input="onInput"
@pointerdown="onRootPointerDown"
@compositionstart="onCompositionStart" @compositionstart="onCompositionStart"
@compositionend="onCompositionEnd" @compositionend="onCompositionEnd"
> >
@@ -134,3 +134,97 @@ describe('WritekitRoot (single contenteditable)', () => {
expect(hosts[0]!.textContent).toBe('foobar'); expect(hosts[0]!.textContent).toBe('foobar');
}); });
}); });
describe('writing after a trailing atom', () => {
it('a click below the last block starts a paragraph when the doc ends in an atom', async () => {
const registry = createDefaultRegistry();
const writekit = createWritekit({
state: createWritekitState({
registry,
doc: createDoc([para('a', 'text'), createNode('divider', { id: 'd' })]),
}),
});
render(WritekitRoot, { props: { writekit, platform: 'mac' } });
await nextTick();
const root = document.querySelector('[data-writekit-content]') as HTMLElement;
root.style.paddingBottom = '120px';
const rect = root.getBoundingClientRect();
root.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
clientX: rect.left + 10,
clientY: rect.bottom - 10,
}));
await nextTick();
expect(writekit.state.doc.content.map(block => block.type)).toEqual(['paragraph', 'divider', 'paragraph']);
expect(writekit.state.selection.kind).toBe('text');
});
it('a click below the last block just places the caret when it is text', async () => {
const registry = createDefaultRegistry();
const writekit = createWritekit({
state: createWritekitState({ registry, doc: createDoc([para('a', 'text')]) }),
});
render(WritekitRoot, { props: { writekit, platform: 'mac' } });
await nextTick();
const root = document.querySelector('[data-writekit-content]') as HTMLElement;
root.style.paddingBottom = '120px';
const rect = root.getBoundingClientRect();
root.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
clientX: rect.left + 10,
clientY: rect.bottom - 10,
}));
await nextTick();
expect(writekit.state.doc.content).toHaveLength(1);
const sel = writekit.state.selection;
expect(sel.kind === 'text' && sel.focus.offset).toBe(4);
});
});
describe('node selection survives the DOM', () => {
it('is represented as a real range, so selectionchange cannot overwrite it', async () => {
const registry = createDefaultRegistry();
const writekit = createWritekit({
state: createWritekitState({
registry,
doc: createDoc([para('a', 'first paragraph'), createNode('divider', { id: 'd' })]),
}),
});
render(WritekitRoot, { props: { writekit, platform: 'mac' } });
await nextTick();
writekit.dispatch(createTransaction(writekit.state).setSelection({ kind: 'node', ids: ['d'] }));
await nextTick();
await new Promise(resolve => requestAnimationFrame(resolve));
// The selection exists in the DOM as a range around the atom's element —
// a focused editable with NO range makes the browser invent a caret at the
// start of the content, which used to overwrite the model's node selection.
const domSel = getSelection()!;
expect(domSel.rangeCount).toBe(1);
const selected = domSel.getRangeAt(0).cloneContents().querySelector('[data-block-id="d"]');
expect(selected).not.toBeNull();
// A selectionchange pass over that range must NOT rewrite the model.
document.dispatchEvent(new Event('selectionchange'));
await nextTick();
expect(writekit.state.selection).toEqual({ kind: 'node', ids: ['d'] });
// Enter on the selected atom exits into a fresh paragraph below it.
const root = document.querySelector('[data-writekit-root]') as HTMLElement;
root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await nextTick();
expect(writekit.state.doc.content.map(block => block.type)).toEqual(['paragraph', 'divider', 'paragraph']);
const sel = writekit.state.selection;
expect(sel.kind === 'text' && sel.focus.blockId).toBe(writekit.state.doc.content[2]!.id);
});
});
+1 -1
View File
@@ -1 +1 @@
export { useContextFactory, useEventListener } from '@robonen/vue'; export { unrefElement, useContextFactory, useEventListener } from '@robonen/vue';
@@ -151,10 +151,25 @@ export function createSelectionBridge(
return; return;
if (selection.kind === 'node') { if (selection.kind === 'node') {
// Block-level selection has no native text range; the visual highlight // The node selection must exist in the DOM too, as a range around the
// comes from [data-selected] on the block wrapper. Keep the editable root // block element. Merely clearing the ranges left a focused editable with
// focused so keyboard commands (Backspace/Delete on the node) still reach it. // no selection — the browser then invents a caret at the START of the
// content, `selectionchange` reads it, and the model's node selection
// gets overwritten by a text caret in the first block (the Enter meant
// for the atom split the opening paragraph instead).
domSel.removeAllRanges(); domSel.removeAllRanges();
const lastId = selection.ids.at(-1);
const el = lastId === undefined
? null
: root.querySelector(`[data-block-id="${CSS.escape(lastId)}"]`);
if (el) {
const range = root.ownerDocument.createRange();
range.selectNode(el);
domSel.addRange(range);
}
if (root.isContentEditable && root.ownerDocument.activeElement !== root) if (root.isContentEditable && root.ownerDocument.activeElement !== root)
root.focus({ preventScroll: true }); root.focus({ preventScroll: true });
return; return;
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'; import { onBeforeUnmount, ref, shallowRef } from 'vue';
import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives'; import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives';
import { isCollapsed } from '../../model'; import { isCollapsed } from '../../model';
import { isMarkActive, toggleMark } from '../../commands'; import { isMarkActive, toggleMark } from '../../commands';
@@ -17,7 +17,7 @@ const ctx = useWritekitContext();
// Virtual reference (a `Measurable`) anchored to the selection rect Popper // Virtual reference (a `Measurable`) anchored to the selection rect Popper
// positions against it with no trigger element. Reassigned on every refresh so // positions against it with no trigger element. Reassigned on every refresh so
// PopperContent re-resolves position as the selection moves. // PopperContent re-resolves position as the selection moves.
const reference = ref<{ getBoundingClientRect: () => DOMRect } | undefined>(); const reference = shallowRef<{ getBoundingClientRect: () => DOMRect } | undefined>();
const open = ref(false); const open = ref(false);
const rev = ref(0); const rev = ref(0);
@@ -54,8 +54,11 @@ function toggle(type: string): void {
</script> </script>
<template> <template>
<Portal to="body"> <!-- Combobox layering: PopperRoot provides the positioning context outside
<PopperRoot> the portal. The bare Portal resolves its target from the ConfigProvider's
teleportTarget (body unless the app overrides it). -->
<PopperRoot>
<Portal>
<PopperContent <PopperContent
v-if="open && reference" v-if="open && reference"
:reference="reference" :reference="reference"
@@ -83,6 +86,6 @@ function toggle(type: string): void {
</slot> </slot>
</DismissableLayer> </DismissableLayer>
</PopperContent> </PopperContent>
</PopperRoot> </Portal>
</Portal> </PopperRoot>
</template> </template>
+123 -54
View File
@@ -1,38 +1,8 @@
<script setup lang="ts"> <script lang="ts">
import { onBeforeUnmount, ref } from 'vue'; /** Regexp-special characters, escaped when the trigger is interpolated. */
import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives'; const ESCAPE_RE = /[.*+?^${}()|[\]\\]/g;
import { blockById, caret, createNode, inlineText, isCollapsed, nodeInline, nodeSelection } from '../../model';
import { createTransaction } from '../../state';
import { useWritekitContext } from '../context';
import { useEventListener } from '../composables';
import type { SlashItem } from './slash-items';
import { getSlashItems } from './slash-items';
export interface WritekitSlashMenuProps {
/** Character that opens the menu (default `'/'`). */
trigger?: string;
}
const { trigger = '/' } = defineProps<WritekitSlashMenuProps>();
const ctx = useWritekitContext();
const open = ref(false);
const items = ref<SlashItem[]>([]);
const highlighted = ref(0);
// Virtual reference (a `Measurable`) anchored to the caret rect; Popper positions
// against it with no trigger element. Focus stays in the contenteditable (so the
// user keeps typing to filter), so nav is driven by the capture-phase keydown
// below and the highlight is an index not roving focus / listbox focus.
const reference = ref<{ getBoundingClientRect: () => DOMRect } | undefined>();
let triggerBlockId = '';
let triggerStart = 0;
let caretOffset = 0;
function escapeRegExp(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** The caret's client rect, when the native selection has a visible one. */
function caretRect(): DOMRect | null { function caretRect(): DOMRect | null {
const selection = globalThis.window === undefined ? null : globalThis.getSelection(); const selection = globalThis.window === undefined ? null : globalThis.getSelection();
if (!selection || selection.rangeCount === 0) if (!selection || selection.rangeCount === 0)
@@ -43,6 +13,60 @@ function caretRect(): DOMRect | null {
const rect = rects.length > 0 ? rects[0]! : range.getBoundingClientRect(); const rect = rects.length > 0 ? rects[0]! : range.getBoundingClientRect();
return rect.width || rect.height ? rect : null; return rect.width || rect.height ? rect : null;
} }
</script>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef, watch } from 'vue';
import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives';
import { blockById, caret, createNode, inlineText, isCollapsed, nodeInline, nodeSelection } from '../../model';
import { createTransaction } from '../../state';
import { useWritekitContext } from '../context';
import { unrefElement, useEventListener } from '../composables';
import type { SlashItem } from './slash-items';
import { getSlashItems } from './slash-items';
export interface WritekitSlashMenuProps {
/** Character that opens the menu (default `'/'`). */
trigger?: string;
}
const { trigger = '/' } = defineProps<WritekitSlashMenuProps>();
/**
* Optional detail pane beside the list. Headless: the menu only knows WHICH
* item is highlighted; what a block looks like is the app's knowledge, so the
* pane renders the `preview` slot when given one and plain `meta.description`
* text otherwise. No slot and no description no pane, same menu as before.
*/
const slots = defineSlots<{
preview?: (props: { item: SlashItem }) => unknown;
}>();
const ctx = useWritekitContext();
const open = ref(false);
const items = shallowRef<SlashItem[]>([]);
const highlighted = ref(0);
// Virtual reference (a `Measurable`) anchored to the caret rect; Popper positions
// against it with no trigger element. Focus stays in the contenteditable (so the
// user keeps typing to filter), so nav is driven by the capture-phase keydown
// below and the highlight is an index not roving focus / listbox focus.
const reference = shallowRef<{ getBoundingClientRect: () => DOMRect } | undefined>();
// vue 3.5: a template ref inside v-for collects the elements in source order.
const layer = useTemplateRef<InstanceType<typeof DismissableLayer>>('layer');
const itemRefs = useTemplateRef<HTMLButtonElement[]>('options');
const layerEl = computed(() => unrefElement(layer.value));
const active = computed<SlashItem | undefined>(() => items.value[highlighted.value]);
const hasPreview = computed(() => slots.preview !== undefined || active.value?.description !== undefined);
/** `(start or whitespace) + trigger + query` immediately before the caret. */
const matcher = computed(() =>
new RegExp(`(?:^|\\s)${trigger.replaceAll(ESCAPE_RE, '\\$&')}([\\p{L}\\p{N}]*)$`, 'u'));
let triggerBlockId = '';
let triggerStart = 0;
let caretOffset = 0;
function close(): void { function close(): void {
open.value = false; open.value = false;
@@ -65,7 +89,7 @@ function refresh(): void {
} }
const before = inlineText(nodeInline(block)).slice(0, sel.focus.offset); const before = inlineText(nodeInline(block)).slice(0, sel.focus.offset);
const match = new RegExp(`(?:^|\\s)${escapeRegExp(trigger)}([\\p{L}\\p{N}]*)$`, 'u').exec(before); const match = matcher.value.exec(before);
if (!match) { if (!match) {
close(); close();
@@ -149,15 +173,41 @@ function onKeydownCapture(event: KeyboardEvent): void {
} }
} }
// Keyboard navigation must chase the highlight into view a list longer than
// the menu's max-height otherwise walks the selection out of sight.
watch(highlighted, index => void nextTick(() => {
itemRefs.value?.[index]?.scrollIntoView({ block: 'nearest' });
}));
/**
* The menu is anchored to a caret rect that does NOT move with the page, so a
* background scroll visually tears the menu off its anchor. Scrolling inside
* the menu (a long block list) stays allowed.
*/
function onScrollIntent(event: Event): void {
if (!open.value)
return;
if (layerEl.value && event.target instanceof Node && layerEl.value.contains(event.target))
return;
event.preventDefault();
}
ctx.writekit.on('transaction', refresh); ctx.writekit.on('transaction', refresh);
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'selectionchange', refresh); useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'selectionchange', refresh);
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'keydown', onKeydownCapture as (event: Event) => void, { capture: true }); useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'keydown', onKeydownCapture as (event: Event) => void, { capture: true });
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'wheel', onScrollIntent, { capture: true, passive: false });
useEventListener(() => (typeof document === 'undefined' ? undefined : document), 'touchmove', onScrollIntent, { capture: true, passive: false });
onBeforeUnmount(() => ctx.writekit.off('transaction', refresh)); onBeforeUnmount(() => ctx.writekit.off('transaction', refresh));
</script> </script>
<template> <template>
<Portal to="body"> <!-- Combobox layering: PopperRoot provides the positioning context outside
<PopperRoot> the portal. The bare Portal resolves its target from the ConfigProvider's
teleportTarget (body unless the app overrides it). -->
<PopperRoot>
<Portal>
<PopperContent <PopperContent
v-if="open && reference" v-if="open && reference"
:reference="reference" :reference="reference"
@@ -167,27 +217,46 @@ onBeforeUnmount(() => ctx.writekit.off('transaction', refresh));
:collision-padding="8" :collision-padding="8"
> >
<DismissableLayer <DismissableLayer
class="writekit-slash-menu" ref="layer"
role="listbox" class="writekit-slash"
data-writekit-slash-menu="" data-writekit-slash=""
@dismiss="close" @dismiss="close"
@focus-outside.prevent @focus-outside.prevent
> >
<button <div
v-for="(item, index) in items" class="writekit-slash-menu"
:key="item.type" role="listbox"
type="button" data-writekit-slash-menu=""
role="option"
:data-highlighted="index === highlighted || undefined"
:aria-selected="index === highlighted"
@mousedown.prevent="selectItem(item)"
@mousemove="highlighted = index"
> >
<span class="slash-title">{{ item.title }}</span> <button
<span class="slash-group">{{ item.group }}</span> v-for="(item, index) in items"
</button> :key="item.type"
ref="options"
type="button"
role="option"
:data-highlighted="index === highlighted || undefined"
:aria-selected="index === highlighted"
@mousedown.prevent="selectItem(item)"
@mousemove="highlighted = index"
>
<span class="slash-title">{{ item.title }}</span>
<span class="slash-group">{{ item.group }}</span>
</button>
</div>
<aside
v-if="active && hasPreview"
class="writekit-slash-preview"
data-writekit-slash-preview=""
aria-hidden="true"
>
<slot name="preview" :item="active">
<span class="slash-preview-title">{{ active.title }}</span>
<span class="slash-preview-text">{{ active.description }}</span>
</slot>
</aside>
</DismissableLayer> </DismissableLayer>
</PopperContent> </PopperContent>
</PopperRoot> </Portal>
</Portal> </PopperRoot>
</template> </template>
+2
View File
@@ -6,6 +6,7 @@ export interface SlashItem {
title: string; title: string;
group: string; group: string;
keywords: readonly string[]; keywords: readonly string[];
description?: string;
} }
/** /**
@@ -21,6 +22,7 @@ export function getSlashItems(registry: Registry, query = ''): SlashItem[] {
title: def.meta!.title, title: def.meta!.title,
group: def.meta!.group ?? 'blocks', group: def.meta!.group ?? 'blocks',
keywords: def.meta!.keywords ?? [], keywords: def.meta!.keywords ?? [],
...(def.meta!.description !== undefined && { description: def.meta!.description }),
})); }));
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();