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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "1.0.0-beta.21",
3
+ "version": "1.0.0-beta.24",
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",
@@ -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
  }
@@ -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>