tiptapify 0.3.2 → 0.3.4

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.
Files changed (87) hide show
  1. package/README.md +1 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +54477 -48112
  4. package/dist/tiptapify.umd.js +68 -68
  5. package/index.d.ts +96 -0
  6. package/package.json +2 -1
  7. package/src/components/Footer.vue +12 -2
  8. package/src/components/ImageBubbleMenu.vue +139 -0
  9. package/src/components/MenuBubble.vue +60 -7
  10. package/src/components/SkinScope.test.ts +74 -0
  11. package/src/components/SkinScope.vue +18 -0
  12. package/src/components/Tiptapify.skin.test.ts +186 -0
  13. package/src/components/Tiptapify.vue +285 -74
  14. package/src/components/Toolbar/Index.test.ts +96 -0
  15. package/src/components/Toolbar/Index.vue +199 -14
  16. package/src/components/Toolbar/Items.test.ts +82 -2
  17. package/src/components/Toolbar/Items.vue +104 -19
  18. package/src/components/Toolbar/alignment.ts +5 -0
  19. package/src/components/UI/TiptapifyDialog.test.ts +132 -0
  20. package/src/components/UI/TiptapifyDialog.vue +128 -12
  21. package/src/components/editorExtensions.test.ts +10 -0
  22. package/src/components/editorExtensions.ts +34 -5
  23. package/src/components/index.ts +3 -2
  24. package/src/composables/useTiptapifyConfig.test.ts +25 -2
  25. package/src/composables/useTiptapifyConfig.ts +21 -2
  26. package/src/constants/defaults.ts +2 -1
  27. package/src/extensions/components/actions/redo/Button.vue +1 -1
  28. package/src/extensions/components/actions/undo/Button.vue +1 -1
  29. package/src/extensions/components/alignment/menu/Button.vue +72 -0
  30. package/src/extensions/components/list/bullet/Button.vue +35 -16
  31. package/src/extensions/components/list/numbered/Button.vue +19 -0
  32. package/src/extensions/components/list/styles/ListStyleButton.vue +156 -0
  33. package/src/extensions/components/list/styles/index.ts +76 -0
  34. package/src/extensions/components/list/styles/listStyle.test.ts +44 -0
  35. package/src/extensions/components/media/image/ImageDialog.vue +1 -1
  36. package/src/extensions/components/media/image/caption.test.ts +122 -0
  37. package/src/extensions/components/media/image/caption.ts +268 -0
  38. package/src/extensions/components/media/image/slider.test.ts +106 -0
  39. package/src/extensions/components/media/image/slider.ts +87 -0
  40. package/src/extensions/components/style/heading/Button.vue +45 -30
  41. package/src/i18n/locales/ar.json +22 -0
  42. package/src/i18n/locales/bg.json +22 -0
  43. package/src/i18n/locales/ch.json +22 -0
  44. package/src/i18n/locales/cz.json +22 -0
  45. package/src/i18n/locales/de.json +22 -0
  46. package/src/i18n/locales/el.json +22 -0
  47. package/src/i18n/locales/en.json +23 -1
  48. package/src/i18n/locales/es.json +22 -0
  49. package/src/i18n/locales/et.json +22 -0
  50. package/src/i18n/locales/fi.json +22 -0
  51. package/src/i18n/locales/fr.json +22 -0
  52. package/src/i18n/locales/he.json +22 -0
  53. package/src/i18n/locales/hi.json +22 -0
  54. package/src/i18n/locales/hr.json +22 -0
  55. package/src/i18n/locales/hu.json +22 -0
  56. package/src/i18n/locales/id.json +22 -0
  57. package/src/i18n/locales/it.json +22 -0
  58. package/src/i18n/locales/ja.json +22 -0
  59. package/src/i18n/locales/ko.json +22 -0
  60. package/src/i18n/locales/la.json +22 -0
  61. package/src/i18n/locales/lt.json +22 -0
  62. package/src/i18n/locales/nl.json +22 -0
  63. package/src/i18n/locales/no.json +22 -0
  64. package/src/i18n/locales/pl.json +22 -0
  65. package/src/i18n/locales/pt-BR.json +22 -0
  66. package/src/i18n/locales/pt.json +22 -0
  67. package/src/i18n/locales/ro.json +22 -0
  68. package/src/i18n/locales/ru.json +23 -1
  69. package/src/i18n/locales/se.json +22 -0
  70. package/src/i18n/locales/sk.json +22 -0
  71. package/src/i18n/locales/sl.json +22 -0
  72. package/src/i18n/locales/sr.json +22 -0
  73. package/src/i18n/locales/th.json +22 -0
  74. package/src/i18n/locales/tr.json +22 -0
  75. package/src/i18n/locales/uk.json +23 -1
  76. package/src/i18n/locales/vi.json +22 -0
  77. package/src/index.test.ts +88 -0
  78. package/src/index.ts +15 -0
  79. package/src/skins/__fixtures__/skin-baseline.json +805 -0
  80. package/src/skins/builtins.ts +81 -0
  81. package/src/skins/menu.css +87 -0
  82. package/src/skins/menu.ts +10 -0
  83. package/src/skins/registry.test.ts +180 -0
  84. package/src/skins/registry.ts +132 -0
  85. package/src/skins/tokens.reachability.test.ts +43 -0
  86. package/src/skins/tokens.ts +92 -0
  87. package/src/types/editor.ts +99 -0
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createSSRApp, defineComponent, h, markRaw, provide, shallowRef } from 'vue'
3
+ import { renderToString } from 'vue/server-renderer'
4
+ import { createVuetify } from 'vuetify'
5
+ import Toolbar from '@tiptapify/components/Toolbar/Index.vue'
6
+
7
+ // Every editor call answers with something callable, so toolbar buttons can render
8
+ function permissive (): unknown {
9
+ return new Proxy(() => false, {
10
+ get: (_, key) => {
11
+ if (key === '__v_skip') {
12
+ return true
13
+ }
14
+ if (key === Symbol.toPrimitive) {
15
+ return () => ''
16
+ }
17
+ if (typeof key === 'symbol' || key.startsWith('__v') || key === 'then') {
18
+ return undefined
19
+ }
20
+
21
+ return key === 'instanceId' ? 1 : permissive()
22
+ },
23
+ apply: () => permissive(),
24
+ })
25
+ }
26
+
27
+ const stub = (className: string) => markRaw(defineComponent({ render: () => h('i', { class: className }) }))
28
+
29
+ async function renderToolbar (items: string[], customExtensions: unknown[]) {
30
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
31
+ const app = createSSRApp({
32
+ setup () {
33
+ provide('tiptapifyEditor', shallowRef(permissive()))
34
+ provide('tiptapifyI18n', { t: (key: string) => key })
35
+
36
+ return () => h(Toolbar, { items, customExtensions })
37
+ },
38
+ })
39
+ app.use(createVuetify())
40
+
41
+ return renderToString(app)
42
+ }
43
+
44
+ describe('Toolbar', () => {
45
+ it('adds a custom button to an existing section after its own buttons', async () => {
46
+ const html = await renderToolbar(['sup', 'blockquote'], [
47
+ { section: 'formatExtra', group: true, components: [{ name: 'disclaimer', component: stub('custom-disclaimer') }] },
48
+ ])
49
+
50
+ expect(html).toContain('custom-disclaimer')
51
+ expect(html.indexOf('custom-disclaimer')).toBeGreaterThan(html.indexOf('tiptapify-toolbar-group'))
52
+ })
53
+
54
+ it('puts custom sections before the spacer', async () => {
55
+ const html = await renderToolbar(['bold', '~', 'undo'], [
56
+ { section: 'extra', group: true, components: [{ name: 'snippet', component: stub('custom-snippet') }] },
57
+ ])
58
+
59
+ expect(html.indexOf('custom-snippet')).toBeLessThan(html.indexOf('tiptapify-toolbar-spacer'))
60
+ })
61
+
62
+ it('does not leak custom buttons into the next editor', async () => {
63
+ await renderToolbar([], [
64
+ { section: 'formatExtra', group: true, components: [{ name: 'disclaimer', component: stub('custom-disclaimer') }] },
65
+ ])
66
+ const html = await renderToolbar([], [])
67
+
68
+ expect(html).not.toContain('custom-disclaimer')
69
+ })
70
+
71
+ it('groups the items by the separators, across sections', async () => {
72
+ const html = await renderToolbar(['bold', 'link', '|', 'blockquote', 'custom-note', '|', 'undo'], [
73
+ { section: 'extra', group: true, components: [{ name: 'custom-note', component: stub('custom-note') }] },
74
+ ])
75
+
76
+ const groups = html.split('tiptapify-toolbar-group').slice(1)
77
+ expect(groups).toHaveLength(3)
78
+ expect(groups[1]).toContain('custom-note')
79
+ expect(groups[0]).not.toContain('custom-note')
80
+ })
81
+
82
+ it('drops the place of a custom button the editor does not have', async () => {
83
+ const html = await renderToolbar(['bold', '|', 'custom-note', '|', 'undo'], [])
84
+
85
+ expect(html.split('tiptapify-toolbar-group').slice(1)).toHaveLength(2)
86
+ })
87
+
88
+ it('replaces a built-in button by a custom one of the same name in its place', async () => {
89
+ const html = await renderToolbar(['bold', '|', 'image', 'link'], [
90
+ { section: 'media', group: true, components: [{ name: 'image', component: stub('custom-image') }] },
91
+ ])
92
+
93
+ expect(html).toContain('custom-image')
94
+ expect(html.split('tiptapify-toolbar-group').slice(1)).toHaveLength(2)
95
+ })
96
+ })
@@ -7,6 +7,7 @@ import { computed, inject, PropType, Ref } from 'vue'
7
7
  import { itemsPropType, toolbarSections } from '@tiptapify/types/toolbarTypes'
