frappe-ui 1.0.0-beta.21 → 1.0.0-beta.25

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 (30) hide show
  1. package/package.json +3 -2
  2. package/src/components/Avatar/Avatar.api.md +7 -1
  3. package/src/components/Avatar/Avatar.cy.ts +21 -0
  4. package/src/components/Avatar/Avatar.md +5 -2
  5. package/src/components/Avatar/Avatar.vue +20 -6
  6. package/src/components/Avatar/index.ts +1 -1
  7. package/src/components/Avatar/stories/Themes.vue +23 -0
  8. package/src/components/Avatar/types.ts +11 -0
  9. package/src/components/Calendar/CalendarMonthEvent.vue +2 -2
  10. package/src/components/Calendar/CalendarMonthly.vue +10 -9
  11. package/src/components/Calendar/CalendarWeekly.vue +1 -1
  12. package/src/components/Calendar/calendarUtils.ts +71 -149
  13. package/src/components/Calendar/stories/CustomHeader.vue +8 -6
  14. package/src/components/Calendar/stories/Examples.vue +7 -5
  15. package/src/components/Calendar/style.css +1 -1
  16. package/src/components/Calendar/useEventBase.ts +12 -0
  17. package/src/components/Dialog/Dialog.cy.ts +80 -21
  18. package/src/components/Dialog/Dialog.vue +0 -1
  19. package/src/components/ErrorMessage/ErrorMessage.test.ts +45 -0
  20. package/src/components/ErrorMessage/ErrorMessage.vue +9 -4
  21. package/src/components/TextEditor/extensions/suggestion/SuggestionList.vue +28 -29
  22. package/src/components/Tree/TreeItem.vue +6 -2
  23. package/src/molecules/editor/Editor.vue +6 -2
  24. package/src/molecules/editor/extensions.ts +5 -0
  25. package/src/molecules/editor/useEditor.test.ts +72 -0
  26. package/src/molecules/editor/useEditor.ts +31 -4
  27. package/vitepress/components/Docs/Sidebar.vue +3 -3
  28. package/vitepress/components/Navbar.vue +5 -2
  29. package/vitepress/index.node.ts +5 -0
  30. package/vitepress/plugins/preventCircularChunks.ts +54 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "1.0.0-beta.21",
3
+ "version": "1.0.0-beta.25",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -172,6 +172,7 @@
172
172
  "@tiptap/extension-typography": "^3.26.0",
173
173
  "@tiptap/extension-underline": "^3.26.0",
174
174
  "@tiptap/extensions": "^3.26.0",
175
+ "@tiptap/markdown": "^3.26.0",
175
176
  "@tiptap/pm": "^3.26.0",
176
177
  "@tiptap/starter-kit": "^3.26.0",
177
178
  "@tiptap/suggestion": "^3.26.0",
@@ -192,7 +193,7 @@
192
193
  "ora": "5.4.1",
193
194
  "prettier": "^3.3.2",
194
195
  "prosemirror-tables": "^1.8.1",
195
- "reka-ui": "^2.5.0",
196
+ "reka-ui": "^2.9.9",
196
197
  "slugify": "^1.6.6",
197
198
  "socket.io-client": "^4.5.1",
198
199
  "tippy.js": "^6.3.7",
@@ -30,6 +30,13 @@
30
30
  required: false,
31
31
  type: '"circle" | "square"',
32
32
  default: '"circle"'
33
+ },
34
+ {
35
+ name: 'theme',
36
+ description: 'Visual color theme used for the fallback avatar',
37
+ required: false,
38
+ type: '"gray" | "blue" | "green" | "amber" | "red" | "violet"',
39
+ default: '"gray"'
33
40
  }
34
41
  ]
35
42
 
@@ -51,4 +58,3 @@
51
58
  <PropsTable name="Avatar" :data="propsData"/>
52
59
 
53
60
  <SlotsTable :data="slotsData"/>
54
-
@@ -44,4 +44,25 @@ describe('Avatar', () => {
44
44
 
45
45
  cy.get('[data-cy="avatar"]').should('have.text', 'A')
46
46
  })
47
+
48
+ it('Uses gray fallback theme by default', () => {
49
+ cy.mount(Avatar, {
50
+ props: { 'data-cy': 'avatar', label: 'Abc' },
51
+ })
52
+
53
+ cy.get('[data-cy="avatar"] > div')
54
+ .should('have.class', 'bg-surface-gray-2')
55
+ .and('have.class', 'text-ink-gray-5')
56
+ })
57
+
58
+ it('Supports colorful fallback themes', () => {
59
+ cy.mount(Avatar, {
60
+ props: { 'data-cy': 'avatar', label: 'Abc', theme: 'blue' },
61
+ })
62
+
63
+ cy.get('[data-cy="avatar"] > div')
64
+ .should('have.class', 'bg-surface-blue-2')
65
+ .and('have.class', 'text-ink-blue-8')
66
+ })
67
+
47
68
  })
@@ -6,10 +6,13 @@ A visual representation of a user, typically shown as an image, initials, or an
6
6
 
7
7
  <ClientOnly><AvatarBuilder /></ClientOnly>
8
8
 
9
- ## Themes
9
+ ## Shapes
10
10
  <ComponentPreview name="Avatar-Shapes" />
11
11
 
12
- ## Controlled state
12
+ ## Sizes
13
13
  <ComponentPreview name="Avatar-Sizes" />
14
14
 
15
+ ## Themes
16
+ <ComponentPreview name="Avatar-Themes" />
17
+
15
18
  <!-- @include: ./Avatar.api.md -->
@@ -12,8 +12,8 @@
12
12
  />
13
13
  <div
14
14
  v-else
15
- class="flex h-full w-full items-center justify-center bg-surface-gray-2 uppercase text-ink-gray-5 select-none"
16
- :class="[labelClasses, shapeClasses]"
15
+ class="flex h-full w-full items-center justify-center uppercase select-none"
16
+ :class="[labelClasses, fallbackThemeClasses, shapeClasses]"
17
17
  >
18
18
  <div :class="iconClasses" v-if="$slots.default">
19
19
  <slot></slot>
@@ -38,13 +38,26 @@
38
38
 
39
39
  <script setup lang="ts">
40
40
  import { ref, computed, useAttrs } from 'vue'
41
- import type { AvatarProps } from './types'
41
+ import type { AvatarProps, AvatarTheme } from './types'
42
42
 
43
43
  const imgFetchError = ref(false)
44
44
 
