tiptapify 0.3.3 → 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 (35) hide show
  1. package/README.md +1 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +15750 -15398
  4. package/dist/tiptapify.umd.js +64 -64
  5. package/index.d.ts +95 -0
  6. package/package.json +2 -1
  7. package/src/components/ImageBubbleMenu.vue +16 -7
  8. package/src/components/MenuBubble.vue +45 -7
  9. package/src/components/SkinScope.test.ts +74 -0
  10. package/src/components/SkinScope.vue +18 -0
  11. package/src/components/Tiptapify.skin.test.ts +186 -0
  12. package/src/components/Tiptapify.vue +115 -21
  13. package/src/components/Toolbar/Index.vue +43 -62
  14. package/src/components/Toolbar/Items.test.ts +58 -2
  15. package/src/components/Toolbar/Items.vue +93 -7
  16. package/src/components/UI/TiptapifyDialog.test.ts +132 -0
  17. package/src/components/UI/TiptapifyDialog.vue +90 -40
  18. package/src/composables/useTiptapifyConfig.test.ts +25 -2
  19. package/src/composables/useTiptapifyConfig.ts +21 -2
  20. package/src/constants/defaults.ts +2 -1
  21. package/src/extensions/components/alignment/menu/Button.vue +21 -17
  22. package/src/extensions/components/list/bullet/Button.vue +20 -16
  23. package/src/extensions/components/list/styles/ListStyleButton.vue +25 -25
  24. package/src/extensions/components/style/heading/Button.vue +33 -29
  25. package/src/index.test.ts +88 -0
  26. package/src/index.ts +15 -0
  27. package/src/skins/__fixtures__/skin-baseline.json +805 -0
  28. package/src/skins/builtins.ts +81 -0
  29. package/src/skins/menu.css +87 -0
  30. package/src/skins/menu.ts +10 -0
  31. package/src/skins/registry.test.ts +180 -0
  32. package/src/skins/registry.ts +132 -0
  33. package/src/skins/tokens.reachability.test.ts +43 -0
  34. package/src/skins/tokens.ts +92 -0
  35. package/src/types/editor.ts +99 -0
@@ -6,7 +6,15 @@ import { SlashCommandsConfig } from '@tiptapify/types/slashCommandsTypes'
6
6
  import { computed, onBeforeUnmount, PropType, provide, ref, ShallowRef, toRef } from 'vue'
7
7
  import { default as Toolbar } from '@tiptapify/components/Toolbar/Index.vue'
8
8
  import { Editor, EditorContent } from '@tiptap/vue-3'
9
- import { TiptapifyAiOptions, TiptapifyAiResolvedOptions, TiptapifyEditor, TiptapifyFooterAlignment, variantBtnTypes, variantFieldTypes } from '@tiptapify/types/editor'
9
+ import {
10
+ TiptapifyAiOptions,
11
+ TiptapifyAiResolvedOptions,
12
+ TiptapifyEditor,
13
+ TiptapifyFooterAlignment,
14
+ TiptapifySkinInput,
15
+ variantBtnTypes,
16
+ variantFieldTypes
17
+ } from '@tiptapify/types/editor'
10
18
  import MenuBubble from '@tiptapify/components/MenuBubble.vue'
11
19
  import MenuFloating from '@tiptapify/components/MenuFloating.vue'
12
20
  import ImageBubbleMenu from '@tiptapify/components/ImageBubbleMenu.vue'
@@ -15,6 +23,7 @@ import { useI18n } from 'vue-i18n'
15
23
 
16
24
  import { getTiptapEditor } from '@tiptapify/components/index'
17
25
  import { TIPTAPIFY_CONFIG_KEY } from '@tiptapify/composables/useTiptapifyConfig'
26
+ import { resolveSkin } from '@tiptapify/skins/registry'
18
27
  import { createAiBackendProvider } from '@tiptapify/extensions/components/ai/backend'
19
28
 
20
29
  import Footer from '@tiptapify/components/Footer.vue'
