frappe-ui 1.0.0-beta.66 → 1.0.0-beta.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "1.0.0-beta.66",
3
+ "version": "1.0.0-beta.67",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "engines": {
6
6
  "node": ">=20.19.0"
@@ -21,6 +21,7 @@
21
21
  "bin": {
22
22
  "tokens-v2": "./tailwind/migrate-tokens-v2.js",
23
23
  "shortcuts-v1": "./scripts/migrate-shortcuts-v1.js",
24
+ "editor-v1": "./scripts/migrate-editor-v1.js",
24
25
  "destinations-v1": "./scripts/migrate-destinations-v1.js"
25
26
  },
26
27
  "scripts": {
@@ -103,6 +104,7 @@
103
104
  "tailwind",
104
105
  "vitepress",
105
106
  "scripts/migrate-shortcuts-v1.js",
107
+ "scripts/migrate-editor-v1.js",
106
108
  "experimental",
107
109
  "experimental.ts",
108
110
  "tsconfig.base.json"
@@ -0,0 +1,477 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Editor v1 migration codemod.
4
+ *
5
+ * Renames the public EditorFixedMenu `buttonSize` prop to `size` in Vue
6
+ * templates. Suggestion `component` keys deliberately stay manual because the
7
+ * replacement is `nodeView` or `listComponent` depending on the config shape.
8
+ *
9
+ * Usage: editor-v1 [--dry-run] <dir-or-file...>
10
+ */
11
+
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import ts from 'typescript'
16
+
17
+ const USAGE = 'Usage: editor-v1 [--dry-run] <dir-or-file...>'
18
+ const OPTIONS = new Set(['--dry-run', '--help', '-h'])
19
+ const VUE_EXTENSIONS = new Set(['.vue'])
20
+ const SKIP_DIRS = new Set([
21
+ 'node_modules',
22
+ '.git',
23
+ 'dist',
24
+ 'build',
25
+ 'cache',
26
+ 'coverage',
27
+ ])
28
+
29
+ function kebabCase(name) {
30
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
31
+ }
32
+
33
+ function editorFixedMenuBindings(source) {
34
+ const bindings = new Set()
35
+ const importPattern =
36
+ /import\s*\{([^}]*)\}\s*from\s*['"]frappe-ui\/editor['"]/g
37
+ for (const match of source.matchAll(importPattern)) {
38
+ for (const specifier of match[1].split(',')) {
39
+ const parts = specifier
40
+ .trim()
41
+ .match(/^EditorFixedMenu(?:\s+as\s+([A-Za-z_$][\w$]*))?$/)
42
+ if (parts) bindings.add(parts[1] || 'EditorFixedMenu')
43
+ }
44
+ }
45
+ return bindings
46
+ }
47
+
48
+ function lineAt(source, offset) {
49
+ return source.slice(0, offset).split('\n').length
50
+ }
51
+
52
+ function tagEnd(source, start) {
53
+ let quote
54
+ for (let index = start; index < source.length; index++) {
55
+ const char = source[index]
56
+ if (quote) {
57
+ if (char === quote) quote = undefined
58
+ } else if (char === '"' || char === "'") {
59
+ quote = char
60
+ } else if (char === '>') {
61
+ return index + 1
62
+ }
63
+ }
64
+ return source.length
65
+ }
66
+
67
+ function expressionEnd(source, start) {
68
+ let quote
69
+ let escaped = false
70
+ for (let index = start; index < source.length - 1; index++) {
71
+ const char = source[index]
72
+ if (quote) {
73
+ if (escaped) escaped = false
74
+ else if (char === '\\') escaped = true
75
+ else if (char === quote) quote = undefined
76
+ } else if (char === '"' || char === "'" || char === '`') {
77
+ quote = char
78
+ } else if (char === '}' && source[index + 1] === '}') {
79
+ return index + 2
80
+ }
81
+ }
82
+ return source.length
83
+ }
84
+
85
+ function templateContentRange(source) {
86
+ let index = 0
87
+ while (index < source.length) {
88
+ const start = source.indexOf('<', index)
89
+ if (start === -1) return
90
+ if (source.startsWith('<!--', start)) {
91
+ const end = source.indexOf('-->', start + 4)
92
+ index = end === -1 ? source.length : end + 3
93
+ continue
94
+ }
95
+
96
+ const opening = source.slice(start).match(/^<([A-Za-z][\w.-]*)\b/)
97
+ if (!opening) {
98
+ index = start + 1
99
+ continue
100
+ }
101
+ const name = opening[1]
102
+ const contentStart = tagEnd(source, start)
103
+ if (name !== 'template') {
104
+ const closing = new RegExp(`</${name}\\s*>`, 'i').exec(
105
+ source.slice(contentStart),
106
+ )
107
+ index = closing
108
+ ? contentStart + closing.index + closing[0].length
109
+ : contentStart
110
+ continue
111
+ }
112
+
113
+ let depth = 1
114
+ index = contentStart
115
+ while (index < source.length) {
116
+ if (source.startsWith('<!--', index)) {
117
+ const end = source.indexOf('-->', index + 4)
118
+ index = end === -1 ? source.length : end + 3
119
+ continue
120
+ }
121
+ if (source.startsWith('{{', index)) {
122
+ index = expressionEnd(source, index + 2)
123
+ continue
124
+ }
125
+ if (source[index] !== '<') {
126
+ index++
127
+ continue
128
+ }
129
+ const tag = source.slice(index).match(/^<(\/?)([A-Za-z][\w.-]*)\b/i)
130
+ if (!tag) {
131
+ index++
132
+ continue
133
+ }
134
+ const end = tagEnd(source, index)
135
+ // Skip the whole tag so a literal `<template>` inside an attribute
136
+ // value is not mistaken for a nested template boundary.
137
+ if (tag[2].toLowerCase() === 'template') {
138
+ if (tag[1]) depth--
139
+ else if (!/\/\s*>$/.test(source.slice(index, end))) depth++
140
+ if (depth === 0) return { start: contentStart, end: index }
141
+ }
142
+ index = end
143
+ }
144
+ return { start: contentStart, end: source.length }
145
+ }
146
+ }
147
+
148
+ function attributesIn(tag, tagNameLength) {
149
+ const attributes = []
150
+ let index = 1 + tagNameLength
151
+ while (index < tag.length - 1) {
152
+ while (/\s/.test(tag[index])) index++
153
+ if (tag[index] === '/' || tag[index] === '>' || index >= tag.length - 1)
154
+ break
155
+
156
+ const start = index
157
+ while (index < tag.length && !/[\s=/>]/.test(tag[index])) index++
158
+ const nameEnd = index
159
+ while (/\s/.test(tag[index])) index++
160
+
161
+ let value
162
+ if (tag[index] === '=') {
163
+ index++
164
+ while (/\s/.test(tag[index])) index++
165
+ const quote = tag[index]
166
+ if (quote === '"' || quote === "'") {
167
+ const valueStart = ++index
168
+ while (index < tag.length && tag[index] !== quote) index++
169
+ value = tag.slice(valueStart, index)
170
+ if (index < tag.length) index++
171
+ } else {
172
+ const valueStart = index
173
+ while (index < tag.length && !/[\s>]/.test(tag[index])) index++
174
+ value = tag.slice(valueStart, index)
175
+ }
176
+ }
177
+ attributes.push({ start, nameEnd, name: tag.slice(start, nameEnd), value })
178
+ }
179
+ return attributes
180
+ }
181
+
182
+ function propertyNameText(name) {
183
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text
184
+ if (ts.isComputedPropertyName(name) && ts.isStringLiteral(name.expression)) {
185
+ return name.expression.text
186
+ }
187
+ }
188
+
189
+ function mergeObjectBindAnalysis(left, right) {
190
+ return {
191
+ hasLegacy: left.hasLegacy || right.hasLegacy,
192
+ hasSize: left.hasSize || right.hasSize,
193
+ opaque: left.opaque || right.opaque,
194
+ }
195
+ }
196
+
197
+ function analyzeObjectBindExpression(expression) {
198
+ while (
199
+ ts.isParenthesizedExpression(expression) ||
200
+ ts.isAsExpression(expression) ||
201
+ ts.isTypeAssertionExpression(expression) ||
202
+ ts.isSatisfiesExpression(expression)
203
+ ) {
204
+ expression = expression.expression
205
+ }
206
+
207
+ if (ts.isConditionalExpression(expression)) {
208
+ return mergeObjectBindAnalysis(
209
+ analyzeObjectBindExpression(expression.whenTrue),
210
+ analyzeObjectBindExpression(expression.whenFalse),
211
+ )
212
+ }
213
+ if (
214
+ ts.isBinaryExpression(expression) &&
215
+ [
216
+ ts.SyntaxKind.BarBarToken,
217
+ ts.SyntaxKind.QuestionQuestionToken,
218
+ ts.SyntaxKind.AmpersandAmpersandToken,
219
+ ].includes(expression.operatorToken.kind)
220
+ ) {
221
+ return mergeObjectBindAnalysis(
222
+ analyzeObjectBindExpression(expression.left),
223
+ analyzeObjectBindExpression(expression.right),
224
+ )
225
+ }
226
+ if (!ts.isObjectLiteralExpression(expression)) {
227
+ return { hasLegacy: false, hasSize: false, opaque: true }
228
+ }
229
+
230
+ return expression.properties.reduce(
231
+ (analysis, property) => {
232
+ if (ts.isSpreadAssignment(property)) {
233
+ return mergeObjectBindAnalysis(
234
+ analysis,
235
+ analyzeObjectBindExpression(property.expression),
236
+ )
237
+ }
238
+ if (!property.name) return analysis
239
+ if (
240
+ ts.isComputedPropertyName(property.name) &&
241
+ !ts.isStringLiteral(property.name.expression)
242
+ ) {
243
+ return { ...analysis, opaque: true }
244
+ }
245
+ const name = propertyNameText(property.name)
246
+ return {
247
+ hasLegacy:
248
+ analysis.hasLegacy || name === 'buttonSize' || name === 'button-size',
249
+ hasSize: analysis.hasSize || name === 'size',
250
+ opaque: analysis.opaque,
251
+ }
252
+ },
253
+ { hasLegacy: false, hasSize: false, opaque: false },
254
+ )
255
+ }
256
+
257
+ function analyzeObjectBind(value) {
258
+ const source = ts.createSourceFile(
259
+ 'editor-v1-bind.ts',
260
+ `const bound = (${value})`,
261
+ ts.ScriptTarget.Latest,
262
+ true,
263
+ ts.ScriptKind.TS,
264
+ )
265
+ const statement = source.statements[0]
266
+ const initializer =
267
+ statement &&
268
+ ts.isVariableStatement(statement) &&
269
+ statement.declarationList.declarations[0]?.initializer
270
+ if (!initializer || source.parseDiagnostics.length) {
271
+ return { hasLegacy: false, hasSize: false, opaque: true }
272
+ }
273
+ return analyzeObjectBindExpression(initializer)
274
+ }
275
+
276
+ function rewriteTag(tag, tagName, filename, offset, source) {
277
+ const edits = []
278
+ const refusals = []
279
+ const attributes = attributesIn(tag, tagName.length)
280
+ const objectBinds = attributes
281
+ .filter((attribute) => attribute.name === 'v-bind' && attribute.value)
282
+ .map((attribute) => ({
283
+ attribute,
284
+ analysis: analyzeObjectBind(attribute.value),
285
+ }))
286
+ const legacyAttributes = attributes.filter((attribute) =>
287
+ /^(?:v-bind:|:)?(?:button-size|buttonSize)$/.test(attribute.name),
288
+ )
289
+ const sizeAttributes = attributes.filter((attribute) =>
290
+ /^(?:v-bind:|:)?size$/.test(attribute.name),
291
+ )
292
+
293
+ if (
294
+ legacyAttributes.length &&
295
+ (legacyAttributes.length + sizeAttributes.length > 1 ||
296
+ objectBinds.some(({ analysis }) => analysis.hasSize))
297
+ ) {
298
+ refusals.push({
299
+ file: filename,
300
+ line: lineAt(source, offset + legacyAttributes[0].start),
301
+ message:
302
+ 'EditorFixedMenu carries both `buttonSize` and `size` values. Renaming would produce duplicate props; merge them manually. This file is unchanged.',
303
+ })
304
+ }
305
+
306
+ for (const { attribute, analysis } of objectBinds) {
307
+ if (analysis.hasLegacy) {
308
+ refusals.push({
309
+ file: filename,
310
+ line: lineAt(source, offset + attribute.start),
311
+ message:
312
+ 'EditorFixedMenu uses an object v-bind containing `buttonSize`; rename that JavaScript property to `size` manually. This file is unchanged.',
313
+ })
314
+ } else if (analysis.opaque) {
315
+ refusals.push({
316
+ file: filename,
317
+ line: lineAt(source, offset + attribute.start),
318
+ message:
319
+ 'EditorFixedMenu uses an opaque object v-bind that may contain `buttonSize`; inspect it and rename that JavaScript property to `size` manually if needed. This file is unchanged.',
320
+ })
321
+ }
322
+ }
323
+
324
+ if (refusals.length) return { tag, refusals }
325
+
326
+ for (const attribute of legacyAttributes) {
327
+ const match = attribute.name.match(
328
+ /^((?:v-bind:|:)?)(button-size|buttonSize)$/,
329
+ )
330
+ if (!match) continue
331
+ const [, prefix] = match
332
+ const replacement =
333
+ prefix && attribute.value === undefined
334
+ ? `${prefix}size="buttonSize"`
335
+ : `${prefix}size`
336
+ edits.push({ start: attribute.start, end: attribute.nameEnd, replacement })
337
+ }
338
+
339
+ for (const edit of edits.reverse()) {
340
+ tag = tag.slice(0, edit.start) + edit.replacement + tag.slice(edit.end)
341
+ }
342
+ return { tag, refusals }
343
+ }
344
+
345
+ export function migrateEditor(source, filename = '<source>') {
346
+ const bindings = editorFixedMenuBindings(source)
347
+ if (!bindings.size) return { migrated: source, changed: false, refusals: [] }
348
+
349
+ const tagNames = new Set()
350
+ for (const binding of bindings) {
351
+ tagNames.add(binding)
352
+ tagNames.add(kebabCase(binding))
353
+ }
354
+
355
+ const edits = []
356
+ const refusals = []
357
+ const template = templateContentRange(source)
358
+ if (!template) return { migrated: source, changed: false, refusals: [] }
359
+
360
+ let index = template.start
361
+ while (index < template.end) {
362
+ if (source.startsWith('<!--', index)) {
363
+ const end = source.indexOf('-->', index + 4)
364
+ index = end === -1 ? template.end : end + 3
365
+ continue
366
+ }
367
+ if (source.startsWith('{{', index)) {
368
+ index = expressionEnd(source, index + 2)
369
+ continue
370
+ }
371
+ if (source[index] !== '<') {
372
+ index++
373
+ continue
374
+ }
375
+ const match = source.slice(index).match(/^<([A-Za-z][\w.-]*)\b/)
376
+ if (!match) {
377
+ index++
378
+ continue
379
+ }
380
+ const tagName = match[1]
381
+ const start = index
382
+ const end = tagEnd(source, start)
383
+ index = end
384
+ if (!tagNames.has(tagName)) continue
385
+ const tag = source.slice(start, end)
386
+ const result = rewriteTag(tag, tagName, filename, start, source)
387
+ refusals.push(...result.refusals)
388
+ if (result.tag !== tag) edits.push({ start, end, replacement: result.tag })
389
+ }
390
+
391
+ if (refusals.length) return { migrated: source, changed: false, refusals }
392
+ let migrated = source
393
+ for (const edit of edits.reverse()) {
394
+ migrated =
395
+ migrated.slice(0, edit.start) +
396
+ edit.replacement +
397
+ migrated.slice(edit.end)
398
+ }
399
+ return { migrated, changed: migrated !== source, refusals }
400
+ }
401
+
402
+ function* walk(target) {
403
+ if (fs.statSync(target).isFile()) {
404
+ if (VUE_EXTENSIONS.has(path.extname(target))) yield target
405
+ else console.error(`Skipped ${target}: not a Vue file`)
406
+ return
407
+ }
408
+ for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
409
+ const full = path.join(target, entry.name)
410
+ if (entry.isSymbolicLink()) continue
411
+ if (entry.isDirectory()) {
412
+ if (!SKIP_DIRS.has(entry.name)) yield* walk(full)
413
+ } else if (VUE_EXTENSIONS.has(path.extname(entry.name))) {
414
+ yield full
415
+ }
416
+ }
417
+ }
418
+
419
+ function main(argv) {
420
+ if (argv.includes('--help') || argv.includes('-h')) {
421
+ console.log(USAGE)
422
+ return 0
423
+ }
424
+ const unknown = argv.filter((arg) => arg.startsWith('-') && !OPTIONS.has(arg))
425
+ if (unknown.length) {
426
+ console.error(`Unknown option: ${unknown.join(' ')}`)
427
+ console.error(USAGE)
428
+ return 1
429
+ }
430
+
431
+ const dryRun = argv.includes('--dry-run')
432
+ const targets = argv.filter((arg) => !arg.startsWith('-'))
433
+ if (!targets.length) {
434
+ console.error(USAGE)
435
+ return 1
436
+ }
437
+
438
+ const files = []
439
+ const seen = new Set()
440
+ for (const target of targets) {
441
+ if (!fs.existsSync(target)) {
442
+ console.error(`Invalid path: ${target}`)
443
+ return 1
444
+ }
445
+ for (const file of walk(path.resolve(target))) {
446
+ const resolved = fs.realpathSync(file)
447
+ if (seen.has(resolved)) continue
448
+ seen.add(resolved)
449
+ files.push(file)
450
+ }
451
+ }
452
+
453
+ let changed = 0
454
+ let refused = 0
455
+ for (const file of files) {
456
+ const source = fs.readFileSync(file, 'utf8')
457
+ const result = migrateEditor(source, file)
458
+ for (const refusal of result.refusals) {
459
+ refused++
460
+ console.error(`${refusal.file}:${refusal.line}: ${refusal.message}`)
461
+ }
462
+ if (!result.changed) continue
463
+ changed++
464
+ if (!dryRun) fs.writeFileSync(file, result.migrated)
465
+ console.log(`${dryRun ? 'Would update' : 'Updated'} ${file}`)
466
+ }
467
+
468
+ if (!changed && !refused)
469
+ console.log('No EditorFixedMenu buttonSize props found.')
470
+ return refused ? 1 : 0
471
+ }
472
+
473
+ const scriptPath = fileURLToPath(import.meta.url)
474
+ const invokedPath = process.argv[1]
475
+ const isCLI =
476
+ invokedPath && fs.realpathSync(invokedPath) === fs.realpathSync(scriptPath)
477
+ if (isCLI) process.exitCode = main(process.argv.slice(2))
@@ -8,7 +8,7 @@ const props = defineProps<{
8
8
  // Optional inside <Editor> — falls back to the provided editor context.
9
9
  editor?: Editor | null
10
10
  items: MenuItem[]
11
- buttonSize?: 'xs' | 'sm'
11
+ size?: 'xs' | 'sm'
12
12
  }>()
13
13
 
14
14
  const editor = useResolvedEditor(() => props.editor)
@@ -16,6 +16,6 @@ const editor = useResolvedEditor(() => props.editor)
16
16
 
17
17
  <template>
18
18
  <div data-slot="fixed-menu" class="flex items-center gap-1">
19
- <MenuItems :editor="editor" :items="items" :button-size="buttonSize" />
19
+ <MenuItems :editor="editor" :items="items" :size="size" />
20
20
  </div>
21
21
  </template>
@@ -9,7 +9,7 @@ import TooltipProvider from '#components/Tooltip/TooltipProvider.vue'
9
9
  const props = defineProps<{
10
10
  editor: Editor | null
11
11
  items: MenuItem[]
12
- buttonSize?: 'xs' | 'sm'
12
+ size?: 'xs' | 'sm'
13
13
  }>()
14
14
 
15
15
  // The editor from `useEditor` is built on `@tiptap/core` (not `@tiptap/vue-3`),
@@ -101,7 +101,7 @@ function run(item: CommandMenuItem, event?: MouseEvent) {
101
101
  }
102
102
  }