8
8
 
9
9
  import { default as toolbarItemsGroups, availableItems } from '@tiptapify/components/Toolbar/items'
10
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
10
11
 
11
12
  const props = defineProps({
12
13
  variantField: { type: String as PropType<variantFieldTypes>, default() { return defaults.variantField } },
@@ -23,20 +24,73 @@ const props = defineProps({
23
24
 
24
25
  const editor = inject('tiptapifyEditor') as Ref<Editor>
25
26
 
27
+ // The toolbar shape flag styles the bar: a flat tinted bar when `toolbar` is flat;
28
+ // the classic shape keeps the raised Vuetify toolbar
29
+ const { shape } = useTiptapifyConfig()
30
+
26
31
  const propsItems = computed(() => props.items)
27
32
  const propsItemsExclude = computed(() => props.itemsExclude)
28
33
 
29
34
  // this prevents from overriding the default items
30
- let toolbarItems = [...Object.values(toolbarItemsGroups)]
35
+ let toolbarItems = Object.values(toolbarItemsGroups).map(section => ({
36
+ ...section,
37
+ components: section.components.map(component => ({ ...component })),
38
+ }))
31
39
  const availableItemsKeys = Object.keys(availableItems)
32
40
 
33
41
  type toolbarItemsType = { [key: string]: { section: string, group: boolean, components: any[] } }
34
42
  if (propsItemsExclude.value) {
35
43
  prepareExcludeToolbarItems()
44
+ } else if (Array.isArray(propsItems.value) && propsItems.value.includes('|')) {
45
+ prepareGroupedToolbarItems(propsItems.value as string[])
36
46
  } else {
37
47
  prepareToolbarItems()
38
48
  }
39
49
 
50
+ /**
51
+ * Items split by `'|'` into groups of their own, whatever sections the buttons come from. A name that is
52
+ * not a built-in item keeps a place for the custom button of that name; editors without it drop the place.
53
+ */
54
+ function prepareGroupedToolbarItems(items: string[]) {
55
+ const groups: toolbarItemsType[string][] = []
56
+ const newGroup = () => ({ section: `__group_${groups.length}__`, group: true, components: [] as any[] })
57
+ let group = newGroup()
58
+
59
+ const flush = () => {
60
+ if (group.components.length > 0) {
61
+ groups.push(group)
62
+ }
63
+ group = newGroup()
64
+ }
65
+
66
+ for (const propsItem of items) {
67
+ if (propsItem === '|') {
68
+ flush()
69
+ continue
70
+ }
71
+
72
+ if (propsItem === '~') {
73
+ flush()
74
+ groups.push({ section: '__spacer__', group: false, components: [] })
75
+ group = newGroup()
76
+ continue
77
+ }
78
+
79
+ const itemName = propsItem.split(':')[0]
80
+ if (!availableItemsKeys.includes(itemName)) {
81
+ group.components.push({ name: itemName, component: null })
82
+ continue
83
+ }
84
+
85
+ const itemSections: toolbarItemsType = {}
86
+ addToolbarItem(itemSections, items, propsItem)
87
+ group.components.push(...Object.values(itemSections).flatMap(section => section.components))
88
+ }
89
+ flush()
90
+
91
+ toolbarItems = groups
92
+ }
93
+
40
94
  function prepareToolbarItems() {
41
95
  const _toolbarItems: toolbarItemsType = {}
42
96
 
@@ -67,6 +121,15 @@ function prepareToolbarItems() {
67
121
  }
68
122
  continue
69
123
  }
124
+ // `~` pushes the items after it to the end of the toolbar
125
+ if (propsItem === '~') {
126
+ _toolbarItems['__spacer__'] ??= {
127
+ section: '__spacer__',
128
+ group: false,
129
+ components: []
130
+ }
131
+ continue
132
+ }
70
133
  const item = propsItem.split(':')
71
134
  if (!availableItemsKeys.includes(item[0])) {
72
135
  throw new Error(`The ${propsItem} is unknown extension. Please use one of the following: ${availableItemsKeys.join(', ')}`)
@@ -105,7 +168,9 @@ function addToolbarItem(_toolbarItems: toolbarItemsType, itemsList: Array<any>,
105
168
  const itemSection = availableItems[_itemTitle]
106
169
  const section = toolbarItemsGroups[itemSection]
107
170
 
108
- const component = section?.components.find((component: { name: string }) => component.name === _itemTitle)
171
+ // A copy: item options set its props, which must not reach the toolbars of other editors
172
+ const sectionComponent = section?.components.find((component: { name: string }) => component.name === _itemTitle)
173
+ const component = sectionComponent ? { ...sectionComponent } : undefined
109
174
 
110
175
  let itemExists = itemsList.includes(itemTitle)
111
176
  itemExists = propsItemsExclude.value ? itemExists : !itemExists
@@ -145,6 +210,20 @@ function addToolbarItem(_toolbarItems: toolbarItemsType, itemsList: Array<any>,
145
210
  }
146
211
  }
147
212
 
213
+ // `orderedList:styles` adds the numbering styles dropdown
214
+ if (_itemTitle === 'orderedList' && _itemOptions.includes('styles')) {
215
+ component.props = {
216
+ withStyles: true,
217
+ }
218
+ }
219
+
220
+ // `align` is the alignment dropdown; `align:left,center,right` limits its options
221
+ if (_itemTitle === 'align' && _itemOptions.length > 0) {
222
+ component.props = {
223
+ alignments: _itemOptions,
224
+ }
225
+ }
226
+
148
227
  if (_itemTitle === 'bulletList' && _itemOptions.length > 0) {
149
228
  const paragraphIndex = _itemOptions.findIndex((item: any) => item === 'p')
150
229
  const withParagraph = paragraphIndex > -1
@@ -153,6 +232,7 @@ function addToolbarItem(_toolbarItems: toolbarItemsType, itemsList: Array<any>,
153
232
  }
154
233
 
155
234
  component.props = {
235
+ withStyles: _itemOptions.includes('styles'),
156
236
  withDisc: _itemOptions.includes('disc'),
157
237
  withCircle: _itemOptions.includes('circle'),
158
238
  withSquare: _itemOptions.includes('square'),
@@ -164,25 +244,46 @@ function addToolbarItem(_toolbarItems: toolbarItemsType, itemsList: Array<any>,
164
244
  return true
165
245
  }
166
246
 
167
- Object.keys(props.customExtensions).forEach((key: any) => {
168
- const existingSection = toolbarItems.find(item => item.section === props.customExtensions[key].section)
169
- if (existingSection) {
170
- for (const component of props.customExtensions[key].components) {
171
- const existingComponent = existingSection.components.find(_component => _component.name === component.name)
172
- if (existingComponent) {
173
- existingComponent.component = component.component
174
- }
247
+ // A custom button takes the place of the item of its name, joins its section or comes as a section of its own
248
+ for (const customSection of props.customExtensions) {
249
+ const unplaced = []
250
+ for (const component of customSection.components) {
251
+ const place = toolbarItems.flatMap(item => item.components).find(_component => _component.name === component.name)
252
+ if (place) {
253
+ Object.assign(place, component)
254
+ } else {
255
+ unplaced.push(component)
175
256
  }
257
+ }
258
+
259
+ if (unplaced.length === 0) {
260
+ continue
261
+ }
262
+
263
+ const existingSection = toolbarItems.find(item => item.section === customSection.section)
264
+ if (existingSection) {
265
+ existingSection.components.push(...unplaced)
176
266
  } else {
177
- toolbarItems.push(props.customExtensions[key])
267
+ const spacerIndex = toolbarItems.findIndex(item => item.section === '__spacer__')
268
+ toolbarItems.splice(spacerIndex > -1 ? spacerIndex : toolbarItems.length, 0, { ...customSection, components: unplaced })
178
269
  }
179
- })
270
+ }
271
+
272
+ toolbarItems = toolbarItems
273
+ .map(item => ({ ...item, components: item.components.filter(component => component.component) }))
274
+ .filter(item => item.components.length > 0 || ['__spacer__', '__separator__'].includes(item.section))
180
275
 
181
276
  </script>
182
277
 
183
278
  <template>
184
279
  <div v-if="editor" class="tiptapify-toolbar">
185
- <VToolbar elevation="1" :theme="theme" height="auto" :class="`ps-1 pr-1 rounded-t-${rounded}`">
280
+ <VToolbar
281
+ :elevation="shape.toolbar === 'flat' ? 0 : 1"
282
+ :flat="shape.toolbar === 'flat'"
283
+ :theme="theme"
284
+ height="auto"
285
+ :class="`rounded-t-${rounded}`"
286
+ >
186
287
  <VSlideGroup v-if="toolbarScrollable">
187
288
  <Items :items="toolbarItems" />
188
289
  </VSlideGroup>
@@ -192,6 +293,90 @@ Object.keys(props.customExtensions).forEach((key: any) => {
192
293
  </div>
193
294
  </template>
194
295
 
195
- <style lang="scss" scoped>
296
+ <style lang="scss">
297
+ /*
298
+ * Flat toolbar: a tinted bar under a hairline. The raised shape paints the bar with the Vuetify
299
+ * toolbar and its own button variants instead, so this rule set is gated on the shape and its
300
+ * fallbacks are the flat shape's base look (the raised values are Vuetify's, not skin rules).
301
+ */
302
+ .tiptapify-shape-toolbar-flat .tiptapify-toolbar {
303
+ > .v-toolbar {
304
+ background: var(--tf-toolbar-bg, rgba(var(--v-theme-on-surface), 0.03));
305
+ border-bottom: var(--tf-toolbar-border-bottom, 1px solid rgba(var(--v-border-color), var(--v-border-opacity)));
306
+ color: var(--tf-toolbar-fg, rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)));
307
+ }
308
+ }
309
+
310
+ /*
311
+ * Quiet buttons: ghost icon buttons that only get a background on hover, and a neutral "pressed"
312
+ * state instead of the filled primary button. The buttons keep coloring themselves with
313
+ * `color="primary"` when active; this rule maps that to the pressed look. The rules apply iff
314
+ * `buttons` is quiet: through the root shape class in the toolbar, and through the
315
+ * `.tiptapify-quiet-buttons` carrier that the bubble menus carry only in that shape.
316
+ * Tripled class: outranks Vuetify's variant rules (`.v-btn--variant-elevated` background/shadow
317
+ * are plain (0-1-0), so the doubled/tripled class beats them — no `!important` needed for the
318
+ * resting state). The pressed-state block keeps its `!important`s: an active button carries the
319
+ * runtime theme utility class `.bg-primary`/`.text-primary`, which Vuetify generates with
320
+ * `!important`, and the consumer "admin theme" repaints those colored buttons on hover, focus
321
+ * and press (`.bg-primary:focus` and the like) — plain specificity loses against `!important`;
322
+ * this is the admin-theme defense set of the skin layer (spec: Specificity convention).
323
+ */
324
+ .tiptapify-shape-buttons-quiet .tiptapify-toolbar,
325
+ .tiptapify-quiet-buttons {
326
+ .v-btn.v-btn.v-btn {
327
+ border-radius: var(--tf-button-radius, 6px);
328
+ box-shadow: none;
329
+ background-color: transparent;
330
+ color: var(--tf-button-fg, rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)));
331
+ transition: background-color 0.15s, color 0.15s;
332
+
333
+ > .v-btn__overlay,
334
+ > .v-btn__underlay {
335
+ display: none;
336
+ }
337
+
338
+ &:hover,
339
+ &:focus-visible,
340
+ &[aria-expanded='true'] {
341
+ background-color: var(--tf-button-hover-bg, rgba(var(--v-theme-on-surface), 0.07)) !important;
342
+ color: var(--tf-button-hover-fg, rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))) !important;
343
+ }
344
+
345
+ &.bg-primary,
346
+ &.text-primary,
347
+ &.tiptapify-btn--active {
348
+ &,
349
+ &:focus,
350
+ &:active {
351
+ background-color: var(--tf-button-active-bg, rgba(var(--v-theme-on-surface), 0.1)) !important;
352
+ color: var(--tf-button-hover-fg, rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))) !important;
353
+ }
354
+
355
+ &:hover,
356
+ &:focus-visible,
357
+ &[aria-expanded='true'] {
358
+ background-color: var(--tf-button-active-hover-bg, rgba(var(--v-theme-on-surface), 0.14)) !important;
359
+ }
360
+ }
361
+
362
+ &:focus-visible {
363
+ outline: 2px solid rgba(var(--v-theme-on-surface), 0.25);
364
+ outline-offset: -2px;
365
+ }
366
+ }
367
+ }
368
+
369
+ /* A toolbar button that opens a dropdown: the current choice and a chevron */
370
+ .tiptapify-toolbar .v-btn.tiptapify-dropdown-button {
371
+ min-width: 0;
372
+ padding-inline: 6px 4px;
373
+
374
+ .tiptapify-dropdown-button__chevron {
375
+ margin-inline-start: 2px;
376
+ opacity: 0.7;
377
+ }
378
+ }
196
379
 
380
+ /* Dropdowns opened from the toolbar: a light card with compact rows of icon + label.
381
+ * The pastel menu skin rules moved to src/skins/menu.css (SkinScope wrapper, spec step 5). */
197
382
  </style>
@@ -1,10 +1,14 @@
1
1
  import { afterEach, describe, expect, it, vi } from 'vitest'
2
- import { createSSRApp, h, provide, ref } from 'vue'
2
+ import { computed, createSSRApp, h, provide, ref } from 'vue'
3
3
  import { renderToString } from 'vue/server-renderer'
4
4
  import { createVuetify } from 'vuetify'
5
5
  import Items from '@tiptapify/components/Toolbar/Items.vue'
6
6
  import HeadingButton from '@tiptapify/extensions/components/style/heading/Button.vue'
7
+ import AlignmentMenuButton from '@tiptapify/extensions/components/alignment/menu/Button.vue'
7
8
  import { headingLevels } from '@tiptapify/constants/style'
9
+ import { TIPTAPIFY_CONFIG_KEY } from '@tiptapify/composables/useTiptapifyConfig'
10
+ import { resolveSkin } from '@tiptapify/skins/registry'
11
+ import { variantBtnTypes } from '@tiptapify/types/editor'
8
12
 
9
13
  const editor = {
10
14
  instanceId: 1,
@@ -22,16 +26,31 @@ const items = [
22
26
  },
23
27
  ]
24
28
 
25
- async function renderToolbar (toolbarItems: typeof items = items) {
29
+ async function renderToolbar (
30
+ toolbarItems: typeof items = items,
31
+ skin: unknown = 'default',
32
+ variantBtn: variantBtnTypes = 'flat',
33
+ ) {
26
34
  const warnings: string[] = []
27
35
  const warn = vi.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => {
28
36
  warnings.push(args.map(String).join(' '))
29
37
  })
30
38
 
39
+ // The shape the section markup reads: exactly the chain a real editor provides
40
+ const resolved = resolveSkin(skin as never)
41
+
31
42
  const app = createSSRApp({
32
43
  setup () {
33
44
  provide('tiptapifyEditor', ref(editor))
34
45
  provide('tiptapifyI18n', { t: (key: string) => key })
46
+ provide(TIPTAPIFY_CONFIG_KEY, {
47
+ variantBtn: ref(variantBtn),
48
+ variantField: ref('outlined'),
49
+ skin: ref(skin),
50
+ skinName: computed(() => resolved.name),
51
+ tokens: computed(() => resolved.tokens),
52
+ shape: computed(() => resolved.shape),
53
+ })
35
54
 
36
55
  return () => h(Items, { items: toolbarItems })
37
56
  },
@@ -44,6 +63,13 @@ async function renderToolbar (toolbarItems: typeof items = items) {
44
63
  return { html, warnings }
45
64
  }
46
65
 
66
+ // The opening tag of the section's group element
67
+ function groupTag (html: string): string {
68
+ const match = html.match(/<div[^>]*class="[^"]*tiptapify-toolbar-group[^"]*"[^>]*>/)
69
+
70
+ return match ? match[0] : ''
71
+ }
72
+
47
73
  describe('Toolbar Items', () => {
48
74
  afterEach(() => {
49
75
  headingLevels.value = [1, 2, 3, 4, 5, 6]
@@ -72,4 +98,58 @@ describe('Toolbar Items', () => {
72
98
  expect(html).not.toContain('tiptapify-heading-button-1')
73
99
  expect(html.match(/<button/g)).toHaveLength(3)
74
100
  })
101
+
102
+ it('renders a spacer that pushes the next items to the end', async () => {
103
+ const { html } = await renderToolbar([
104
+ items[0],
105
+ { section: '__spacer__', group: false, components: [] },
106
+ { section: 'actions', group: true, components: [{ name: 'heading', component: HeadingButton }] },
107
+ ])
108
+
109
+ expect(html).toContain('tiptapify-toolbar-spacer')
110
+ })
111
+
112
+ it('renders the alignment dropdown with the chosen alignments', async () => {
113
+ const { html } = await renderToolbar([
114
+ {
115
+ section: 'alignment',
116
+ group: true,
117
+ components: [{ name: 'align', component: AlignmentMenuButton, props: { alignments: ['left', 'center'] } }],
118
+ },
119
+ ])
120
+
121
+ expect(html).toContain('tiptapify-align-button-1')
122
+ expect(html).toContain('tiptapify-dropdown-button__chevron')
123
+ })
124
+
125
+ it('renders a grouped section as a VBtnGroup under groups: box', async () => {
126
+ const tag = groupTag(await renderToolbar(items, 'default').then((r) => r.html))
127
+
128
+ expect(tag).toContain('tiptapify-toolbar-group')
129
+ expect(tag).toContain('v-btn-group')
130
+ expect(tag).toContain('elevation-4')
131
+ })
132
+
133
+ it('keeps the 0.3.2 variantBtn elevation class conditional', async () => {
134
+ const flat = groupTag(await renderToolbar(items, 'default', 'flat').then((r) => r.html))
135
+ expect(flat).toContain('v-btn-group')
136
+ expect(flat).not.toContain('tiptapify-btn-group--elevated')
137
+
138
+ const elevated = groupTag(await renderToolbar(items, 'default', 'elevated').then((r) => r.html))
139
+ expect(elevated).toContain('tiptapify-btn-group--elevated')
140
+ })
141
+
142
+ it('renders a grouped section as a plain div under groups: flat', async () => {
143
+ const tag = groupTag(await renderToolbar(items, 'pastel').then((r) => r.html))
144
+
145
+ expect(tag).toContain('tiptapify-toolbar-group')
146
+ expect(tag).not.toContain('v-btn-group')
147
+ })
148
+
149
+ it('an inline groups: box flag on a pastel-base skin gets the VBtnGroup markup', async () => {
150
+ const tag = groupTag(await renderToolbar(items, { extends: 'pastel', groups: 'box' }).then((r) => r.html))
151
+
152
+ expect(tag).toContain('tiptapify-toolbar-group')
153
+ expect(tag).toContain('v-btn-group')
154
+ })
75
155
  })
@@ -1,30 +1,47 @@
1
1
  <script setup lang="ts">
2
2
 
3
- import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
4
3
  import { PropType } from 'vue'
5
4
 
6
5
  import { toolbarSections } from '@tiptapify/types/toolbarTypes'
7
6
 
7
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
8
+
8
9
  defineProps({
9
10
  items: { type: Array as PropType<toolbarSections>, default() { return {} } },
10
11
  })
11
12
 
12
- const { variantBtn } = useTiptapifyConfig()
13
+ // The section markup follows the `groups` shape flag: 'box' renders the 0.3.2 VBtnGroup
14
+ // (joined buttons in a raised box, the tokens below paint it), 'flat' keeps the plain div.
15
+ // `variantBtn` carries the 0.3.2 elevation-override class onto the group.
16
+ const { shape, variantBtn } = useTiptapifyConfig()
13
17
 
14
18
  </script>
15
19
 
16
20
  <template>
17
- <VToolbarItems class="px-2 py-3">
21
+ <VToolbarItems class="tiptapify-toolbar-items">
18
22
  <template v-for="item in items" :key="item.section">
19
23
  <template v-if="item.section === '__separator__'">
20
24
  <div class="menu-divider" />
21
25
  </template>
26
+ <template v-else-if="item.section === '__spacer__'">
27
+ <div class="tiptapify-toolbar-spacer" />
28
+ </template>
22
29
  <template v-else>
23
- <VBtnGroup v-if="item.group" elevation="4" :class="{ 'tiptapify-btn-group--elevated': variantBtn === 'elevated' }">
30
+ <VBtnGroup
31
+ v-if="item.group && shape.groups === 'box'"
32
+ class="tiptapify-toolbar-group"
33
+ elevation="4"
34
+ :class="{ 'tiptapify-btn-group--elevated': variantBtn === 'elevated' }"
35
+ >
24
36
  <template v-for="sectionItem in item.components" :key="sectionItem.name">
25
37
  <component :is="sectionItem.component" v-bind="sectionItem.props ?? {}" />
26
38
  </template>
27
39
  </VBtnGroup>
40
+ <div v-else-if="item.group" class="tiptapify-toolbar-group">
41
+ <template v-for="sectionItem in item.components" :key="sectionItem.name">
42
+ <component :is="sectionItem.component" v-bind="sectionItem.props ?? {}" />
43
+ </template>
44
+ </div>
28
45
  <template v-else>
29
46
  <component
30
47
  :is="sectionItem.component"
@@ -40,35 +57,103 @@ const { variantBtn } = useTiptapifyConfig()
40
57
  </template>
41
58
 
42
59
  <style lang="scss" scoped>
43
- .tiptapify-btn-group--elevated {
60
+ /*
61
+ * Skin rules for the bar, the group boxes and the dividers (spec 2026-10-18: single rules
62
+ * reading `var(--tf-*)`). Base = the classic look, the fixture-canonical fallbacks; the
63
+ * shape gates (`toolbar: flat`, `groups: flat`) carry the same tokens with the shape's
64
+ * own base as fallback, so a shape flag without tokens paints the 0.3.3 look.
65
+ */
66
+ .tiptapify-toolbar-items {
67
+ /* Vuetify sizes .v-toolbar-items to its content; grow it to the full toolbar width,
68
+ * otherwise the `~` spacer has no free space to expand into. */
69
+ flex-grow: 1;
70
+ flex-wrap: wrap;
71
+ align-items: center;
72
+ row-gap: var(--tf-items-row-gap, 5px);
73
+ /* Classic: no column gap (Vuetify's `normal`); the flat gate sets the token's own base */
74
+ column-gap: var(--tf-items-gap, normal);
75
+ padding: var(--tf-items-padding, 12px 16px);
76
+ }
77
+
78
+ .tiptapify-toolbar-group {
79
+ display: flex;
80
+ align-items: center;
81
+
82
+ /* The classic group: a raised box around the section's buttons, Vuetify elevation 2 (fixture-canonical) */
83
+ height: var(--tf-group-height, 32px);
84
+ border-radius: var(--tf-group-radius, 4px);
85
+ background: var(--tf-group-bg, rgb(var(--v-theme-surface)));
44
86
  box-shadow:
87
+ var(--tf-group-shadow,
45
88
  0 3px 1px -2px var(--v-shadow-key-umbra-opacity, rgba(0, 0, 0, 0.2)),
46
89
  0 2px 2px 0 var(--v-shadow-key-penumbra-opacity, rgba(0, 0, 0, 0.14)),
47
- 0 1px 5px 0 var(--v-shadow-key-ambient-opacity, rgba(0, 0, 0, 0.12));
90
+ 0 1px 5px 0 var(--v-shadow-key-ambient-opacity, rgba(0, 0, 0, 0.12)));
48
91
  }
49
92
 
50
- :deep(.v-btn-group) {
51
- height: 32px !important;
93
+ .menu-divider {
94
+ margin: 0 4px;
95
+ width: var(--tf-divider-width, 0px);
96
+ height: var(--tf-divider-height, 0px);
97
+ background: var(--tf-divider-color, transparent);
52
98
  }
53
99
 
54
- .menu-item-title {
55
- font-size: 14px;
100
+ .menu-divider:nth-last-child(1),
101
+ .menu-divider:has(+ .tiptapify-toolbar-spacer) {
102
+ display: none;
56
103
  }
57
104
 
58
- .menu-button {
59
- margin: 0 1px;
105
+ .tiptapify-toolbar-spacer {
106
+ flex: 1 0 0;
60
107
  }
61
108
 
62
- .menu-divider {
63
- margin: 0 4px;
109
+ /* Flat toolbar: compact padding and tighter gaps in the flat bar */
110
+ .tiptapify-shape-toolbar-flat {
111
+ .tiptapify-toolbar-items {
112
+ column-gap: var(--tf-items-gap, 6px);
113
+ row-gap: var(--tf-items-row-gap, 4px);
114
+ padding: var(--tf-items-padding, 6px 8px);
115
+ }
64
116
  }
65
117
 
66
- .menu-divider:nth-last-child(1) {
67
- display: none;
118
+ /* Box groups: the section is a VBtnGroup (0.3.2 structure restored). The base token rules
119
+ * above keep painting the box: the scoped `.tiptapify-toolbar-group` (class + scope
120
+ * attribute) out-specifies Vuetify's single-class `.v-btn-group` background, radius and
121
+ * zero-shadow rules, and `.elevation-4`. Two 0.3.2 pieces are what its own CSS fights:
122
+ * - `.v-btn-group--density-default.v-btn-group` pins 48px at equal specificity, decided by
123
+ * stylesheet order — 0.3.2 needed `:deep(.v-btn-group){ height: 32px !important }`, kept
124
+ * here as the tokenized `--tf-group-height` consumer (the 0.3.2 set demands the
125
+ * !important; the token contract stays honest, no new tokens);
126
+ * - `.menu-button { margin: 0 1px }` re-added for CSS-set parity with 0.3.2: inert there
127
+ * too (no toolbar element carries the class), and the buttons joining inside the box is
128
+ * native VBtnGroup (`border-radius: 0` + corner `inherit`), needing no rule of ours.
129
+ * The 0.3.2 `.tiptapify-btn-group--elevated` shadow override is NOT re-added: the token
130
+ * fallback is exactly that elevation-2 triple, and the scoped base rule beats elevation-4. */
131
+ .tiptapify-shape-groups-box {
132
+ .tiptapify-toolbar-group {
133
+ height: var(--tf-group-height, 32px) !important;
134
+ }
135
+
136
+ .menu-button {
137
+ margin: 0 1px;
138
+ }
68
139
  }
69
140
 
70
- .v-toolbar-items {
71
- flex-wrap: wrap;
72
- row-gap: 5px;
141
+ /* Flat groups: sections become plain runs of buttons with hairline dividers between them;
142
+ * the box shape keeps the raised group defaults above untouched. */
143
+ .tiptapify-shape-groups-flat {
144
+ .tiptapify-toolbar-group {
145
+ gap: var(--tf-group-gap, 2px);
146
+ height: var(--tf-group-height, auto);
147
+ border-radius: var(--tf-group-radius, 0px);
148
+ background: var(--tf-group-bg, transparent);
149
+ box-shadow: var(--tf-group-shadow, none);
150
+ }
151
+
152
+ .menu-divider {
153
+ margin: 0;
154
+ width: var(--tf-divider-width, 1px);
155
+ height: var(--tf-divider-height, 20px);
156
+ background: var(--tf-divider-color, rgba(var(--v-border-color), var(--v-border-opacity)));
157
+ }
73
158
  }
74
159
  </style>