@@ -52,6 +61,8 @@ const props = defineProps({
52
61
  loadingHeight: { type: String, default() { return '1px' } },
53
62
  // From this many images in a row the editor shows them as a slider grid; 0 turns it off
54
63
  imageSlider: { type: Number, default() { return 0 } },
64
+ // The skin of the editor chrome: a registered name ('default', 'pastel') or an inline skin object
65
+ skin: { type: [String, Object] as PropType<TiptapifySkinInput>, default() { return defaults.skin } },
55
66
  })
56
67
 
57
68
  const emit = defineEmits(['update:modelValue', 'editor-ready', 'content-changed'])
@@ -151,9 +162,33 @@ const scrollContainer = ref<HTMLElement | null>(null)
151
162
  provide('tiptapifyScrollContainer', scrollContainer)
152
163
  provide('tiptapifyI18n', { t })
153
164
  provide('tiptapifyAi', tiptapifyAi)
165
+ // The skin prop resolves through the registry; computed refs so a prop change
166
+ // re-resolves tokens and shape without an editor re-render
167
+ const resolvedSkin = computed(() => resolveSkin(props.skin))
168
+
169
+ // The shape classes: `.tiptapify-shape-{flag}-{value}` for all six flags, so CSS gates on
170
+ // shape (not on the skin name); templates read the computed refs. The name class stays
171
+ // as the escape hatch for consumer CSS.
172
+ const shapeClasses = computed(() => {
173
+ const shape = resolvedSkin.value.shape
174
+
175
+ return [
176
+ `tiptapify-shape-toolbar-${shape.toolbar}`,
177
+ `tiptapify-shape-groups-${shape.groups}`,
178
+ `tiptapify-shape-buttons-${shape.buttons}`,
179
+ `tiptapify-shape-bubble-${shape.bubble}`,
180
+ `tiptapify-shape-dialog-${shape.dialog}`,
181
+ `tiptapify-shape-menu-${shape.menu}`,
182
+ ].join(' ')
183
+ })
184
+
154
185
  provide(TIPTAPIFY_CONFIG_KEY, {
155
186
  variantBtn: toRef(props, 'variantBtn'),
156
187
  variantField: toRef(props, 'variantField'),
188
+ skin: toRef(props, 'skin'),
189
+ skinName: computed(() => resolvedSkin.value.name),
190
+ tokens: computed(() => resolvedSkin.value.tokens),
191
+ shape: computed(() => resolvedSkin.value.shape),
157
192
  })
158
193
 
159
194
  editor.value?.chain().setFontFamily(props.defaultFontFamily).run()