45
45
  const props = withDefaults(defineProps<AvatarProps>(), {
46
46
  size: 'md',
47
47
  shape: 'circle',
48
+ theme: 'gray',
49
+ })
50
+
51
+ const fallbackThemeClasses = computed(() => {
52
+ const classes: Record<AvatarTheme, string> = {
53
+ gray: 'bg-surface-gray-2 text-ink-gray-5',
54
+ blue: 'bg-surface-blue-2 text-ink-blue-8',
55
+ green: 'bg-surface-green-2 text-ink-green-8',
56
+ amber: 'bg-surface-amber-2 text-ink-amber-8',
57
+ red: 'bg-surface-red-2 text-ink-red-8',
58
+ violet: 'bg-surface-violet-2 text-ink-violet-8',
59
+ }
60
+ return classes[props.theme]
48
61
  })
49
62
 
50
63
  // Let a consumer size the avatar with a Tailwind utility (`class="size-16"`)
@@ -60,7 +73,9 @@ const hasSizeOverride = computed(() => {
60
73
  ? attrs.class
61
74
  : ''
62
75
  return cls.split(/\s+/).some((token) => {
63
- const base = token.includes(':') ? token.slice(token.lastIndexOf(':') + 1) : token
76
+ const base = token.includes(':')
77
+ ? token.slice(token.lastIndexOf(':') + 1)
78
+ : token
64
79
  return /^-?(size|w|h|min-w|max-w|min-h|max-h)-/.test(base)
65
80
  })
66
81
  })
@@ -142,7 +157,7 @@ const iconClasses = computed(() => {
142
157
  }[props.size]
143
158
  })
144
159
 
