1 Commits

Author SHA1 Message Date
Renovate Bot a523297241 chore(deps): update dependency eslint-plugin-unicorn to v73
CI / @robonen/crdt (pull_request) Failing after 1m5s
CI / @robonen/docs (pull_request) Failing after 1m33s
CI / @robonen/encoding (pull_request) Failing after 59s
CI / @robonen/eslint (pull_request) Failing after 1m0s
CI / @robonen/fetch (pull_request) Failing after 59s
CI / @robonen/platform (pull_request) Failing after 1m9s
CI / @robonen/primitives (pull_request) Failing after 1m7s
CI / @robonen/primitives-playground (pull_request) Failing after 58s
CI / @robonen/renovate (pull_request) Failing after 57s
CI / @robonen/stdlib (pull_request) Failing after 1m23s
CI / @robonen/stories (pull_request) Failing after 1m4s
CI / @robonen/tsconfig (pull_request) Failing after 59s
CI / @robonen/tsdown (pull_request) Failing after 1m0s
CI / @robonen/vue (pull_request) Failing after 1m19s
CI / @robonen/writekit (pull_request) Failing after 58s
CI / @robonen/writekit-playground (pull_request) Failing after 58s
CI / CI (pull_request) Failing after 5s
2026-08-04 04:04:12 +00:00
71 changed files with 225 additions and 1904 deletions
+1 -1
View File
@@ -52,7 +52,7 @@
"eslint-plugin-import-x": "^4.17.1", "eslint-plugin-import-x": "^4.17.1",
"eslint-plugin-n": "^18.2.2", "eslint-plugin-n": "^18.2.2",
"eslint-plugin-regexp": "^3.1.1", "eslint-plugin-regexp": "^3.1.1",
"eslint-plugin-unicorn": "^72.0.0", "eslint-plugin-unicorn": "^73.0.0",
"eslint-plugin-vue": "^10.10.0", "eslint-plugin-vue": "^10.10.0",
"globals": "^17.8.0", "globals": "^17.8.0",
"jiti": "^2.7.0", "jiti": "^2.7.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@robonen/tsconfig", "name": "@robonen/tsconfig",
"version": "0.1.1", "version": "0.1.0",
"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,7 +8,6 @@
"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
+1 -6
View File
@@ -89,12 +89,7 @@ const roleColor: Record<string, string> = {
<DocsEmitsTable :emits="part.emits" /> <DocsEmitsTable :emits="part.emits" />
</div> </div>
<div v-if="part.exposes?.length" class="mb-3"> <p v-if="part.props.length === 0 && part.emits.length === 0" class="text-sm text-fg-subtle italic">
<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,7 +12,6 @@ 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>
@@ -23,10 +22,6 @@ 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
@@ -1,34 +0,0 @@
<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>
+6 -152
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, ts } from 'ts-morph'; import { Node, Project, SyntaxKind } 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,144 +858,6 @@ 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*\)/);
@@ -1045,7 +907,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: `Emitted when \`v-model${name === 'modelValue' ? '' : `:${name}`}\` updates.` }); emits.push({ name: `update:${name}`, payload: `[value: ${type}]`, description: '' });
} }
return { props, emits }; return { props, emits };
@@ -1106,7 +968,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, sfcProject?: Project): ComponentMeta | null { function buildComponentAt(dir: string, slug: string, category: string, entryPoint: string): 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;
@@ -1139,22 +1001,16 @@ 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, exposes }); parts.push({ name, role, description, props, emits });
} }
return { return {
@@ -1174,7 +1030,6 @@ 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>/.
@@ -1193,14 +1048,13 @@ 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}`, sfcProject); const c = buildComponentAt(catDir, catEntry.name, 'Other', `./${catEntry.name}`);
if (c) components.push(c); if (c) components.push(c);
} }
} }
-5
View File
@@ -142,11 +142,6 @@ 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,11 +226,6 @@ 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;
} }
+5 -5
View File
@@ -99,8 +99,8 @@ importers:
specifier: ^3.1.1 specifier: ^3.1.1
version: 3.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) version: 3.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))
eslint-plugin-unicorn: eslint-plugin-unicorn:
specifier: ^72.0.0 specifier: ^73.0.0
version: 72.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) version: 73.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))
eslint-plugin-vue: eslint-plugin-vue:
specifier: ^10.10.0 specifier: ^10.10.0
version: 10.10.0(@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)))(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)) version: 10.10.0(@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)))(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript-native-bridge@6.0.3-bridge.7.tsgo.7.0.2))(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2))
@@ -4762,8 +4762,8 @@ packages:
peerDependencies: peerDependencies:
eslint: '>=9.38.0' eslint: '>=9.38.0'
eslint-plugin-unicorn@72.0.0: eslint-plugin-unicorn@73.0.0:
resolution: {integrity: sha512-hqO6ksoOHO+ZhdseTuKRVQbx9U7PRO/cv8qAR1mctwzdVO2hYud8uS9luAhp43RJgziYgHAph8eHyipT8GL0ng==} resolution: {integrity: sha512-V0YatLe9nkGhXEXKe2Qljb1EY0sJHwDV0HUF1NKFwtsHh/fU7qGHDgv+6fchzZcgU2/7noHo2gdjnmo0P2uDPw==}
engines: {node: '>=22'} engines: {node: '>=22'}
peerDependencies: peerDependencies:
eslint: '>=10.4' eslint: '>=10.4'
@@ -12783,7 +12783,7 @@ snapshots:
regexp-ast-analysis: 0.7.1 regexp-ast-analysis: 0.7.1
scslre: 0.3.0 scslre: 0.3.0
eslint-plugin-unicorn@72.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)): eslint-plugin-unicorn@73.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2)) '@eslint-community/eslint-utils': 4.9.1(eslint@10.8.0(jiti@2.7.0)(supports-color@10.2.2))
'@eslint/css-tree': 4.0.5 '@eslint/css-tree': 4.0.5
-191
View File
@@ -1,191 +0,0 @@
<!-- 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.6", "version": "0.0.2",
"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.6", "version": "0.0.2",
"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', zIndex: 0, color }" :style="{ position: 'absolute', inset: '0', width: '100%', height: '100%', pointerEvents: 'none', color }"
> >
<pattern <pattern
:id="patternId" :id="patternId"
+1 -17
View File
@@ -134,14 +134,13 @@ 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, edge?.label, markerStartRef, markerEndRef]" v-memo="[path[0], selected, edge?.animated, edge?.selectable, edge?.data, markerStartRef, markerEndRef]"
data-flow-edge="" data-flow-edge=""
:data-id="id" :data-id="id"
:data-type="resolvedType" :data-type="resolvedType"
@@ -177,21 +176,6 @@ 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>
+2 -13
View File
@@ -65,10 +65,8 @@ 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>
@@ -81,16 +79,7 @@ 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="{ :style="{ position: 'relative', overflow: 'hidden', touchAction: 'none' }"
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 />
+1 -3
View File
@@ -28,9 +28,7 @@ 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'];
// Above the viewport's explicit layer (zIndex 1): a positioned sibling const s: CSSProperties = { position: 'absolute', pointerEvents: 'all' };
// 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%';
+1 -76
View File
@@ -73,46 +73,26 @@ 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, getCurrentInstance, shallowRef, toRef, triggerRef, useSlots, watch } from 'vue'; import { computed, 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';
@@ -144,7 +124,6 @@ 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>();
@@ -156,7 +135,6 @@ 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) => {
@@ -166,7 +144,6 @@ 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) => {
@@ -176,7 +153,6 @@ 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) => {
@@ -192,7 +168,6 @@ 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);
@@ -355,7 +330,6 @@ 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 {
@@ -372,7 +346,6 @@ 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;
@@ -384,7 +357,6 @@ 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;
@@ -396,20 +368,17 @@ 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;
@@ -546,56 +515,12 @@ 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,9 +42,6 @@ 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,
@@ -1,191 +0,0 @@
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,9 +35,6 @@ 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
@@ -60,7 +57,6 @@ 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;
@@ -154,26 +150,6 @@ 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,9 +158,7 @@ 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;
// A double click on a node belongs to the node (nodeDoubleClick), not if (event.target instanceof Element && event.target.closest('.nopan')) return;
// 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,10 +121,6 @@ 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,9 +14,6 @@ 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[];
@@ -54,10 +51,6 @@ 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];
@@ -86,6 +79,8 @@ 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. */
+15 -25
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<Value extends TabsValue = TabsValue> extends PrimitiveProps { export interface TabsRootProps extends PrimitiveProps {
/** Controlled selected value. Bind with `v-model`. */ /** Controlled selected value. Bind with `v-model`. */
modelValue?: Value; modelValue?: TabsValue;
/** Uncontrolled initial value. */ /** Uncontrolled initial value. */
defaultValue?: Value; defaultValue?: TabsValue;
/** Orientation of the tab list. @default 'horizontal' */ /** Orientation of the tab list. @default 'horizontal' */
orientation?: 'horizontal' | 'vertical'; orientation?: 'horizontal' | 'vertical';
/** /**
@@ -40,14 +40,13 @@ export interface TabsRootProps<Value extends TabsValue = TabsValue> extends Prim
unmountOnHide?: boolean; unmountOnHide?: boolean;
} }
export interface TabsRootEmits<Value extends TabsValue = TabsValue> { export interface TabsRootEmits {
/** Fired when the selected value changes. */ /** Fired when the selected value changes. */
'update:modelValue': [value: Value]; 'update:modelValue': [value: TabsValue | undefined];
} }
</script> </script>
<script setup lang="ts" generic="Value extends TabsValue = TabsValue"> <script setup lang="ts">
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';
@@ -64,16 +63,15 @@ const {
activationMode = 'automatic', activationMode = 'automatic',
unmountOnHide = true, unmountOnHide = true,
defaultValue, defaultValue,
modelValue,
as = 'div', as = 'div',
} = defineProps<TabsRootProps<Value>>(); } = defineProps<TabsRootProps>();
const emit = defineEmits<TabsRootEmits<Value>>(); defineEmits<TabsRootEmits>();
defineSlots<{ defineSlots<{
default?: (props: { default?: (props: {
/** Current selected value. */ /** Current selected value. */
value: Value | undefined; value: TabsValue | undefined;
}) => unknown; }) => unknown;
}>(); }>();
@@ -81,24 +79,16 @@ const { forwardRef } = useForwardExpose();
const direction = useDirection(() => dir); const direction = useDirection(() => dir);
// `defineModel` would type `update:modelValue` as `TabsValue | undefined`, const localValue = ref<TabsValue | undefined>(defaultValue);
// 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 = computed<Value | undefined>({ const value = defineModel<TabsValue | undefined>({
get: () => modelValue ?? localValue.value, get: v => v ?? localValue.value,
set: (v) => { set: (v) => {
localValue.value = v; localValue.value = v;
if (v !== undefined) emit('update:modelValue', v); return 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>();
@@ -126,7 +116,7 @@ function unregisterContent(v: TabsValue): void {
function select(v: TabsValue): void { function select(v: TabsValue): void {
if (disabled) return; if (disabled) return;
contextValue.value = v; value.value = v;
} }
// DOM-order tabs via Collection primitive — survives `v-for` reorders and // DOM-order tabs via Collection primitive — survives `v-for` reorders and
@@ -171,7 +161,7 @@ function onTriggerKeyDown(event: KeyboardEvent, el: HTMLElement): void {
} }
provideTabsContext({ provideTabsContext({
value: contextValue, value,
// 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,11 +66,6 @@ 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];
@@ -111,6 +106,8 @@ const {
dateAdapter, dateAdapter,
} = defineProps<CalendarRootProps>(); } = defineProps<CalendarRootProps>();
defineEmits<CalendarRootEmits>();
defineSlots<{ defineSlots<{
default?: (props: { default?: (props: {
date: Date; date: Date;
@@ -40,11 +40,6 @@ 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];
@@ -100,6 +95,8 @@ 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,11 +37,6 @@ 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];
@@ -64,6 +59,8 @@ 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);
+2 -5
View File
@@ -43,11 +43,6 @@ 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];
@@ -76,6 +71,8 @@ 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>;
+3 -5
View File
@@ -4,11 +4,7 @@ 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];
@@ -62,6 +58,8 @@ 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,35 +4,7 @@ import { renderSlotChild } from './Slot';
type FunctionalComponentContext = Omit<SetupContext, 'expose'>; type FunctionalComponentContext = Omit<SetupContext, 'expose'>;
type Booleanish = boolean | 'true' | 'false'; export interface PrimitiveProps {
/**
* 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 PrimitiveAttributes, type PrimitiveProps } from './Primitive'; export { Primitive, type PrimitiveProps } from './Primitive';
export { Slot } from './Slot'; export { Slot } from './Slot';
@@ -38,11 +38,6 @@ 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];
} }
@@ -75,6 +70,8 @@ const {
as = 'nav', as = 'nav',
} = defineProps<NavigationMenuRootProps>(); } = defineProps<NavigationMenuRootProps>();
defineEmits<NavigationMenuRootEmits>();
defineSlots<{ defineSlots<{
default?: (props: { modelValue: string }) => unknown; default?: (props: { modelValue: string }) => unknown;
}>(); }>();
@@ -15,11 +15,6 @@ 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];
} }
@@ -40,6 +35,8 @@ 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,14 +44,6 @@ 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">
@@ -72,7 +64,7 @@ const {
as = 'div', as = 'div',
} = defineProps<ToolbarRootProps>(); } = defineProps<ToolbarRootProps>();
const emit = defineEmits<ToolbarRootOwnEmits>(); const emit = defineEmits<ToolbarRootEmits>();
const { forwardRef } = useForwardExpose(); const { forwardRef } = useForwardExpose();
@@ -28,11 +28,6 @@ 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();
@@ -62,7 +57,7 @@ onMounted(() => {
:present="present" :present="present"
> >
<SelectContentImpl <SelectContentImpl
v-bind="{ ...props, ...$attrs }" v-bind="props"
@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,11 +63,8 @@ const selectedItemTextRef = rootCtx.selectedItemTextRef;
const firstValidItemFoundRef = ref(false); const firstValidItemFoundRef = ref(false);
// Recompute the selected/first-valid item afresh for this open cycle. The text // Recompute the selected/first-valid item afresh for this open cycle.
// 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,46 +47,6 @@ 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;
@@ -94,61 +54,20 @@ 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 ?? itemTextOf(selectedItem); const selectedItemText = contentCtx.selectedItemTextRef.value;
if (!trigger || !wrapper || !content || !viewport) { if (!trigger || !valueNode || !wrapper || !content || !viewport || !selectedItem || !selectedItemText) {
emit('placed'); emit('placed');
return; return;
} }
// 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;
}
// --- Measure: every layout read lives here, before the first write ---
const triggerRect = trigger.getBoundingClientRect(); const triggerRect = trigger.getBoundingClientRect();
// --- Horizontal positioning ---
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;
@@ -156,9 +75,10 @@ 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));
placement.minWidth = `${minContentWidth}px`; wrapper.style.minWidth = `${minContentWidth}px`;
placement.left = `${clamp(left, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, rightEdge - contentWidth))}px`; wrapper.style.left = `${clampedLeft}px`;
} }
else { else {
const itemTextOffset = contentRect.right - itemTextRect.right; const itemTextOffset = contentRect.right - itemTextRect.right;
@@ -167,52 +87,67 @@ 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));
placement.minWidth = `${minContentWidth}px`; wrapper.style.minWidth = `${minContentWidth}px`;
placement.right = `${clamp(right, CONTENT_MARGIN, Math.max(CONTENT_MARGIN, leftEdge - contentWidth))}px`; wrapper.style.right = `${clampedRight}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 = selectedItemHeight / 2; const selectedItemHalfHeight = selectedItem.offsetHeight / 2;
const itemOffsetMiddle = selectedItemOffsetTop + selectedItemHalfHeight; const itemOffsetMiddle = selectedItem.offsetTop + selectedItemHalfHeight;
const contentTopToItemMiddle = contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle; const contentTopToItemMiddle = contentBorderTopWidth + contentPaddingTop + itemOffsetMiddle;
const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle; const itemMiddleToContentBottom = fullContentHeight - contentTopToItemMiddle;
let scrollTop: number | undefined; const willAlignWithoutTopOverflow = contentTopToItemMiddle <= topEdgeToTriggerMiddle;
if (contentTopToItemMiddle <= topEdgeToTriggerMiddle) { if (willAlignWithoutTopOverflow) {
const isLastItem = selectedItem === items.at(-1); const isLastItem = selectedItem === items.at(-1);
const viewportOffsetBottom = contentClientHeight - viewportOffsetTop - viewportOffsetHeight; wrapper.style.bottom = '0px';
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;
placement.bottom = '0px'; wrapper.style.height = `${height}px`;
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 + viewportOffsetTop + (isFirstItem ? viewportPaddingTop : 0) + selectedItemHalfHeight, contentBorderTopWidth + viewport.offsetTop + (isFirstItem ? viewportPaddingTop : 0) + selectedItemHalfHeight,
); );
const height = clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom;
placement.top = '0px'; wrapper.style.height = `${height}px`;
placement.height = `${clampedTopEdgeToTriggerMiddle + itemMiddleToContentBottom}px`; viewport.scrollTop = contentTopToItemMiddle - topEdgeToTriggerMiddle + viewport.offsetTop;
scrollTop = contentTopToItemMiddle - topEdgeToTriggerMiddle + viewportOffsetTop;
} }
placement.margin = `${CONTENT_MARGIN}px 0`; wrapper.style.margin = `${CONTENT_MARGIN}px 0`;
placement.minHeight = `${Math.min(selectedItemHeight * 5, fullContentHeight)}px`; wrapper.style.minHeight = `${minContentHeight}px`;
placement.maxHeight = `${availableHeight}px`; wrapper.style.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,12 +2,6 @@
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
@@ -22,9 +16,7 @@ export type SelectModelValue<T extends AcceptableValue, Multiple extends boolean
* (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, Multiple extends boolean = false> { export interface SelectRootProps<T extends AcceptableValue = AcceptableValue> {
/** 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. */
@@ -34,11 +26,11 @@ export interface SelectRootProps<T extends AcceptableValue = AcceptableValue, Mu
/** Native input name for form submission. */ /** Native input name for form submission. */
name?: string; name?: string;
/** Uncontrolled default value. */ /** Uncontrolled default value. */
defaultValue?: SelectModelValue<T, Multiple>; defaultValue?: T | T[];
/** 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?: Multiple; multiple?: boolean;
/** /**
* 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.
@@ -48,20 +40,13 @@ export interface SelectRootProps<T extends AcceptableValue = AcceptableValue, Mu
autocomplete?: string; autocomplete?: string;
} }
export interface SelectRootEmits<T extends AcceptableValue = AcceptableValue, Multiple extends boolean = false> { export interface SelectRootEmits<T extends AcceptableValue = AcceptableValue> {
'update:modelValue': [value: SelectModelValue<T, Multiple>]; 'update:modelValue': [value: T | T[] | undefined];
'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, Multiple extends boolean = false"> <script setup lang="ts" generic="T extends AcceptableValue = AcceptableValue">
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';
@@ -75,7 +60,6 @@ import { compare, shouldShowPlaceholder } from './utils';
defineOptions({ inheritAttrs: false }); defineOptions({ inheritAttrs: false });
const { const {
modelValue,
dir, dir,
disabled = false, disabled = false,
required = false, required = false,
@@ -85,13 +69,11 @@ const {
multiple = false, multiple = false,
by, by,
autocomplete, autocomplete,
} = defineProps<SelectRootProps<T, Multiple>>(); } = defineProps<SelectRootProps<T>>();
const emit = defineEmits<SelectRootOwnEmits<T, Multiple>>();
defineSlots<{ defineSlots<{
default?: (props: { default?: (props: {
modelValue: SelectModelValue<T, Multiple> | undefined; modelValue: T | T[] | undefined;
open: boolean; open: boolean;
}) => unknown; }) => unknown;
}>(); }>();
@@ -106,26 +88,16 @@ const open = defineModel<boolean>('open', {
}, },
}); });
type ModelValue = SelectModelValue<T, Multiple>; const localValue = ref<T | T[] | undefined>(defaultValue ?? (multiple ? ([] as T[]) : undefined)) as Ref<T | T[] | undefined>;
const value = defineModel<T | T[] | undefined>('modelValue', {
// `defineModel` would type `update:modelValue` as `ModelValue | undefined`, default: undefined,
// forcing every consumer's `v-model` target to accept `undefined` even though get: v => (v ?? localValue.value),
// 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;
emit('update:modelValue', v as ModelValue); return v;
}, },
}); });
// 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);
@@ -147,7 +119,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(model.value)); const isEmptyModelValue = computed(() => shouldShowPlaceholder(value.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) {
@@ -171,8 +143,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, model], () => { watch([optionsSet, value], () => {
const current = model.value; const current = value.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;
@@ -180,21 +152,21 @@ watch([optionsSet, model], () => {
function handleValueChange(newValue: AcceptableValue) { function handleValueChange(newValue: AcceptableValue) {
if (multiple) { if (multiple) {
const array = Array.isArray(model.value) ? [...model.value] : []; const array = Array.isArray(value.value) ? [...value.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);
model.value = [...array] as T[]; value.value = [...array] as T[];
} }
else { else {
model.value = newValue as T; value.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 = model.value; const current = value.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) {
@@ -225,7 +197,7 @@ const isFormControl = computed(() => {
}); });
provideSelectRootContext({ provideSelectRootContext({
value: model, value,
onValueChange: handleValueChange, onValueChange: handleValueChange,
open, open,
onOpenChange: (v) => { open.value = v; }, onOpenChange: (v) => { open.value = v; },
@@ -265,7 +237,7 @@ provideSelectRootContext({
:disabled="disabled" :disabled="disabled"
:multiple="multiple" :multiple="multiple"
:options="nativeOptions" :options="nativeOptions"
:value="model" :value="value"
@change="handleValueChange" @change="handleValueChange"
/> />
@@ -273,7 +245,7 @@ provideSelectRootContext({
v-else-if="name" v-else-if="name"
type="hidden" type="hidden"
:name="name" :name="name"
:value="Array.isArray(model) ? '' : (model ?? '')" :value="Array.isArray(value) ? '' : (value ?? '')"
: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, useStyleTag } from '@robonen/vue'; import { useForwardExpose } 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, VIEWPORT_SCROLLBAR_CSS } from './utils'; import { CONTENT_MARGIN } from './utils';
const { as = 'div', nonce: propNonce } = defineProps<SelectViewportProps>(); const { as = 'div', nonce: propNonce } = defineProps<SelectViewportProps>();
@@ -32,11 +32,6 @@ 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;
@@ -87,4 +82,8 @@ 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,133 +385,3 @@ 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,6 +4,13 @@ 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;
+2 -1
View File
@@ -29,6 +29,7 @@ export {
} from './context'; } from './context';
export type { export type {
SelectValue,
SelectOption, SelectOption,
SelectRootContext, SelectRootContext,
SelectContentContext, SelectContentContext,
@@ -37,7 +38,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 { SelectModelValue, SelectRootProps, SelectRootEmits } from './SelectRoot.vue'; export type { 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,11 +9,6 @@ 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,14 +45,6 @@ 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>;
@@ -85,7 +77,7 @@ const {
as = 'div', as = 'div',
} = defineProps<RovingFocusGroupProps>(); } = defineProps<RovingFocusGroupProps>();
const emit = defineEmits<RovingFocusGroupOwnEmits>(); const emit = defineEmits<RovingFocusGroupEmits>();
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.4", "version": "0.0.1",
"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', description: 'Set a passage apart from the narration.' }, meta: { title: 'Quote', icon: 'quote', keywords: ['quote', 'blockquote', 'citation'], group: 'basic' },
}); });
+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', description: 'A highlighted note the eye cannot miss.' }, meta: { title: 'Callout', icon: 'info', keywords: ['callout', 'note', 'info', 'warning'], group: 'basic' },
}); });
+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', description: 'Verbatim monospaced text; Enter stays inside.' }, meta: { title: 'Code block', icon: 'code', keywords: ['code', 'pre', 'snippet'], group: 'basic' },
}); });
+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', description: 'A horizontal rule between sections.' }, meta: { title: 'Divider', icon: 'minus', keywords: ['divider', 'hr', 'rule', 'separator'], group: 'media' },
}); });
+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', description: 'A section title, levels 16.' }, meta: { title: 'Heading', icon: 'heading', keywords: ['heading', 'title', 'h1', 'h2', 'h3'], group: 'basic' },
}); });
+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', description: 'An image with an optional caption.' }, meta: { title: 'Image', icon: 'image', keywords: ['image', 'img', 'picture', 'photo'], group: 'media' },
}); });
+5 -11
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[]; description?: string }) { function defineListBlock(options: { type: string; listType: ListType; title: string; keywords: readonly string[] }) {
const todo = options.listType === 'todo'; const todo = options.listType === 'todo';
const attrs: AttrsSpec = { const attrs: AttrsSpec = {
@@ -43,16 +43,10 @@ function defineListBlock(options: { type: string; listType: ListType; title: str
parseDOM: [{ tag: `[data-list='${options.listType}']` }], parseDOM: [{ tag: `[data-list='${options.listType}']` }],
}, },
inputRules, inputRules,
meta: { meta: { title: options.title, icon: 'list', keywords: options.keywords, group: 'lists' },
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'], description: 'Items marked with bullets; Tab indents.' }); export const bulletedList = defineListBlock({ type: 'bulleted-list', listType: 'bullet', title: 'Bulleted list', keywords: ['ul', 'bullet', 'unordered', '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 numberedList = defineListBlock({ type: 'numbered-list', listType: 'ordered', title: 'Numbered list', keywords: ['ol', 'number', 'ordered', 'list'] });
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.' }); export const todoList = defineListBlock({ type: 'todo-list', listType: 'todo', title: 'To-do list', keywords: ['todo', 'task', 'checkbox', 'check'] });
+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', description: 'Plain prose — the default block.' }, meta: { title: 'Paragraph', icon: 'text', keywords: ['paragraph', 'text', 'p'], group: 'basic' },
}); });
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { caret, createDoc, createNode, nodeInline, nodeSelection, nodeText, textSelection } from '../../model'; import { caret, createDoc, createNode, nodeInline, nodeText, textSelection } from '../../model';
import { createDefaultRegistry } from '../../preset'; import { createDefaultRegistry } from '../../preset';
import { createWritekit, createWritekitState } from '../../state'; import { createWritekit, createWritekitState } from '../../state';
import { exitAtom, joinBackward, splitBlock, toggleMark } from '..'; import { 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,31 +44,6 @@ 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,7 +2,6 @@ import type { Attrs, Node } from '../model';
import { import {
blockById, blockById,
caret, caret,
createNode,
inlineLength, inlineLength,
isAcrossBlocks, isAcrossBlocks,
isCollapsed, isCollapsed,
@@ -89,36 +88,6 @@ 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;
+1 -3
View File
@@ -1,7 +1,6 @@
import { import {
chainCommands, chainCommands,
deleteSelection, deleteSelection,
exitAtom,
indentListItem, indentListItem,
insertHardBreak, insertHardBreak,
joinBackward, joinBackward,
@@ -37,8 +36,7 @@ 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,
// With an atom selected, Enter starts a paragraph below it; in text it splits. Enter: splitBlock,
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),
+1 -11
View File
@@ -4,9 +4,6 @@ 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`). */
@@ -25,8 +22,6 @@ 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. */
@@ -41,16 +36,11 @@ 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 | BlockComponentLoader; readonly component?: Component;
readonly meta?: BlockMeta; readonly meta?: BlockMeta;
readonly behavior?: BlockBehavior; readonly behavior?: BlockBehavior;
readonly commands?: Record<string, CommandFactory>; readonly commands?: Record<string, CommandFactory>;
@@ -1,76 +0,0 @@
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' });
});
});
+7 -41
View File
@@ -14,61 +14,27 @@ 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, run `validate`, keep unknown keys for a block type. */ /** Fill defaults and drop 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, run `validate`, keep unknown keys for a mark type. */ /** Fill defaults and drop 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 attrs ? { ...attrs } : {}; return {};
}
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)
if (attr.validate && !attr.validate(provided)) {
if (__DEV__)
console.warn(`[writekit] Attr "${key}" rejected by validate(); falling back to its default.`, provided);
if (attr.default !== undefined)
result[key] = attr.default;
}
else {
result[key] = provided; result[key] = provided;
} else if (spec[key]!.default !== undefined)
} result[key] = spec[key]!.default!;
else if (attr.default !== undefined) {
result[key] = attr.default;
}
} }
return result; return result;
@@ -1,132 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { HistoryEntry } from '../history';
import type { Step } from '../step';
import { createHistory } from '../history';
const caret = { type: 'text', anchor: { blockId: 'b1', offset: 0 }, focus: { blockId: 'b1', offset: 0 } } as never;
function typing(blockId: string, text: string): HistoryEntry {
return {
steps: [{ type: 'insertInline', blockId, offset: 0, content: [{ text, marks: [] }] } as Step],
inverted: [{ type: 'deleteText', blockId, from: 0, to: text.length } as Step],
selectionBefore: caret,
selectionAfter: caret,
};
}
function structural(blockId: string): HistoryEntry {
return {
steps: [{ type: 'removeBlock', blockId } as Step],
inverted: [{ type: 'insertBlock', node: { id: blockId, type: 'paragraph', attrs: {}, content: [] }, index: 0 } as never],
selectionBefore: caret,
selectionAfter: caret,
};
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe('history coalescing', () => {
it('merges a typing burst in one block into one undo press', () => {
const history = createHistory();
for (const ch of ['h', 'e', 'l', 'l', 'o']) {
history.record(typing('b1', ch));
vi.advanceTimersByTime(100);
}
const entry = history.undo()!;
expect(entry.steps).toHaveLength(5);
expect(history.canUndo()).toBe(false);
});
it('keeps the replay order: later keystrokes undo first', () => {
const history = createHistory();
history.record(typing('b1', 'a'));
history.record(typing('b1', 'b'));
const entry = history.undo()!;
// `inverted` stays in application order; undo replays it reversed, so the
// inverse of "b" must sit AFTER the inverse of "a".
expect(entry.inverted.map(step => (step as { to: number }).to)).toEqual([1, 1]);
expect(entry.steps.map(step => (step as { content: Array<{ text: string }> }).content[0]!.text)).toEqual(['a', 'b']);
});
it('starts a new group after the time window', () => {
const history = createHistory({ coalesceMs: 500 });
history.record(typing('b1', 'a'));
vi.advanceTimersByTime(600);
history.record(typing('b1', 'b'));
history.undo();
expect(history.canUndo()).toBe(true);
});
it('never merges across blocks', () => {
const history = createHistory();
history.record(typing('b1', 'a'));
history.record(typing('b2', 'b'));
history.undo();
expect(history.canUndo()).toBe(true);
});
it('never merges structural changes', () => {
const history = createHistory();
history.record(typing('b1', 'a'));
history.record(structural('b1'));
history.record(typing('b1', 'b'));
expect(history.undo()!.steps).toHaveLength(1);
expect(history.undo()!.steps).toHaveLength(1);
expect(history.undo()!.steps).toHaveLength(1);
});
it('breaks the chain on interrupt — a foreign transaction is a boundary', () => {
// A remote setDoc or an undo between keystrokes must not be spliced into
// one undo press with them.
const history = createHistory();
history.record(typing('b1', 'a'));
history.interrupt();
history.record(typing('b1', 'b'));
history.undo();
expect(history.canUndo()).toBe(true);
});
it('counts groups, not keystrokes, against maxSize', () => {
const history = createHistory({ maxSize: 2 });
// Two bursts of three keystrokes: two groups — both must survive.
for (const ch of ['a', 'b', 'c'])
history.record(typing('b1', ch));
vi.advanceTimersByTime(1000);
for (const ch of ['d', 'e', 'f'])
history.record(typing('b1', ch));
expect(history.undo()!.steps).toHaveLength(3);
expect(history.undo()!.steps).toHaveLength(3);
expect(history.canUndo()).toBe(false);
});
it('can be disabled outright', () => {
const history = createHistory({ coalesceMs: 0 });
history.record(typing('b1', 'a'));
history.record(typing('b1', 'b'));
history.undo();
expect(history.canUndo()).toBe(true);
});
});
-73
View File
@@ -15,14 +15,6 @@ 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;
} }
/** /**
@@ -34,13 +26,6 @@ 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). */
@@ -50,75 +35,18 @@ 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) {
const now = Date.now();
const block = typingBlockOf(entry.steps);
const top = undoStack[undoStack.length - 1];
const mergeable
= coalesceMs > 0
&& top !== undefined
&& block !== null
&& block === lastTypingBlock
&& now - lastRecordAt <= coalesceMs;
if (mergeable) {
undoStack[undoStack.length - 1] = coalesce(top, entry);
}
else {
undoStack.push(entry); undoStack.push(entry);
if (undoStack.length > maxSize) if (undoStack.length > maxSize)
undoStack.shift(); 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)
@@ -136,7 +64,6 @@ 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,11 +63,6 @@ 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)
+3 -27
View File
@@ -3,9 +3,8 @@ import type { Attrs, Node } from '../model';
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import type { Component, IntrinsicElementAttributes } from 'vue'; import type { IntrinsicElementAttributes } from 'vue';
import type { BlockDefinition } from '../registry'; import { computed } from 'vue';
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';
@@ -22,30 +21,7 @@ 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);
+2 -52
View File
@@ -3,8 +3,8 @@ import type { PrimitiveProps } from './primitive';
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import { blockById, caret, createNode, inlineLength, isCollapsed, nodeInline } from '../model'; import { blockById, caret, inlineLength, isCollapsed, nodeInline } from '../model';
import { applyInputRule, deleteSelection, exitAtom, insertHardBreak, joinBackward, joinForward, splitBlock } from '../commands'; import { applyInputRule, deleteSelection, 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,20 +35,6 @@ 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;
@@ -131,41 +117,6 @@ 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;
@@ -192,7 +143,6 @@ 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,97 +134,3 @@ 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 { unrefElement, useContextFactory, useEventListener } from '@robonen/vue'; export { useContextFactory, useEventListener } from '@robonen/vue';
@@ -151,25 +151,10 @@ export function createSelectionBridge(
return; return;
if (selection.kind === 'node') { if (selection.kind === 'node') {
// The node selection must exist in the DOM too, as a range around the // Block-level selection has no native text range; the visual highlight
// block element. Merely clearing the ranges left a focused editable with // comes from [data-selected] on the block wrapper. Keep the editable root
// no selection — the browser then invents a caret at the START of the // focused so keyboard commands (Backspace/Delete on the node) still reach it.
// 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, shallowRef } from 'vue'; import { onBeforeUnmount, ref } 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 = shallowRef<{ getBoundingClientRect: () => DOMRect } | undefined>(); const reference = ref<{ getBoundingClientRect: () => DOMRect } | undefined>();
const open = ref(false); const open = ref(false);
const rev = ref(0); const rev = ref(0);
@@ -54,11 +54,8 @@ function toggle(type: string): void {
</script> </script>
<template> <template>
<!-- Combobox layering: PopperRoot provides the positioning context outside <Portal to="body">
the portal. The bare Portal resolves its target from the ConfigProvider's
teleportTarget (body unless the app overrides it). -->
<PopperRoot> <PopperRoot>
<Portal>
<PopperContent <PopperContent
v-if="open && reference" v-if="open && reference"
:reference="reference" :reference="reference"
@@ -86,6 +83,6 @@ function toggle(type: string): void {
</slot> </slot>
</DismissableLayer> </DismissableLayer>
</PopperContent> </PopperContent>
</Portal>
</PopperRoot> </PopperRoot>
</Portal>
</template> </template>
+24 -93
View File
@@ -1,27 +1,10 @@
<script lang="ts">
/** Regexp-special characters, escaped when the trigger is interpolated. */
const ESCAPE_RE = /[.*+?^${}()|[\]\\]/g;
/** The caret's client rect, when the native selection has a visible one. */
function caretRect(): DOMRect | null {
const selection = globalThis.window === undefined ? null : globalThis.getSelection();
if (!selection || selection.rangeCount === 0)
return null;
const range = selection.getRangeAt(0);
const rects = range.getClientRects();
const rect = rects.length > 0 ? rects[0]! : range.getBoundingClientRect();
return rect.width || rect.height ? rect : null;
}
</script>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useTemplateRef, watch } from 'vue'; import { onBeforeUnmount, ref } from 'vue';
import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives'; import { DismissableLayer, PopperContent, PopperRoot, Portal } from '@robonen/primitives';
import { blockById, caret, createNode, inlineText, isCollapsed, nodeInline, nodeSelection } from '../../model'; import { blockById, caret, createNode, inlineText, isCollapsed, nodeInline, nodeSelection } from '../../model';
import { createTransaction } from '../../state'; import { createTransaction } from '../../state';
import { useWritekitContext } from '../context'; import { useWritekitContext } from '../context';
import { unrefElement, useEventListener } from '../composables'; import { useEventListener } from '../composables';
import type { SlashItem } from './slash-items'; import type { SlashItem } from './slash-items';
import { getSlashItems } from './slash-items'; import { getSlashItems } from './slash-items';
@@ -32,42 +15,35 @@ export interface WritekitSlashMenuProps {
const { trigger = '/' } = defineProps<WritekitSlashMenuProps>(); 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 ctx = useWritekitContext();
const open = ref(false); const open = ref(false);
const items = shallowRef<SlashItem[]>([]); const items = ref<SlashItem[]>([]);
const highlighted = ref(0); const highlighted = ref(0);
// Virtual reference (a `Measurable`) anchored to the caret rect; Popper positions // Virtual reference (a `Measurable`) anchored to the caret rect; Popper positions
// against it with no trigger element. Focus stays in the contenteditable (so the // 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 // 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. // below and the highlight is an index not roving focus / listbox focus.
const reference = shallowRef<{ getBoundingClientRect: () => DOMRect } | undefined>(); const reference = ref<{ 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 triggerBlockId = '';
let triggerStart = 0; let triggerStart = 0;
let caretOffset = 0; let caretOffset = 0;
function escapeRegExp(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function caretRect(): DOMRect | null {
const selection = globalThis.window === undefined ? null : globalThis.getSelection();
if (!selection || selection.rangeCount === 0)
return null;
const range = selection.getRangeAt(0);
const rects = range.getClientRects();
const rect = rects.length > 0 ? rects[0]! : range.getBoundingClientRect();
return rect.width || rect.height ? rect : null;
}
function close(): void { function close(): void {
open.value = false; open.value = false;
} }
@@ -89,7 +65,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 = matcher.value.exec(before); const match = new RegExp(`(?:^|\\s)${escapeRegExp(trigger)}([\\p{L}\\p{N}]*)$`, 'u').exec(before);
if (!match) { if (!match) {
close(); close();
@@ -173,41 +149,15 @@ 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>
<!-- Combobox layering: PopperRoot provides the positioning context outside <Portal to="body">
the portal. The bare Portal resolves its target from the ConfigProvider's
teleportTarget (body unless the app overrides it). -->
<PopperRoot> <PopperRoot>
<Portal>
<PopperContent <PopperContent
v-if="open && reference" v-if="open && reference"
:reference="reference" :reference="reference"
@@ -217,21 +167,15 @@ onBeforeUnmount(() => ctx.writekit.off('transaction', refresh));
:collision-padding="8" :collision-padding="8"
> >
<DismissableLayer <DismissableLayer
ref="layer"
class="writekit-slash"
data-writekit-slash=""
@dismiss="close"
@focus-outside.prevent
>
<div
class="writekit-slash-menu" class="writekit-slash-menu"
role="listbox" role="listbox"
data-writekit-slash-menu="" data-writekit-slash-menu=""
@dismiss="close"
@focus-outside.prevent
> >
<button <button
v-for="(item, index) in items" v-for="(item, index) in items"
:key="item.type" :key="item.type"
ref="options"
type="button" type="button"
role="option" role="option"
:data-highlighted="index === highlighted || undefined" :data-highlighted="index === highlighted || undefined"
@@ -242,21 +186,8 @@ onBeforeUnmount(() => ctx.writekit.off('transaction', refresh));
<span class="slash-title">{{ item.title }}</span> <span class="slash-title">{{ item.title }}</span>
<span class="slash-group">{{ item.group }}</span> <span class="slash-group">{{ item.group }}</span>
</button> </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>
</Portal>
</PopperRoot> </PopperRoot>
</Portal>
</template> </template>
-2
View File
@@ -6,7 +6,6 @@ export interface SlashItem {
title: string; title: string;
group: string; group: string;
keywords: readonly string[]; keywords: readonly string[];
description?: string;
} }
/** /**
@@ -22,7 +21,6 @@ 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();