103
103
 
104
- const menuButtonSize = computed(() => props.buttonSize ?? 'xs')
104
+ const menuButtonSize = computed(() => props.size ?? 'xs')
105
105
  </script>
106
106
 
107
107
  <template>
@@ -3,6 +3,7 @@ import Suggestion from '@tiptap/suggestion'
3
3
  import { PluginKey } from '@tiptap/pm/state'
4
4
  import type { Component } from 'vue'
5
5
  import type { Editor } from './useEditor'
6
+ import { warnRemoved } from '#utils/warnDeprecated'
6
7
  import {
7
8
  createSuggestionRenderer,
8
9
  type SuggestionFloatingOptions,
@@ -14,7 +15,7 @@ export type SuggestionExtensionOptions<TItem = any> = {
14
15
  name: string
15
16
  trigger: string
16
17
  items: TItem[] | ((query: string) => TItem[] | Promise<TItem[]>)
17
- component?: Component
18
+ listComponent?: Component
18
19
  floatingOptions?: SuggestionFloatingOptions
19
20
  allowSpaces?: boolean
20
21
  /**
@@ -33,6 +34,12 @@ export type SuggestionExtensionOptions<TItem = any> = {
33
34
  function buildSuggestionExtension<TItem = any>(
34
35
  options: SuggestionExtensionOptions<TItem>,
35
36
  ) {
37
+ if ('component' in options) {
38
+ warnRemoved(
39
+ 'SuggestionExtension.component',
40
+ 'SuggestionExtension.listComponent',
41
+ )
42
+ }
36
43
  return Extension.create({
37
44
  name: options.name,
38
45
  addOptions() {
@@ -57,10 +64,10 @@ function buildSuggestionExtension<TItem = any>(
57
64
  }) => {
58
65
  options.command({ editor, item: props, range })
59
66
  },
60
- render: options.component
67
+ render: options.listComponent
61
68
  ? () =>
62
69
  createSuggestionRenderer(
63
- options.component as Component,
70
+ options.listComponent as Component,
64
71
  options.floatingOptions,
65
72
  )
66
73
  : undefined,
@@ -50,5 +50,5 @@ export default createSuggestionExtension<EmojiItem>({
50
50
  )
51
51
  }
52
52
  },
53
- component: EmojiList,
53
+ listComponent: EmojiList,
54
54
  })
@@ -6,7 +6,7 @@
6
6
  * (`jane@example.com`). Typography rewrites straight quotes to curly ones, so
7
7
  * those prefixes are covered too.
8
8
  */
9
- import { describe, it, expect, afterEach } from 'vitest'
9
+ import { describe, it, expect, afterEach, vi } from 'vitest'
10
10
  import { Editor, type AnyExtension } from '@tiptap/core'
11
11
  import { Document } from '@tiptap/extension-document'
12
12
  import { Paragraph } from '@tiptap/extension-paragraph'
@@ -14,6 +14,7 @@ import { Text } from '@tiptap/extension-text'
14
14
  import { PluginKey } from '@tiptap/pm/state'
15
15
  import { Typography } from '../../extensions'
16
16
  import { MentionExtension } from './mention-extension'
17
+ import { _resetWarnDeprecated } from '../../../../utils/warnDeprecated'
17
18
 
18
19
  const openEditors: Editor[] = []
19
20
 
@@ -60,6 +61,7 @@ function type(editor: Editor, text: string) {
60
61
  describe('Mention allowedPrefixes', () => {
61
62
  afterEach(() => {
62
63
  while (openEditors.length) openEditors.pop()?.destroy()
64
+ _resetWarnDeprecated()
63
65
  })
64
66
 
65
67
  it('opens at the start of a paragraph and after a space', () => {
@@ -72,6 +74,20 @@ describe('Mention allowedPrefixes', () => {
72
74
  expect(mentionActive(editor)).toBe(true)
73
75
  })
74
76
 
77
+ it('warns in development when Mention receives the old component key', () => {
78
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
79
+ const extension = MentionExtension.configure({ component: {} } as any)
80
+
81
+ ;(extension.config.addExtensions as Function).call(extension)
82
+ ;(extension.config.addExtensions as Function).call(extension)
83
+
84
+ expect(warn).toHaveBeenCalledWith(
85
+ '[frappe-ui] Mention.component was removed. Use Mention.nodeView instead.',
86
+ )
87
+ expect(warn).toHaveBeenCalledTimes(1)
88
+ warn.mockRestore()
89
+ })
90
+
75
91
  it.each([
76
92
  '(',
77
93
  '[',
@@ -14,6 +14,7 @@ import {
14
14
  type BaseSuggestionItem,
15
15
  } from '../suggestion/createSuggestionExtension'
16
16
  import SuggestionList from '../suggestion/SuggestionList.vue'
17
+ import { warnRemoved } from '#utils/warnDeprecated'
17
18
  import {
18
19
  insertSuggestionNode,
19
20
  filterByQuery,
@@ -33,9 +34,9 @@ interface MentionSuggestionOptions {
33
34
  mentions: MaybeRefOrGetter<MentionSuggestionItem[]>
34
35
  }
35
36
 
36
- function createMentionNode(component?: Component) {
37
- const nodeView = component
38
- ? { addNodeView: () => VueNodeViewRenderer(component) }
37
+ function createMentionNode(nodeView?: Component) {
38
+ const nodeViewExtension = nodeView
39
+ ? { addNodeView: () => VueNodeViewRenderer(nodeView) }
39
40
  : {}
40
41
 
41
42
  return Node.create({
@@ -44,12 +45,6 @@ function createMentionNode(component?: Component) {
44
45
  inline: true,
45
46
  selectable: true,
46
47
  atom: true,
47
- addOptions() {
48
- return {
49
- component: undefined,
50
- }
51
- },
52
-
53
48
  addAttributes() {
54
49
  return {
55
50
  id: {
@@ -104,7 +99,7 @@ function createMentionNode(component?: Component) {
104
99
  return `@${node.attrs.label || node.attrs.id || ''}`
105
100
  },
106
101
 
107
- ...nodeView,
102
+ ...nodeViewExtension,
108
103
  })
109
104
  }
110
105
 
@@ -150,7 +145,7 @@ const MentionSuggestionExtension =
150
145
  name: 'mentionSuggestion',
151
146
  char: '@',
152
147
  pluginKey: new PluginKey('mentionSuggestion'),
153
- component: SuggestionList,
148
+ listComponent: SuggestionList,
154
149
  allowedPrefixes: ALLOWED_MENTION_PREFIXES,
155
150
 
156
151
  addOptions() {
@@ -189,19 +184,21 @@ const MentionSuggestionExtension =
189
184
 
190
185
  export const MentionExtension = Extension.create<{
191
186
  items: MaybeRefOrGetter<MentionSuggestionItem[]> | null
192
- component?: Component
187
+ nodeView?: Component
193
188
  }>({
194
189
  name: 'mentionExtension',
195
190
 
196
191
  addOptions() {
197
192
  return {
198
193
  items: null,
199
- component: undefined,
200
194
  }
201
195
  },
202
196
 
203
197
  addExtensions() {
204
- const node = createMentionNode(this.options.component)
198
+ if ('component' in this.options) {
199
+ warnRemoved('Mention.component', 'Mention.nodeView')
200
+ }
201
+ const node = createMentionNode(this.options.nodeView)
205
202
  // Inert until configured: only wire the `@` suggestion when an item source
206
203
  // is provided. Existing mentions in content still render through the node.
207
204
  if (this.options.items == null) return [node]
@@ -97,7 +97,7 @@ function makeRealEditor(content = '') {
97
97
  pluginKey: key,
98
98
  items: () => [],
99
99
  command: () => {},
100
- component: { render: () => null },
100
+ listComponent: { render: () => null },
101
101
  }),
102
102
  ],
103
103
  content: content ? `<p>${content}</p>` : '<p></p>',
@@ -190,7 +190,7 @@ export const SlashCommands = createSuggestionExtension<CommandItem>({
190
190
  )
191
191
  }
192
192
  },
193
- component: SlashCommandsList,
193
+ listComponent: SlashCommandsList,
194
194
  })
195
195
 
196
196
  export default SlashCommands
@@ -57,7 +57,7 @@ export interface CreateSuggestionExtensionOptions<
57
57
  editor: Editor
58
58
  }) => TItem[] | Promise<TItem[]>
59
59
  command: (props: { editor: Editor; range: Range; props: TItem }) => void
60
- component: VueComponent
60
+ listComponent: VueComponent
61
61
  floatingOptions?: SuggestionFloatingOptions
62
62
  allowSpaces?: boolean
63
63
  /**
@@ -111,7 +111,7 @@ export function createSuggestionExtension<TItem extends BaseSuggestionItem>(
111
111
  decorationClass: options.decorationClass || 'suggestion',
112
112
  render: () =>
113
113
  createSuggestionRenderer(
114
- options.component,
114
+ options.listComponent,
115
115
  options.floatingOptions,
116
116
  ),
117
117
  } as Omit<SuggestionOptions<TItem>, 'editor'>,
@@ -113,7 +113,7 @@ export const TagExtension = createSuggestionExtension<TagSuggestionItem>({
113
113
  name: 'tagSuggestion',
114
114
  char: '#',
115
115
  pluginKey: new PluginKey('tagSuggestion'),
116
- component: SuggestionList,
116
+ listComponent: SuggestionList,
117
117
 
118
118
  addOptions() {
119
119
  return {
@@ -55,7 +55,7 @@ type CustomMember = Record<string, any> | false
55
55
  type MentionMember =
56
56
  | {
57
57
  items?: MaybeRefOrGetter<MentionSuggestionItem[]> | null
58
- component?: Component
58
+ nodeView?: Component
59
59
  }
60
60
  | false
61
61
  type TagMember =
@@ -144,6 +144,17 @@ describe('editor menu primitives and presets', () => {
144
144
  expect(calls.join('|')).toContain('focus:|toggleBold:|run:')
145
145
  })
146
146
 
147
+ it('uses the fixed menu size for its buttons', async () => {
148
+ const { EditorFixedMenu, Bold } = editorMenu
149
+ const { editor } = fakeEditor()
150
+
151
+ const root = mount(EditorFixedMenu, { editor, items: [Bold], size: 'sm' })
152
+
153
+ expect(root.querySelector('[aria-label="Bold"]')?.className).toContain(
154
+ 'h-7',
155
+ )
156
+ })
157
+
147
158
  it('hides items whose mark/node/extension is absent (self-pruning)', async () => {
148
159
  const { EditorFixedMenu, Bold, InsertTable, AlignLeft } = editorMenu
149
160
  // A trimmed editor: no table node, no textAlign extension.
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { describe, it, expect, vi, beforeEach } from 'vitest'
6
6
  import { createApp, defineComponent, h, nextTick, ref, toRaw } from 'vue'
7
+ import { _resetWarnDeprecated } from '../../utils/warnDeprecated'
7
8
 
8
9
  const editors: any[] = []
9
10
 
@@ -126,6 +127,7 @@ vi.mock('@tiptap/vue-3', () => ({
126
127
 
127
128
  beforeEach(() => {
128
129
  editors.length = 0
130
+ _resetWarnDeprecated()
129
131
  })
130
132
 
131
133
  describe('frappe-ui/editor minimal primitives', () => {
@@ -141,7 +143,7 @@ describe('frappe-ui/editor minimal primitives', () => {
141
143
  expect(editor.InsertIframe).toBeTruthy()
142
144
  })
143
145
 
144
- it('wires the public SuggestionExtension component into the suggestion renderer', async () => {
146
+ it('wires the public SuggestionExtension list component into the suggestion renderer', async () => {
145
147
  const component = defineComponent({ setup: () => () => h('div') })
146
148
  const { SuggestionExtension } = await import('./index')
147
149
 
@@ -149,7 +151,7 @@ describe('frappe-ui/editor minimal primitives', () => {
149
151
  name: 'people',
150
152
  trigger: '@',
151
153
  items: [],
152
- component,
154
+ listComponent: component,
153
155
  command: vi.fn(),
154
156
  })
155
157
 
@@ -157,6 +159,32 @@ describe('frappe-ui/editor minimal primitives', () => {
157
159
  expect(typeof options.suggestion.render).toBe('function')
158
160
  })
159
161
 
162
+ it('warns in development when SuggestionExtension receives the old component key', async () => {
163
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
164
+ const { SuggestionExtension } = await import('./index')
165
+
166
+ SuggestionExtension.configure({
167
+ name: 'people',
168
+ trigger: '@',
169
+ items: [],
170
+ component: defineComponent({ setup: () => () => h('div') }),
171
+ command: vi.fn(),
172
+ } as any)
173
+ SuggestionExtension.configure({
174
+ name: 'tags',
175
+ trigger: '#',
176
+ items: [],
177
+ component: defineComponent({ setup: () => () => h('div') }),
178
+ command: vi.fn(),
179
+ } as any)
180
+
181
+ expect(warn).toHaveBeenCalledWith(
182
+ '[frappe-ui] SuggestionExtension.component was removed. Use SuggestionExtension.listComponent instead.',
183
+ )
184
+ expect(warn).toHaveBeenCalledTimes(1)
185
+ warn.mockRestore()
186
+ })
187
+
160
188
  it('forwards allowSpaces from SuggestionExtension options to the suggestion plugin', async () => {
161
189
  const { SuggestionExtension } = await import('./index')
162
190
 
@@ -1,19 +1,36 @@
1
1
  const warned = new Set<string>()
2
2
 
3
+ function warnOnce(key: string, message: string) {
4
+ if (import.meta.env.PROD) return
5
+ if (warned.has(key)) return
6
+ warned.add(key)
7
+ console.warn(message)
8
+ }
9
+
3
10
  export function warnDeprecated(
4
11
  name: string,
5
12
  replacement: string,
6
13
  docHref?: string,
7
14
  ) {
8
- if (import.meta.env.PROD) return
9
- if (warned.has(name)) return
10
- warned.add(name)
11
15
  const suffix = docHref ? ` See ${docHref}` : ''
12
- console.warn(
16
+ warnOnce(
17
+ `deprecated:${name}`,
13
18
  `[frappe-ui] ${name} is deprecated. Use ${replacement} instead.${suffix}`,
14
19
  )
15
20
  }
16
21
 
22
+ export function warnRemoved(
23
+ name: string,
24
+ replacement: string,
25
+ docHref?: string,
26
+ ) {
27
+ const suffix = docHref ? ` See ${docHref}` : ''
28
+ warnOnce(
29
+ `removed:${name}`,
30
+ `[frappe-ui] ${name} was removed. Use ${replacement} instead.${suffix}`,
31
+ )
32
+ }
33
+
17
34
  /** Test-only: clear the dedup set so each test sees a fresh warning surface. */
18
35
  export function _resetWarnDeprecated() {
19
36
  warned.clear()