145
- function handleImageError(err) {
160
+ function handleImageError(err: Event) {
146
161
  if (err.type) {
147
162
  imgFetchError.value = true
148
163
  }
@@ -155,5 +170,4 @@ defineSlots<{
155
170
  /** Small indicator shown at the bottom-right of the avatar */
156
171
  indicator?: () => any
157
172
  }>()
158
-
159
173
  </script>
@@ -1,2 +1,2 @@
1
1
  export { default as Avatar } from './Avatar.vue'
2
- export type { AvatarProps } from './types'
2
+ export type { AvatarProps, AvatarTheme } from './types'
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ import { Avatar } from 'frappe-ui'
3
+ import type { AvatarTheme } from 'frappe-ui'
4
+
5
+ const themes: AvatarTheme[] = [
6
+ 'gray',
7
+ 'blue',
8
+ 'green',
9
+ 'amber',
10
+ 'red',
11
+ 'violet',
12
+ ]
13
+ </script>
14
+
15
+ <template>
16
+ <Avatar
17
+ v-for="theme in themes"
18
+ :key="theme"
19
+ :label="theme"
20
+ :theme="theme"
21
+ size="lg"
22
+ />
23
+ </template>
@@ -1,3 +1,11 @@
1
+ export type AvatarTheme =
2
+ | 'gray'
3
+ | 'blue'
4
+ | 'green'
5
+ | 'amber'
6
+ | 'red'
7
+ | 'violet'
8
+
1
9
  export interface AvatarProps {
2
10
  /** Image URL used for the avatar */
3
11
  image?: string
@@ -10,4 +18,7 @@ export interface AvatarProps {
10
18
 
11
19
  /** Defines the avatar shape */
12
20
  shape?: 'circle' | 'square'
21
+
22
+ /** Visual color theme used for the fallback avatar */
23
+ theme?: AvatarTheme
13
24
  }
@@ -25,10 +25,10 @@
25
25
  class="relative flex h-full select-none items-start gap-2 overflow-hidden"
26
26
  >
27
27
  <div v-if="config.showIcon && eventIcon">
28
- <component :is="eventIcon" class="h-4 w-4 text-black" />
28
+ <component :is="eventIcon" class="h-4 w-4 text-ink-gray-8" />
29
29
  </div>
30
30
  <p
31
- class="text-sm-medium truncate"
31
+ class="text-sm-medium truncate text-ink-gray-8"
32
32
  :class="{ italic: !props.event.title }"
33
33
  >
34
34
  {{ props.event.title || '[No title]' }}
@@ -37,20 +37,17 @@
37
37
  >
38
38
  <span
39
39
  class="w-full flex justify-between items-center"
40
- :class="[
41
- date.toDateString() === new Date().toDateString()
42
- ? 'p-[3px] pb-0.5'
43
- : 'p-2',
44
- ]"
40
+ :class="[isToday(date) ? 'p-[3px] pb-0.5' : 'p-2']"
45
41
  >
46
42
  <div></div>
47
43
  <div
48
44
  class="cursor-pointer"
49
45
  :class="[
50
- date.toDateString() === new Date().toDateString()
51
- ? 'flex items-center justify-center bg-surface-gray-10 text-ink-base rounded size-[25px]'
52
- : 'bg-surface-base ',
53
- isCurrentMonth(date) ? 'text-ink-gray-6' : 'text-ink-gray-4',
46
+ !isCurrentMonth(date)
47
+ ? 'bg-surface-base text-ink-gray-4'
48
+ : isToday(date)
49
+ ? 'flex items-center justify-center bg-surface-gray-10 text-ink-gray-2 rounded size-[25px]'
50
+ : 'bg-surface-base text-ink-gray-8',
54
51
  ]"
55
52
  @click.stop="
56
53
  isCurrentMonth(date)
@@ -145,6 +142,10 @@ const maxEventsInCell = computed(() =>
145
142
  props.currentMonthDates.length > 35 ? 1 : 2,
146
143
  )
147
144
 
145
+ function isToday(date: Date) {
146
+ return date.toDateString() === new Date().toDateString()
147
+ }
148
+
148
149
  function isCurrentMonth(date: Date) {
149
150
  return date.getMonth() === props.currentMonth
150
151
  }
@@ -12,7 +12,7 @@
12
12
  {{ isToday(date) ? daysList[date.getDay()] : parseDateWithDay(date) }}
13
13
  <span
14
14
  v-if="isToday(date)"
15
- class="inline-flex items-center justify-center bg-surface-gray-10 text-ink-base rounded size-[25px]"
15
+ class="inline-flex items-center justify-center bg-surface-gray-10 text-ink-gray-1 rounded size-[25px]"
16
16
  >
17
17
  {{ date.getDate() }}
18
18
  </span>
@@ -304,170 +304,92 @@ export function formatTime(time: string, format: CalendarTimeFormat): string {
304
304
 
305
305
  export const colorMap: Record<string, CalendarColor> = {
306
306
  amber: {
307
- color: '#DB7706',
308
- border: '#DB7706',
309
- borderActive: '#FBCC55',
310
- text: '#91400D',
311
- subtext: '#AD8460',
312
- subtextActive: '#FAEBD0',
313
- bg: '#FFF7D3',
314
- bgHover: '#FEEDA9',
315
- bgActive: '#E79913',
307
+ color: 'var(--ink-amber-7)',
308
+ border: 'var(--ink-amber-7)',
309
+ borderActive: 'var(--outline-amber-2)',
310
+ text: 'var(--ink-amber-7)',
311
+ textActive: 'var(--ink-amber-1)',
312
+ subtext: 'var(--ink-gray-6)',
313
+ subtextActive: 'var(--ink-amber-1)',
314
+ bg: 'var(--surface-amber-1)',
315
+ bgHover: 'var(--surface-amber-1)',
316
+ bgActive: 'var(--surface-amber-2)',
316
317
  },
317
318
  violet: {
318
- color: '#6846E3',
319
- border: '#6846E3',
320
- borderActive: '#B3A1F5',
321
- text: '#5F46C7',
322
- subtext: '#766D9B',
323
- subtextActive: '#E4DCFD',
324
- bg: '#F0EBFF',
325
- bgHover: '#DBD5FF',
326
- bgActive: '#7A51F4',
319
+ color: 'var(--ink-violet-7)',
320
+ border: 'var(--ink-violet-7)',
321
+ borderActive: 'var(--outline-gray-4)',
322
+ text: 'var(--ink-violet-7)',
323
+ textActive: 'var(--ink-violet-1)',
324
+ subtext: 'var(--ink-gray-6)',
325
+ subtextActive: 'var(--ink-base)',
326
+ bg: 'var(--surface-violet-1)',
327
+ bgHover: 'var(--surface-violet-1)',
328
+ bgActive: 'var(--surface-violet-7)',
327
329
  },
328
330
  pink: {
329
- color: '#E34AA6',
330
- border: '#E34AA6',
331
- borderActive: '#F6A7D6',
332
- text: '#CF3A96',
333
- subtext: '#B26997',
334
- subtextActive: '#F9DBED',
335
- bg: '#FDE8F5',
336
- bgHover: '#FFD5F0',
337
- bgActive: '#E34AA6',
331
+ color: 'var(--ink-pink-7)',
332
+ border: 'var(--ink-pink-7)',
333
+ borderActive: 'var(--outline-gray-4)',
334
+ text: 'var(--ink-pink-7)',
335
+ textActive: 'var(--ink-pink-1)',
336
+ subtext: 'var(--ink-gray-6)',
337
+ subtextActive: 'var(--ink-base)',
338
+ bg: 'var(--surface-pink-1)',
339
+ bgHover: 'var(--surface-pink-1)',
340
+ bgActive: 'var(--surface-pink-7)',
338
341
  },
339
342
  cyan: {
340
- color: '#3BBDE5',
341
- border: '#3BBDE5',
342
- borderActive: '#72D5F3',
343
- text: '#267A94',
344
- subtext: '#668E9C',
345
- subtextActive: '#D6EDF4',
346
- bg: '#DDF7FF',
347
- bgHover: '#B3E8F7',
348
- bgActive: '#32A4C7',
343
+ color: 'var(--ink-cyan-7)',
344
+ border: 'var(--ink-cyan-7)',
345
+ borderActive: 'var(--ink-cyan-7)',
346
+ text: 'var(--ink-cyan-7)',
347
+ textActive: 'var(--ink-cyan-1)',
348
+ subtext: 'var(--ink-gray-6)',
349
+ subtextActive: 'var(--ink-base)',
350
+ bg: 'var(--surface-cyan-1)',
351
+ bgHover: 'var(--surface-cyan-1)',
352
+ bgActive: 'var(--surface-cyan-7)',
349
353
  },
350
354
  blue: {
351
- color: '#0289F7',
352
- border: '#0289F7',
353
- borderActive: '#A7D7FD',
354
- text: '#007BE0',
355
- subtext: '#5C8DB3',
356
- subtextActive: '#CCE7FD',
357
- bg: '#E6F4FF',
358
- bgHover: '#C8E6FF',
359
- bgActive: '#0289F7',
355
+ color: 'var(--ink-blue-7)',
356
+ border: 'var(--ink-blue-7)',
357
+ borderActive: 'var(--outline-blue-2)',
358
+ text: 'var(--ink-blue-7)',
359
+ textActive: 'var(--ink-blue-1)',
360
+ subtext: 'var(--ink-gray-6)',
361
+ subtextActive: 'var(--ink-blue-1)',
362
+ bg: 'var(--surface-blue-1)',
363
+ bgHover: 'var(--surface-blue-1)',
364
+ bgActive: 'var(--surface-blue-2)',
360
365
  },
361
366
  orange: {
362
- color: '#E86C13',
363
- border: '#E86C13',
364
- borderActive: '#FFCBA3',
365
- text: '#E86C13',
366
- subtext: '#A67765',
367
- subtextActive: '#FAE2D0',
368
- bg: '#FFEFE4',
369
- bgHover: '#FFDEC5',
370
- bgActive: '#E86C13',
367
+ color: 'var(--ink-orange-7)',
368
+ border: 'var(--outline-orange-1)',
369
+ borderActive: 'var(--outline-orange-1)',
370
+ text: 'var(--ink-orange-7)',
371
+ textActive: 'var(--ink-orange-1)',
372
+ subtext: 'var(--ink-gray-6)',
373
+ subtextActive: 'var(--ink-base)',
374
+ bg: 'var(--surface-orange-1)',
375
+ bgHover: 'var(--surface-orange-1)',
376
+ bgActive: 'var(--outline-orange-1)',
371
377
  },
372
378
  green: {
373
- color: '#30A66D',
374
- border: '#30A66D',
375
- borderActive: '#88D5A5',
376
- text: '#137949',
377
- subtext: '#678877',
378
- subtextActive: '#D6EDE2',
379
- bg: '#E4FAEB',
380
- bgHover: '#CBF3D7',
381
- bgActive: '#30A66D',
379
+ color: 'var(--ink-green-7)',
380
+ border: 'var(--ink-green-7)',
381
+ borderActive: 'var(--outline-green-2)',
382
+ text: 'var(--ink-green-7)',
383
+ textActive: 'var(--ink-green-1)',
384
+ subtext: 'var(--ink-gray-6)',
385
+ subtextActive: 'var(--ink-green-1)',
386
+ bg: 'var(--surface-green-1)',
387
+ bgHover: 'var(--surface-green-1)',
388
+ bgActive: 'var(--surface-green-3)',
382
389
  },
383
390
  }
384
391
 
385
- export const colorMapDark: Record<string, CalendarColor> = {
386
- amber: {
387
- color: '#DB7706',
388
- border: '#C57411',
389
- borderActive: '#C57411',
390
- text: '#C57411',
391
- textActive: '#824108',
392
- subtext: '#988356',
393
- subtextActive: '#8E6026',
394
- bg: '#371E06',
395
- bgHover: '#4B2606',
396
- bgActive: '#F8D16E',
397
- },
398
- violet: {
399
- color: '#6846E3',
400
- border: '#A384EC',
401
- borderActive: '#8867E8',
402
- text: '#A384EC',
403
- textActive: '#4639A6',
404
- subtext: '#9389AE',
405
- subtextActive: '#332978',
406
- bg: '#221C42',
407
- bgHover: '#281E5D',
408
- bgActive: '#C4AFEE',
409
- },
410
- pink: {
411
- color: '#E34AA6',
412
- border: '#CB4394',
413
- borderActive: '#CB4394',
414
- text: '#E359AB',
415
- textActive: '#822A5F',
416
- subtext: '#B07E99',
417
- subtextActive: '#935277',
418
- bg: '#471432',
419
- bgHover: '#68204B',
420
- bgActive: '#F6C5DE',
421
- },
422
- cyan: {
423
- color: '#3BBDE5',
424
- border: '#2B8DAB',
425
- borderActive: '#2B8DAB',
426
- text: '#3CB8DC',
427
- textActive: '#155266',
428
- subtext: '#819FA8',
429
- subtextActive: '#3A6E7D',
430
- bg: '#0B252D',
431
- bgHover: '#0E3B49',
432
- bgActive: '#A0E6F7',
433
- },
434
- blue: {
435
- color: '#0289F7',
436
- border: '#3294E3',
437
- borderActive: '#1580D8',
438
- text: '#5AAEF2',
439
- textActive: '#155999',
440
- subtext: '#7F95AC',
441
- subtextActive: '#386A99',
442
- bg: '#10243E',
443
- bgHover: '#052B53',
444
- bgActive: '#ADD2F5',
445
- },
446
- orange: {
447
- color: '#E86C13',
448
- border: '#C45A0E',
449
- borderActive: '#C45A0E',
450
- text: '#DE6D1B',
451
- textActive: '#823906',
452
- subtext: '#B3876B',
453
- subtextActive: '#955528',
454
- bg: '#401F07',
455
- bgHover: '#532707',
456
- bgActive: '#FFA873',
457
- },
458
- green: {
459
- color: '#30A66D',
460
- border: '#35AE74',
461
- borderActive: '#35AE74',
462
- text: '#58C08E',
463
- textActive: '#0B6139',
464
- subtext: '#7CA490',
465
- subtextActive: '#0B6139',
466
- bg: '#0B2E1C',
467
- bgHover: '#0A3F27',
468
- bgActive: '#9BE6C1',
469
- },
470
- }
392
+ export const colorMapDark: Record<string, CalendarColor> = colorMap
471
393
 
472
394
  // config.weekends can be array of numbers [0-6] (0=Sun) or weekday names (e.g., 'Saturday').
473
395
  // Falls back to [0] (Sunday) if not provided / invalid.
@@ -1,6 +1,6 @@
1
1
  <script setup>
2
2
  import { ref } from 'vue'
3
- import { Button, Calendar, DatePicker, Select } from "frappe-ui";
3
+ import { Button, Calendar, DatePicker, Select } from 'frappe-ui'
4
4
 
5
5
  const config = {
6
6
  defaultMode: 'Month',
@@ -65,10 +65,10 @@ const events = ref([
65
65
  color: 'red',
66
66
  },
67
67
  {
68
- title: 'Google Meet with John ',
68
+ title: 'Frappe Meet with John',
69
69
  participant: 'John',
70
70
  id: '#htrht41',
71
- venue: 'Google Meet',
71
+ venue: 'Frappe Meet',
72
72
  fromDate: currentMonthYear + '-11',
73
73
  toDate: currentMonthYear + '-11',
74
74
  fromTime: '00:00',
@@ -77,10 +77,10 @@ const events = ref([
77
77
  isFullDay: true,
78
78
  },
79
79
  {
80
- title: 'Zoom Meet with Sheldon',
80
+ title: 'Frappe Meet with Sheldon',
81
81
  participant: 'Sheldon',
82
82
  id: '#htrht42',
83
- venue: 'Google Meet',
83
+ venue: 'Frappe Meet',
84
84
  fromDate: currentMonthYear + '-07',
85
85
  toDate: currentMonthYear + '-07',
86
86
  fromTime: '00:00',
@@ -92,7 +92,9 @@ const events = ref([
92
92
  </script>
93
93
 
94
94
  <template>
95
- <div class="w-full flex flex-wrap gap-3 items-center h-screen overflow-hidden">
95
+ <div
96
+ class="w-full flex flex-wrap gap-3 items-center h-screen overflow-hidden"
97
+ >
96
98
  <Calendar
97
99
  :config="config"
98
100
  :events="events"
@@ -65,10 +65,10 @@ const events = ref([
65
65
  color: 'red',
66
66
  },
67
67
  {
68
- title: 'Google Meet with John ',
68
+ title: 'Frappe Meet with John',
69
69
  participant: 'John',
70
70
  id: '#htrht41',
71
- venue: 'Google Meet',
71
+ venue: 'Frappe Meet',
72
72
  fromDate: currentMonthYear + '-11',
73
73
  toDate: currentMonthYear + '-11',
74
74
  fromTime: '00:00',
@@ -77,10 +77,10 @@ const events = ref([
77
77
  isFullDay: true,
78
78
  },
79
79
  {
80
- title: 'Zoom Meet with Sheldon',
80
+ title: 'Frappe Meet with Sheldon',
81
81
  participant: 'Sheldon',
82
82
  id: '#htrht42',
83
- venue: 'Google Meet',
83
+ venue: 'Frappe Meet',
84
84
  fromDate: currentMonthYear + '-07',
85
85
  toDate: currentMonthYear + '-07',
86
86
  fromTime: '00:00',
@@ -92,7 +92,9 @@ const events = ref([
92
92
  </script>
93
93
 
94
94
  <template>
95
- <div class="w-full flex flex-wrap gap-3 items-center h-screen overflow-hidden">
95
+ <div
96
+ class="w-full flex flex-wrap gap-3 items-center h-screen overflow-hidden"
97
+ >
96
98
  <Calendar
97
99
  :config="config"
98
100
  :events="events"
@@ -27,7 +27,7 @@
27
27
  background-color: var(--bg-active);
28
28
  }
29
29
  .event.active .event-title {
30
- color: var(--text-active, #fff);
30
+ color: var(--text-active, var(--ink-base));
31
31
  }
32
32
  .event.active .event-subtitle {
33
33
  color: var(--subtext-active);
@@ -8,6 +8,16 @@ import {
8
8
  type CalendarEvent,
9
9
  } from './types'
10
10
 
11
+ const legacyColorNamesByHex: Record<string, keyof typeof colorMap> = {
12
+ '#db7706': 'amber',
13
+ '#6846e3': 'violet',
14
+ '#e34aa6': 'pink',
15
+ '#3bbde5': 'cyan',
16
+ '#0289f7': 'blue',
17
+ '#e86c13': 'orange',
18
+ '#30a66d': 'green',
19
+ }
20
+
11
21
  export const isAnyPopoverOpen = ref(false)
12
22
 
13
23
  export function useEventBase(props: { event: CalendarEvent; date: Date }) {
@@ -51,6 +61,8 @@ export function useEventBase(props: { event: CalendarEvent; date: Date }) {
51
61
  const map = getTheme() === 'dark' ? colorMapDark : colorMap
52
62
  if (!colorValue?.startsWith('#'))
53
63
  return map[colorValue || 'green'] || map['green']!
64
+ const legacyColorName = legacyColorNamesByHex[colorValue.toLowerCase()]
65
+ if (legacyColorName) return map[legacyColorName]!
54
66
  for (const value of Object.values(map)) {
55
67
  if (value.color === colorValue) return value
56
68
  }
@@ -86,21 +86,92 @@ describe('Dialog', () => {
86
86
 
87
87
  // ---- New behavior props ----------------------------------------------------
88
88
 
89
+ it('closes on outside click when dismissible', () => {
90
+ const Wrapper = defineComponent({
91
+ setup() {
92
+ const open = ref(true)
93
+ return { open }
94
+ },
95
+ render() {
96
+ return h(Dialog, {
97
+ open: this.open,
98
+ 'onUpdate:open': (v: boolean) => (this.open = v),
99
+ title: 'Dismissible',
100
+ })
101
+ },
102
+ })
103
+
104
+ cy.mount(Wrapper)
105
+ cy.get('[role=dialog]').should('exist')
106
+ cy.get('.dialog-overlay').click(0, 0, { force: true })
107
+ cy.get('[role=dialog]').should('not.exist')
108
+ })
109
+
89
110
  it('dismissible=false blocks outside click and Escape from closing', () => {
90
- cy.mount(Dialog, {
91
- props: {
92
- open: true,
93
- title: 'Locked',
94
- message: 'Cannot dismiss',
95
- dismissible: false,
111
+ const Wrapper = defineComponent({
112
+ setup() {
113
+ const open = ref(true)
114
+ return { open }
115
+ },
116
+ render() {
117
+ return h(Dialog, {
118
+ open: this.open,
119
+ 'onUpdate:open': (v: boolean) => (this.open = v),
120
+ title: 'Locked',
121
+ message: 'Cannot dismiss',
122
+ dismissible: false,
123
+ })
96
124
  },
97
125
  })
98
126
 
127
+ cy.mount(Wrapper)
128
+ cy.get('[role=dialog]').should('exist')
129
+ cy.get('.dialog-overlay').click(0, 0, { force: true })
99
130
  cy.get('[role=dialog]').should('exist')
100
131
  cy.get('body').type('{esc}')
101
132
  cy.get('[role=dialog]').should('exist')
102
133
  })
103
134
 
135
+ it('focuses and types into an input clicked inside a scrollable dialog', () => {
136
+ cy.mount(Dialog, {
137
+ props: { open: true, title: 'Long form' },
138
+ slots: {
139
+ default: () =>
140
+ h(
141
+ 'div',
142
+ {
143
+ 'data-cy': 'tall-dialog-body',
144
+ style: {
145
+ height: '1200px',
146
+ display: 'flex',
147
+ alignItems: 'flex-end',
148
+ },
149
+ },
150
+ [
151
+ h('input', {
152
+ 'data-cy': 'mouse-focus-input',
153
+ type: 'text',
154
+ }),
155
+ ],
156
+ ),
157
+ },
158
+ })
159
+
160
+ cy.get('.dialog-overlay').should(($overlay) => {
161
+ expect($overlay[0].scrollHeight).to.be.greaterThan(
162
+ $overlay[0].clientHeight,
163
+ )
164
+ })
165
+
166
+ // Cypress cannot synthesize fully trusted OS-level events, so this guards
167
+ // the click-to-focus behavior contract rather than the exact event chain.
168
+ cy.get('[data-cy=mouse-focus-input]')
169
+ .click()
170
+ .should('be.focused')
171
+ .type('Focused by mouse')
172
+ .should('have.value', 'Focused by mouse')
173
+ })
174
+
104
175
  it('showCloseButton renders an accessible close affordance that closes the dialog', () => {
105
176
  const Wrapper = defineComponent({
106
177
  setup() {
@@ -218,11 +289,7 @@ describe('Dialog', () => {
218
289
  cy.mount(Dialog, {
219
290
  props: { modelValue: true },
220
291
  slots: {
221
- 'body-header': h(
222
- 'div',
223
- { 'data-cy': 'body-header' },
224
- 'legacy header',
225
- ),
292
+ 'body-header': h('div', { 'data-cy': 'body-header' }, 'legacy header'),
226
293
  'body-content': h(
227
294
  'div',
228
295
  { 'data-cy': 'body-content' },
@@ -261,11 +328,7 @@ describe('Dialog', () => {
261
328
  cy.mount(Dialog, {
262
329
  props: { modelValue: true },
263
330
  slots: {
264
- 'body-header': h(
265
- 'div',
266
- { 'data-cy': 'body-header' },
267
- 'legacy header',
268
- ),
331
+ 'body-header': h('div', { 'data-cy': 'body-header' }, 'legacy header'),
269
332
  },
270
333
  })
271
334
 
@@ -320,11 +383,7 @@ describe('Dialog', () => {
320
383
  default: () =>
321
384
  h('div', { autofocus: '' }, [
322
385
  h('span', 'label'),
323
- h(
324
- 'button',
325
- { 'data-cy': 'toggle', type: 'button' },
326
- 'Toggle',
327
- ),
386
+ h('button', { 'data-cy': 'toggle', type: 'button' }, 'Toggle'),
328
387
  h('button', { 'data-cy': 'second', type: 'button' }, 'Other'),
329
388
  ]),
330
389
  },
@@ -16,7 +16,6 @@
16
16
  ref="contentRef"
17
17
  class="my-8 inline-block w-full transform overflow-hidden rounded-xl bg-surface-elevation-1 text-start align-middle shadow-xl dialog-content focus-visible:outline-none"
18
18
  :class="sizeClass"
19
- @pointerdown.stop
20
19
  @open-auto-focus="handleOpenAutoFocus"
21
20
  @escape-key-down="
22
21
  (e: Event) => {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { describe, expect, it } from 'vitest'
6
+ import { createApp } from 'vue'
7
+ import ErrorMessage from './ErrorMessage.vue'
8
+
9
+ function renderErrorMessage(message: string | Error) {
10
+ const host = document.createElement('div')
11
+ const app = createApp(ErrorMessage, { message })
12
+ app.mount(host)
13
+
14
+ return {
15
+ alert: host.querySelector('[role="alert"]')!,
16
+ unmount: () => app.unmount(),
17
+ }
18
+ }
19
+
20
+ describe('ErrorMessage', () => {
21
+ it('keeps safe markup while removing executable HTML', () => {
22
+ const { alert, unmount } = renderErrorMessage(
23
+ '<strong>Invalid</strong><img src=x onerror=alert(1)><script>alert(2)</script>',
24
+ )
25
+
26
+ expect(alert.innerHTML).toContain('<strong>Invalid</strong>')
27
+ expect(alert.innerHTML).not.toContain('onerror')
28
+ expect(alert.innerHTML).not.toContain('<script')
29
+ expect(alert.textContent).not.toContain('alert(2)')
30
+
31
+ unmount()
32
+ })
33
+
34
+ it('sanitizes the non-standard messages value on Error objects', () => {
35
+ const error = Object.assign(new Error('Fallback message'), {
36
+ messages: '<a href="javascript:alert(1)">Invalid</a>',
37
+ })
38
+ const { alert, unmount } = renderErrorMessage(error)
39
+
40
+ expect(alert.textContent).toBe('Invalid')
41
+ expect(alert.querySelector('a')?.getAttribute('href')).toBeNull()
42
+
43
+ unmount()
44
+ })
45
+ })
@@ -8,6 +8,7 @@
8
8
  </template>
9
9
 
10
10
  <script setup lang="ts">
11
+ import DOMPurify from 'dompurify'
11
12
  import { computed } from 'vue'
12
13
  import type { ErrorMessageProps } from './types'
13
14
 
@@ -15,9 +16,13 @@ const props = defineProps<ErrorMessageProps>()
15
16
 
16
17
  const errorMessage = computed(() => {
17
18
  if (!props.message) return ''
18
- if (props.message instanceof Error) {
19
- return (props.message as any).messages || props.message.message
20
- }
21
- return props.message
19
+
20
+ const message =
21
+ props.message instanceof Error
22
+ ? (props.message as Error & { messages?: string }).messages ||
23
+ props.message.message
24
+ : props.message
25
+
26
+ return DOMPurify.sanitize(message)
22
27
  })
23
28
  </script>
@@ -1,35 +1,34 @@
1
1
  <template>
2
- <div
3
- v-if="items.length"
4
- ref="container"
5
- class="relative max-h-[300px] min-w-40 overflow-y-auto rounded-lg bg-surface-base p-1 text-base shadow-lg"
6
- :class="containerClass"
7
- >
8
- <button
9
- v-for="(item, index) in items"
10
- :key="index"
11
- :ref="
12
- (el) => {
13
- if (el) itemRefs[index] = el as HTMLButtonElement
14
- }
15
- "
16
- :class="[
17
- 'flex w-full items-center whitespace-nowrap rounded-md px-2 py-1.5 text-sm text-ink-gray-9',
18
- index === selectedIndex ? 'bg-surface-gray-2' : '',
19
- itemClass,
20
- ]"
21
- @click="selectItem(index)"
22
- @mouseover="selectedIndex = index"
23
- >
24
- <slot :item="item" :index="index">
25
- <span>{{ item.display || item.title || item.name }}</span>
26
- </slot>
27
- </button>
2
+ <div>
28
3
  <div
29
- v-if="!items.length && showNoResults"
30
- class="px-3 py-1.5 text-sm text-ink-gray-5"
4
+ v-if="items.length || showNoResults"
5
+ ref="container"
6
+ class="relative max-h-[300px] min-w-40 overflow-y-auto rounded-lg bg-surface-base p-1 text-base shadow-lg"
7
+ :class="containerClass"
31
8
  >
32
- No results
9
+ <button
10
+ v-for="(item, index) in items"
11
+ :key="index"
12
+ :ref="
13
+ (el) => {
14
+ if (el) itemRefs[index] = el as HTMLButtonElement
15
+ }
16
+ "
17
+ :class="[
18
+ 'flex w-full items-center whitespace-nowrap rounded-md px-2 py-1.5 text-sm text-ink-gray-9',
19
+ index === selectedIndex ? 'bg-surface-gray-2' : '',
20
+ itemClass,
21
+ ]"
22
+ @click="selectItem(index)"
23
+ @mouseover="selectedIndex = index"
24
+ >
25
+ <slot :item="item" :index="index">
26
+ <span>{{ item.display || item.title || item.name }}</span>
27
+ </slot>
28
+ </button>
29
+ <div v-if="!items.length" class="px-3 py-1.5 text-sm text-ink-gray-5">
30
+ No results
31
+ </div>
33
32
  </div>
34
33
  </div>
35
34
  </template>
@@ -18,8 +18,12 @@
18
18
  >
19
19
  <div
20
20
  data-slot="row"
21
- class="frappe-tree-row group/row relative flex items-center gap-1.5 rounded-md px-1.5 hover:bg-surface-gray-2"
22
- :class="ctx.draggable.value && !ctx.disabled.value ? 'cursor-grab' : ''"
21
+ class="frappe-tree-row group/row relative flex items-center gap-1.5 rounded-md px-1.5"
22
+ :class="[
23
+ ctx.draggable.value && !ctx.disabled.value ? 'cursor-grab' : '',
24
+ // When the row is fully overridden via #item, the consumer owns its hover/selected surface
25
+ $slots.item ? '' : 'hover:bg-surface-gray-2',
26
+ ]"
23
27
  :draggable="ctx.draggable.value && !ctx.disabled.value"
24
28
  @click="onRowClick"
25
29
  @dragstart="ctx.onDragStart($event, node, parent)"
@@ -16,7 +16,7 @@ const props = withDefaults(
16
16
  extensions: Extension[]
17
17
 
18
18
  // content / behavior knobs (universal, reactive where noted)
19
- format?: 'html' | 'json'
19
+ format?: 'html' | 'json' | 'markdown'
20
20
  placeholder?: string
21
21
  editable?: boolean
22
22
  autofocus?: boolean
@@ -60,7 +60,11 @@ const editor = useEditor({
60
60
  // independent of v-model timing).
61
61
  emit(
62
62
  'change',
63
- props.format === 'json' ? editor.getJSON() : editor.getHTML(),
63
+ props.format === 'json'
64
+ ? editor.getJSON()
65
+ : props.format === 'markdown'
66
+ ? editor.getMarkdown()
67
+ : editor.getHTML(),
64
68
  )
65
69
  },
66
70
  onFocus(_editor, event) {
@@ -311,3 +311,8 @@ export const Toc = TocNodeExtension
311
311
  export const ContentPaste = ContentPasteExtension
312
312
  export const StyleClipboard = StyleClipboardExtension
313
313
  export type { MediaUploadRequestOptions } from './extensions/shared/media-upload-engine'
314
+
315
+ // Required in `extensions` for `format: 'markdown'`. A plain re-export (no
316
+ // use inside useEditor) so bundlers drop the markdown/marked payload for
317
+ // editors that never import it.
318
+ export { Markdown, type MarkdownExtensionOptions } from '@tiptap/markdown'
@@ -54,6 +54,10 @@ vi.mock('@tiptap/core', () => {
54
54
  return typeof this.content === 'object' ? this.content : { type: 'doc', content: [] }
55
55
  }
56
56
 
57
+ getMarkdown() {
58
+ return typeof this.content === 'string' ? this.content : '# json'
59
+ }
60
+
57
61
  setEditable(value: boolean) {
58
62
  this.editable = value
59
63
  }
@@ -66,6 +70,17 @@ vi.mock('@tiptap/core', () => {
66
70
  return { Editor, Extension, Node, Mark, mergeAttributes, nodeInputRule, markInputRule }
67
71
  })
68
72
 
73
+ vi.mock('@tiptap/markdown', () => ({
74
+ Markdown: {
75
+ name: 'markdown',
76
+ configure: vi.fn((options: any) => ({
77
+ name: 'markdown',
78
+ options,
79
+ configured: true,
80
+ })),
81
+ },
82
+ }))
83
+
69
84
  vi.mock('@tiptap/vue-3', () => ({
70
85
  EditorContent: defineComponent({
71
86
  props: ['editor'],
@@ -196,6 +211,63 @@ describe('frappe-ui/editor minimal primitives', () => {
196
211
  expect(editor.commands.setContent).toHaveBeenCalledTimes(calls)
197
212
  })
198
213
 
214
+ it('sets markdown contentType and warns when the Markdown extension is missing', async () => {
215
+ const { useEditor } = await import('./index')
216
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
217
+
218
+ createApp(
219
+ defineComponent({
220
+ setup() {
221
+ useEditor({ content: ref('# Hi'), format: 'markdown', extensions: [] })
222
+ return () => null
223
+ },
224
+ }),
225
+ ).mount(document.createElement('div'))
226
+
227
+ expect(editors[0].options.contentType).toBe('markdown')
228
+ expect(warn).toHaveBeenCalledWith(
229
+ expect.stringContaining("format: 'markdown' needs the Markdown extension"),
230
+ )
231
+ warn.mockRestore()
232
+ })
233
+
234
+ it('binds markdown content in both directions without rewriting equal external markdown', async () => {
235
+ const { useEditor, Markdown } = await import('./index')
236
+ const content = ref('# Hello')
237
+
238
+ createApp(
239
+ defineComponent({
240
+ setup() {
241
+ useEditor({ content, format: 'markdown', extensions: [Markdown as any] })
242
+ return () => null
243
+ },
244
+ }),
245
+ ).mount(document.createElement('div'))
246
+
247
+ const editor = editors[0]
248
+ expect(editor.options.content).toBe('# Hello')
249
+
250
+ // Internal edit flows out through getMarkdown() and must not bounce back.
251
+ editor.content = '# Internal'
252
+ editor.options.onUpdate({ editor })
253
+ expect(content.value).toBe('# Internal')
254
+ await nextTick()
255
+ expect(editor.commands.setContent).not.toHaveBeenCalled()
256
+
257
+ // External write equal to the current document is a no-op.
258
+ content.value = '# Internal'
259
+ await nextTick()
260
+ expect(editor.commands.setContent).not.toHaveBeenCalled()
261
+
262
+ // A genuinely new external value is parsed as markdown.
263
+ content.value = '# External'
264
+ await nextTick()
265
+ expect(editor.commands.setContent).toHaveBeenCalledWith('# External', {
266
+ emitUpdate: false,
267
+ contentType: 'markdown',
268
+ })
269
+ })
270
+
199
271
  it('keeps editable reactive', async () => {
200
272
  const { useEditor } = await import('./index')
201
273
  const editable = ref(true)
@@ -19,7 +19,7 @@ type Editor = TiptapEditor
19
19
 
20
20
  export type UseEditorOptions = {
21
21
  content?: Ref<string | JSONContent | null | undefined>
22
- format?: 'html' | 'json'
22
+ format?: 'html' | 'json' | 'markdown'
23
23
  editable?: MaybeRefOrGetter<boolean>
24
24
  autofocus?: boolean
25
25
  uploadFunction?: (file: File) => Promise<UploadedFile>
@@ -63,6 +63,23 @@ export function useEditor(
63
63
 
64
64
  const extensions = [UploadStorage, ...options.extensions]
65
65
 
66
+ if (
67
+ import.meta.env?.DEV &&
68
+ format === 'markdown' &&
69
+ !extensions.some((extension) => extension.name === 'markdown')
70
+ ) {
71
+ console.warn(
72
+ "[frappe-ui] format: 'markdown' needs the Markdown extension in `extensions` — import { Markdown } from 'frappe-ui/editor'.",
73
+ )
74
+ }
75
+
76
+ const serialize = (tiptapEditor: Editor) =>
77
+ format === 'json'
78
+ ? tiptapEditor.getJSON()
79
+ : format === 'markdown'
80
+ ? tiptapEditor.getMarkdown()
81
+ : tiptapEditor.getHTML()
82
+
66
83
  const editorOptions: Partial<EditorOptions> = {
67
84
  element: null,
68
85
  extensions,
@@ -70,8 +87,7 @@ export function useEditor(
70
87
  autofocus: options.autofocus,
71
88
  onUpdate: ({ editor: tiptapEditor }) => {
72
89
  if (!isCollaborationMode && options.content && !applyingExternalUpdate) {
73
- const value =
74
- format === 'json' ? tiptapEditor.getJSON() : tiptapEditor.getHTML()
90
+ const value = serialize(tiptapEditor)
75
91
  lastEmitted = value
76
92
  options.content.value = value
77
93
  }
@@ -88,6 +104,10 @@ export function useEditor(
88
104
  },
89
105
  }
90
106
 
107
+ if (format === 'markdown') {
108
+ editorOptions.contentType = 'markdown'
109
+ }
110
+
91
111
  if (!isCollaborationMode && options.content?.value != null) {
92
112
  editorOptions.content = options.content.value
93
113
  }
@@ -109,10 +129,17 @@ export function useEditor(
109
129
  // whose fresh-object identity defeats the HTML string check below).
110
130
  if (toRaw(content) === lastEmitted) return
111
131
  if (format === 'html' && editor.value.getHTML() === content) return
132
+ if (format === 'markdown' && editor.value.getMarkdown() === content)
133
+ return
112
134
 
113
135
  applyingExternalUpdate = true
114
136
  try {
115
- editor.value.commands.setContent(content ?? '', { emitUpdate: false })
137
+ editor.value.commands.setContent(
138
+ content ?? '',
139
+ format === 'markdown'
140
+ ? { emitUpdate: false, contentType: 'markdown' }
141
+ : { emitUpdate: false },
142
+ )
116
143
  } finally {
117
144
  applyingExternalUpdate = false
118
145
  }
@@ -32,12 +32,12 @@ const isActive = (link: string) =>
32
32
  >
33
33
  <div class="px-1">
34
34
  <a
35
- class="hidden items-center gap-2.5 px-2 py-2 lg:flex hover:bg-surface-gray-2 rounded transition-colors"
35
+ class="hidden items-center gap-1.5 px-2 py-2 lg:flex hover:bg-surface-gray-2 rounded transition-colors"
36
36
  :href="withBase('/')"
37
37
  >
38
- <img v-if="logo" :src="withBase(logo)" class="w-7" />
38
+ <img v-if="logo" :src="withBase(logo)" class="w-6" />
39
39
  <div class="flex flex-col gap-1 *:leading-none">
40
- <span class="text-base font-medium text-ink-gray-8">{{
40
+ <span class="text-lg-bold text-ink-gray-8 tracking-[-0.01em]">{{
41
41
  siteName
42
42
  }}</span>
43
43
  <span v-if="curVersion" class="text-sm text-ink-gray-5"
@@ -58,7 +58,7 @@ const toggleTheme = () =>
58
58
  <a
59
59
  v-if="logo || name"
60
60
  :href="withBase('/')"
61
- class="flex items-center gap-2.5 min-w-0"
61
+ class="flex items-center gap-1.5 min-w-0"
62
62
  :class="{ 'lg:hidden': isDocs }"
63
63
  >
64
64
  <img
@@ -67,7 +67,10 @@ const toggleTheme = () =>
67
67
  class="w-6 shrink-0"
68
68
  :alt="name"
69
69
  />
70
- <span v-if="name" class="text-sm font-medium text-ink-gray-8 truncate">
70
+ <span
71
+ v-if="name"
72
+ class="text-lg-bold text-ink-gray-8 truncate tracking-[-0.01em]"
73
+ >
71
74
  {{ name }}
72
75
  </span>
73
76
  </a>
@@ -8,6 +8,7 @@ import { lucideIcons } from 'frappe-ui/vite'
8
8
 
9
9
  import { createComponentTransformer } from './plugins/componentTransformer.ts'
10
10
  import colocatedComponentDocs from './plugins/colocatedComponentDocs.ts'
11
+ import preventCircularChunks from './plugins/preventCircularChunks.ts'
11
12
  import type { SidebarSection } from './components/Docs/sidebarList.ts'
12
13
 
13
14
  // frappe-ui package root — this file lives at <root>/vitepress/index.node.ts.
@@ -118,6 +119,10 @@ export function defineDocsConfig(
118
119
  vite: {
119
120
  plugins: [
120
121
  lucideIcons(),
122
+ // reka-ui 2.9 + VitePress both use @vueuse 14; pin shared Vue ecosystem
123
+ // packages into the framework chunk so CI never ships a framework→theme
124
+ // cycle (TDZ on ui.frappe.io). See preventCircularChunks.ts.
125
+ preventCircularChunks(),
121
126
  ...(colocatedDocs
122
127
  ? [
123
128
  colocatedComponentDocs(
@@ -0,0 +1,54 @@
1
+ import type { Plugin } from 'vite'
2
+
3
+ /**
4
+ * Keep Vue ecosystem packages in VitePress's `framework` chunk.
5
+ *
6
+ * Why: reka-ui ≥2.9 depends on @vueuse 14, same major as VitePress. VitePress
7
+ * `manualChunks` only pins `@vue/{runtime,shared,reactivity}` into `framework`
8
+ * and dumps anything statically imported by the theme entry into `theme`. Shared
9
+ * @vueuse modules can land in `theme` while `framework` still needs them →
10
+ * framework→theme cycle → TDZ at runtime (`can't access lexical declaration
11
+ * before initialization` on ui.frappe.io after the reka-ui 2.9.9 bump).
12
+ *
13
+ * VitePress overwrites user `manualChunks`, so we wrap its function in
14
+ * `outputOptions` and run first. `generateBundle` fails the build if a cycle
15
+ * still appears.
16
+ */
17
+ const FRAMEWORK_PKG_RE =
18
+ /[/\\]node_modules[/\\](?:vue[/\\]|@vue[/\\]|vue-router[/\\]|@vueuse[/\\])/
19
+
20
+ export default function preventCircularChunks(): Plugin {
21
+ return {
22
+ name: 'frappe-ui:prevent-circular-chunks',
23
+ apply: 'build',
24
+ outputOptions(options) {
25
+ const previous = options.manualChunks
26
+ options.manualChunks = (id, meta) => {
27
+ if (FRAMEWORK_PKG_RE.test(id)) return 'framework'
28
+ if (typeof previous === 'function') return previous(id, meta)
29
+ return undefined
30
+ }
31
+ return options
32
+ },
33
+ generateBundle(_options, bundle) {
34
+ for (const [fileName, chunk] of Object.entries(bundle)) {
35
+ if (chunk.type !== 'chunk') continue
36
+ if (
37
+ chunk.name !== 'framework' &&
38
+ !/[/\\]framework\.[^/\\]+\.js$/.test(fileName)
39
+ ) {
40
+ continue
41
+ }
42
+ const bad = chunk.imports.filter(
43
+ (imp) => /theme\.[^/]+\.js$/.test(imp) || imp.includes('/theme.'),
44
+ )
45
+ if (bad.length) {
46
+ this.error(
47
+ `Circular VitePress chunks: ${fileName} imports ${bad.join(', ')}. ` +
48
+ 'framework must not import theme (TDZ on load).',
49
+ )
50
+ }
51
+ }
52
+ },
53
+ }
54
+ }