@@ -173,7 +208,11 @@ onBeforeUnmount(() => {
173
208
  </script>
174
209
 
175
210
  <template>
176
- <div :id="`tiptapify-editor-${editor?.instanceId}`">
211
+ <div
212
+ :id="`tiptapify-editor-${editor?.instanceId}`"
213
+ :class="[resolvedSkin.className, shapeClasses]"
214
+ :style="resolvedSkin.tokens"
215
+ >
177
216
  <div :class="`tiptapify-frame rounded-${rounded}`">
178
217
  <template v-if="toolbar">
179
218
  <Toolbar
@@ -245,11 +284,28 @@ onBeforeUnmount(() => {
245
284
  --border: 1px solid var(--gray-2);
246
285
  }
247
286
 
287
+ /*
288
+ * The frame: one rule, skin-agnostic, reading the frame tokens (classic fallbacks).
289
+ * The classic shape keeps `border-top: 0`: the raised toolbar sits on the frame's top
290
+ * edge, so no line is needed there. The per-side deltas of the flat shape (full border,
291
+ * bottom edge at 1.6x border opacity) are a property of that shape, not of any one
292
+ * skin, so the gate is the toolbar shape class. `--tf-frame-border` is the shorthand
293
+ * for all sides; the bottom emphasis composes from Vuetify's border-opacity (it is a
294
+ * shading rule, not a catalog token).
295
+ */
248
296
  .tiptapify-frame {
249
- border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
297
+ border: var(--tf-frame-border, 1px solid rgba(var(--v-border-color), var(--v-border-opacity)));
298
+ border-top: 0;
299
+ background: var(--tf-frame-bg, transparent);
300
+ box-shadow: var(--tf-frame-shadow, none);
301
+ }
302
+
303
+ .tiptapify-shape-toolbar-flat .tiptapify-frame {
304
+ /* A flat toolbar lives inside the frame: the top edge closes like the other sides... */
305
+ border-top: var(--tf-frame-border, 1px solid rgba(var(--v-border-color), var(--v-border-opacity)));
306
+
307
+ /* ...and the bottom edge carries the frame's visual weight (1.6x border opacity) */
250
308
  border-bottom-color: rgba(var(--v-border-color), calc(var(--v-border-opacity) * 1.6));
251
- background: rgb(var(--v-theme-surface));
252
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
253
309
  }
254
310
 
255
311
  .resizable {
@@ -557,29 +613,24 @@ onBeforeUnmount(() => {
557
613
  max-width: 100%;
558
614
  height: auto !important;
559
615
  margin: 0;
560
- border-radius: 4px;
561
- background: rgba(var(--v-theme-on-surface), 0.04);
562
616
  }
563
617
 
564
618
  [data-resize-wrapper] {
565
619
  max-width: min(100%, calc(var(--tiptapify-image-max-height) * var(--tiptapify-image-ratio, 100)));
566
- border-radius: 4px;
567
- outline: 2px solid transparent;
568
- outline-offset: 2px;
620
+ border-radius: var(--tf-image-radius, 0.125rem);
621
+ outline: 1px solid transparent;
569
622
  transition: outline-color 0.15s;
570
623
  }
571
624
 
572
625
  /* One block: hovering outlines it, a click selects it and shows its toolbar */
573
626
  img {
574
627
  cursor: pointer;
575
- }
576
-
577
- &:hover [data-resize-wrapper] {
578
- outline-color: rgba(var(--v-theme-primary), 0.35);
628
+ border-radius: var(--tf-image-radius, 0px);
629
+ background: var(--tf-image-bg, transparent);
579
630
  }
580
631
 
581
632
  &.ProseMirror-selectednode [data-resize-wrapper] {
582
- outline-color: rgb(var(--v-theme-primary));
633
+ outline: var(--tf-image-outline, 1px solid rgba(0, 0, 0, 0.25));
583
634
  }
584
635
 
585
636
  /* Centered unless floated; a floated image lets the text wrap around it */
@@ -696,7 +747,7 @@ onBeforeUnmount(() => {
696
747
  }
697
748
  }
698
749
 
699
- /* Resize handles of a selected image: dots on its corners and edge middles */
750
+ /* Resize handles of a selected image: dark squares at the corners and edge middles */
700
751
  [data-node='image'] [data-resize-handle] {
701
752
  display: none;
702
753
  }
@@ -707,10 +758,19 @@ onBeforeUnmount(() => {
707
758
  display: block;
708
759
  width: 10px;
709
760
  height: 10px;
710
- border: 2px solid rgb(var(--v-theme-surface));
711
- border-radius: 50%;
712
- background: rgb(var(--v-theme-primary));
713
- box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15);
761
+ border: var(--tf-handle-border, none);
762
+ border-radius: var(--tf-handle-radius, 2px);
763
+ background: var(--tf-handle-bg, rgba(0, 0, 0, 0.5));
764
+ box-shadow: var(--tf-handle-shadow, none);
765
+ transition: background-color 0.15s;
766
+
767
+ /* Longhand on purpose: the hover needle in the measurement bundle reads
768
+ * `background-color` from CSSOM, and a shorthand carrying var() has no
769
+ * retrievable longhand (pending-substitution value).
770
+ */
771
+ &:hover {
772
+ background-color: var(--tf-handle-bg-hover, rgba(0, 0, 0, 0.8));
773
+ }
714
774
 
715
775
  &[data-resize-handle='top-left'] { top: -5px !important; left: -5px !important; cursor: nwse-resize; }
716
776
  &[data-resize-handle='top-right'] { top: -5px !important; right: -5px !important; left: auto !important; cursor: nesw-resize; }
@@ -723,6 +783,29 @@ onBeforeUnmount(() => {
723
783
  }
724
784
  }
725
785
 
786
+ /*
787
+ * The quiet image chrome (shape: `buttons: quiet`): the outline around an image becomes an
788
+ * inset 2px ring (the classic flush 1px hairline is the base rule's fallback) and hovering
789
+ * tints it. Gated on the buttons shape because that is the flag of the 0.3.3 quiet look this
790
+ * structure belongs to; the colors stay tokens (`--tf-image-outline*`), the structural
791
+ * width/offset pair is the shape's base.
792
+ */
793
+ .tiptapify-shape-buttons-quiet .tiptap .tiptapify-image {
794
+ [data-resize-wrapper] {
795
+ outline: 2px solid transparent;
796
+ outline-offset: 2px;
797
+ }
798
+
799
+ &:hover [data-resize-wrapper] {
800
+ outline-color: var(--tf-image-outline-hover, rgba(var(--v-theme-primary), 0.35));
801
+ }
802
+
803
+ &.ProseMirror-selectednode [data-resize-wrapper] {
804
+ outline: var(--tf-image-outline, 2px solid rgb(var(--v-theme-primary)));
805
+ outline-offset: 2px;
806
+ }
807
+ }
808
+
726
809
  /*
727
810
  * Dialog layout primitives.
728
811
  *
@@ -739,7 +822,7 @@ onBeforeUnmount(() => {
739
822
  */
740
823
  .tiptapify-dialog-grid {
741
824
  display: grid;
742
- gap: 16px;
825
+ gap: var(--tf-dialog-grid-gap, 24px);
743
826
  grid-template-columns: repeat(12, minmax(0, 1fr));
744
827
 
745
828
  > .tiptapify-dialog-col {
@@ -766,6 +849,17 @@ onBeforeUnmount(() => {
766
849
  }
767
850
  }
768
851
 
852
+ /*
853
+ * The soft dialog grid.
854
+ *
855
+ * The dialog is teleported to the overlay root, so it has no shape class
856
+ * ancestor: the shape reaches it through the dialog's own modifier class,
857
+ * the same way every other soft dialog style applies.
858
+ */
859
+ .tiptapify-dialog--soft .tiptapify-dialog-grid {
860
+ gap: var(--tf-dialog-grid-gap, 16px);
861
+ }
862
+
769
863
  .tiptapify-dialog-actions {
770
864
  display: flex;
771
865
  flex-wrap: wrap;
@@ -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,6 +24,10 @@ 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
 
@@ -272,7 +277,13 @@ toolbarItems = toolbarItems
272
277
 
273
278
  <template>
274
279
  <div v-if="editor" class="tiptapify-toolbar">
275
- <VToolbar flat :theme="theme" height="auto" :class="`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
+ >
276
287
  <VSlideGroup v-if="toolbarScrollable">
277
288
  <Items :items="toolbarItems" />
278
289
  </VSlideGroup>
@@ -284,30 +295,39 @@ toolbarItems = toolbarItems
284
295
 
285
296
  <style lang="scss">
286
297
  /*
287
- * Quiet toolbar: a tinted bar under a hairline, ghost icon buttons that only get a background on
288
- * hover, and a neutral "pressed" state instead of the filled primary button. The buttons keep
289
- * coloring themselves with `color="primary"` when active; the toolbar maps that to the pressed look.
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).
290
301
  */
291
- .tiptapify-toolbar {
302
+ .tiptapify-shape-toolbar-flat .tiptapify-toolbar {
292
303
  > .v-toolbar {
293
- background: rgba(var(--v-theme-on-surface), 0.03);
294
- border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
295
- color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
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)));
296
307
  }
297
308
  }
298
309
 
299
310
  /*
300
- * Quiet buttons of the toolbar and the bubble menus. Tripled class: outranks Vuetify's color utilities and
301
- * the theme rules that repaint colored buttons on hover, focus and press (`.bg-primary:focus` and the like).
302
- * Active buttons keep `color="primary"` (or `.tiptapify-btn--active`) and get the neutral pressed look.
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).
303
323
  */
304
- .tiptapify-toolbar,
324
+ .tiptapify-shape-buttons-quiet .tiptapify-toolbar,
305
325
  .tiptapify-quiet-buttons {
306
326
  .v-btn.v-btn.v-btn {
307
- border-radius: 6px;
308
- box-shadow: none !important;
309
- background-color: transparent !important;
310
- color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !important;
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)));
311
331
  transition: background-color 0.15s, color 0.15s;
312
332
 
313
333
  > .v-btn__overlay,
@@ -318,8 +338,8 @@ toolbarItems = toolbarItems
318
338
  &:hover,
319
339
  &:focus-visible,
320
340
  &[aria-expanded='true'] {
321
- background-color: rgba(var(--v-theme-on-surface), 0.07) !important;
322
- color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
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;
323
343
  }
324
344
 
325
345
  &.bg-primary,
@@ -328,14 +348,14 @@ toolbarItems = toolbarItems
328
348
  &,
329
349
  &:focus,
330
350
  &:active {
331
- background-color: rgba(var(--v-theme-on-surface), 0.1) !important;
332
- color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
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;
333
353
  }
334
354
 
335
355
  &:hover,
336
356
  &:focus-visible,
337
357
  &[aria-expanded='true'] {
338
- background-color: rgba(var(--v-theme-on-surface), 0.14) !important;
358
+ background-color: var(--tf-button-active-hover-bg, rgba(var(--v-theme-on-surface), 0.14)) !important;
339
359
  }
340
360
  }
341
361
 
@@ -357,45 +377,6 @@ toolbarItems = toolbarItems
357
377
  }
358
378
  }
359
379
 
360
- /* Dropdowns opened from the toolbar: a light card with compact rows of icon + label. */
361
- .tiptapify-menu {
362
- /* Doubled classes: the admin theme pads menu lists and items with its own !important rules */
363
- > .v-list.v-list {
364
- padding: 4px !important;
365
- border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
366
- border-radius: 8px;
367
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
368
-
369
- > .v-list-item.v-list-item {
370
- min-height: 34px;
371
- margin: 0 !important;
372
- padding: 4px 8px !important;
373
- border-radius: 6px;
374
- color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
375
-
376
- .v-list-item__prepend > .v-icon {
377
- margin-inline-end: 0;
378
- opacity: 0.6;
379
- }
380
-
381
- .v-list-item__spacer {
382
- width: 10px !important;
383
- }
384
-
385
- .v-list-item-title {
386
- font-size: 14px;
387
- font-weight: 500;
388
- }
389
-
390
- &.v-list-item--active {
391
- background: rgba(var(--v-theme-on-surface), 0.06);
392
- color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
393
-
394
- > .v-list-item__overlay {
395
- opacity: 0;
396
- }
397
- }
398
- }
399
- }
400
- }
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). */
401
382
  </style>
@@ -1,11 +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
7
  import AlignmentMenuButton from '@tiptapify/extensions/components/alignment/menu/Button.vue'
8
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'
9
12
 
10
13
  const editor = {
11
14
  instanceId: 1,
@@ -23,16 +26,31 @@ const items = [
23
26
  },
24
27
  ]
25
28
 
26
- 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
+ ) {
27
34
  const warnings: string[] = []
28
35
  const warn = vi.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => {
29
36
  warnings.push(args.map(String).join(' '))
30
37
  })
31
38
 
39
+ // The shape the section markup reads: exactly the chain a real editor provides
40
+ const resolved = resolveSkin(skin as never)
41
+
32
42
  const app = createSSRApp({
33
43
  setup () {
34
44
  provide('tiptapifyEditor', ref(editor))
35
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
+ })
36
54
 
37
55
  return () => h(Items, { items: toolbarItems })
38
56
  },
@@ -45,6 +63,13 @@ async function renderToolbar (toolbarItems: typeof items = items) {
45
63
  return { html, warnings }
46
64
  }
47
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
+
48
73
  describe('Toolbar Items', () => {
49
74
  afterEach(() => {
50
75
  headingLevels.value = [1, 2, 3, 4, 5, 6]
@@ -96,4 +121,35 @@ describe('Toolbar Items', () => {
96
121
  expect(html).toContain('tiptapify-align-button-1')
97
122
  expect(html).toContain('tiptapify-dropdown-button__chevron')
98
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
+ })
99
155
  })
@@ -4,10 +4,17 @@ import { PropType } from 'vue'
4
4
 
5
5
  import { toolbarSections } from '@tiptapify/types/toolbarTypes'
6
6
 
7
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
8
+
7
9
  defineProps({
8
10
  items: { type: Array as PropType<toolbarSections>, default() { return {} } },
9
11
  })
10
12
 
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()
17
+
11
18
  </script>
12
19
 
13
20
  <template>
@@ -20,7 +27,17 @@ defineProps({
20
27
  <div class="tiptapify-toolbar-spacer" />
21
28
  </template>
22
29
  <template v-else>
23
- <div v-if="item.group" class="tiptapify-toolbar-group">
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
+ >
36
+ <template v-for="sectionItem in item.components" :key="sectionItem.name">
37
+ <component :is="sectionItem.component" v-bind="sectionItem.props ?? {}" />
38
+ </template>
39
+ </VBtnGroup>
40
+ <div v-else-if="item.group" class="tiptapify-toolbar-group">
24
41
  <template v-for="sectionItem in item.components" :key="sectionItem.name">
25
42
  <component :is="sectionItem.component" v-bind="sectionItem.props ?? {}" />
26
43
  </template>
@@ -40,26 +57,44 @@ defineProps({
40
57
  </template>
41
58
 
42
59
  <style lang="scss" scoped>
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
+ */
43
66
  .tiptapify-toolbar-items {
44
67
  /* Vuetify sizes .v-toolbar-items to its content; grow it to the full toolbar width,
45
68
  * otherwise the `~` spacer has no free space to expand into. */
46
69
  flex-grow: 1;
47
70
  flex-wrap: wrap;
48
71
  align-items: center;
49
- gap: 4px 6px;
50
- padding: 6px 8px;
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);
51
76
  }
52
77
 
53
78
  .tiptapify-toolbar-group {
54
79
  display: flex;
55
80
  align-items: center;
56
- gap: 2px;
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)));
86
+ box-shadow:
87
+ var(--tf-group-shadow,
88
+ 0 3px 1px -2px var(--v-shadow-key-umbra-opacity, rgba(0, 0, 0, 0.2)),
89
+ 0 2px 2px 0 var(--v-shadow-key-penumbra-opacity, rgba(0, 0, 0, 0.14)),
90
+ 0 1px 5px 0 var(--v-shadow-key-ambient-opacity, rgba(0, 0, 0, 0.12)));
57
91
  }
58
92
 
59
93
  .menu-divider {
60
- width: 1px;
61
- height: 20px;
62
- background: rgba(var(--v-border-color), var(--v-border-opacity));
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);
63
98
  }
64
99
 
65
100
  .menu-divider:nth-last-child(1),
@@ -70,4 +105,55 @@ defineProps({
70
105
  .tiptapify-toolbar-spacer {
71
106
  flex: 1 0 0;
72
107
  }
108
+
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
+ }
116
+ }
117
+
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
+ }
139
+ }
140
+
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
+ }
158
+ }
73
159
  </style>