frappe-ui 1.0.0-beta.1 → 1.0.0-beta.3

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 (94) hide show
  1. package/package.json +7 -1
  2. package/src/components/Button/Button.vue +292 -284
  3. package/src/components/Button/types.ts +38 -32
  4. package/src/components/Tooltip/Tooltip.cy.ts +48 -0
  5. package/src/components/Tooltip/Tooltip.md +6 -0
  6. package/src/components/Tooltip/Tooltip.vue +19 -4
  7. package/src/components/Tooltip/TooltipProvider.vue +53 -0
  8. package/src/components/Tooltip/index.ts +1 -0
  9. package/src/components/Tooltip/stories/Group.vue +22 -0
  10. package/src/molecules/editor/EditorTableMenu.vue +216 -0
  11. package/src/molecules/editor/MenuItems.vue +64 -52
  12. package/src/molecules/editor/components/EditorDropZone.vue +89 -0
  13. package/src/molecules/editor/components/EditorPopover.vue +89 -0
  14. package/src/molecules/editor/components/ImageViewerModal.vue +6 -6
  15. package/src/molecules/editor/components/MediaNodeView.vue +6 -6
  16. package/src/molecules/editor/components/MediaToolbar.vue +1 -1
  17. package/src/molecules/editor/components/TableContextMenu.vue +87 -0
  18. package/src/molecules/editor/components/font-color/ColorSwatchGrid.vue +4 -6
  19. package/src/molecules/editor/components/font-color/fontColorController.ts +30 -25
  20. package/src/molecules/editor/components/font-color/swatches.ts +1 -1
  21. package/src/molecules/editor/components/image-viewer/ImageViewerControlsBar.vue +1 -1
  22. package/src/molecules/editor/components/media-node-view-controller.ts +1 -1
  23. package/src/molecules/editor/components/table-color/tableCellColorController.ts +80 -0
  24. package/src/molecules/editor/composables/useEditorFileDrop.ts +99 -0
  25. package/src/molecules/editor/composables/useFloatingPopup.ts +45 -2
  26. package/src/molecules/editor/composables/useNamedColorState.ts +1 -1
  27. package/src/molecules/editor/composables/useNodeViewResize.ts +1 -1
  28. package/src/molecules/editor/composables/useSuggestionList.ts +2 -2
  29. package/src/molecules/editor/composables/useTableCellColorState.ts +78 -0
  30. package/src/molecules/editor/composables/useTocActiveHeading.ts +1 -1
  31. package/src/molecules/editor/composables/useTocAnchors.ts +2 -2
  32. package/src/molecules/editor/composables/useWindowFileDragging.ts +68 -0
  33. package/src/molecules/editor/extensions/code-block/CodeBlockComponent.css +6 -26
  34. package/src/molecules/editor/extensions/code-block/CodeBlockComponent.vue +109 -24
  35. package/src/molecules/editor/extensions/code-block/code-block.ts +1 -1
  36. package/src/molecules/editor/extensions/content-paste/content-paste-extension.ts +3 -3
  37. package/src/molecules/editor/extensions/content-paste/paste-image-controller.test.ts +1 -1
  38. package/src/molecules/editor/extensions/content-paste/paste-image-controller.ts +1 -1
  39. package/src/molecules/editor/extensions/content-paste/paste-markdown-utils.ts +2 -2
  40. package/src/molecules/editor/extensions/emoji/EmojiList.vue +2 -2
  41. package/src/molecules/editor/extensions/emoji/emoji-extension.ts +1 -1
  42. package/src/molecules/editor/extensions/iframe/IframeInsertDialog.vue +3 -3
  43. package/src/molecules/editor/extensions/iframe/IframeNodeView.vue +3 -3
  44. package/src/molecules/editor/extensions/iframe/iframe-allowlist.ts +1 -1
  45. package/src/molecules/editor/extensions/iframe/iframe-embed-utils.ts +1 -1
  46. package/src/molecules/editor/extensions/iframe/iframe-paste-handler.ts +1 -1
  47. package/src/molecules/editor/extensions/image/image-engine.ts +3 -3
  48. package/src/molecules/editor/extensions/image/image-extension.ts +7 -7
  49. package/src/molecules/editor/extensions/image-group/ImageGroupGridCell.vue +1 -1
  50. package/src/molecules/editor/extensions/image-group/ImageGroupNodeView.vue +5 -5
  51. package/src/molecules/editor/extensions/image-group/ImageGroupUploadDialog.vue +5 -5
  52. package/src/molecules/editor/extensions/image-group/image-group-commands.ts +2 -2
  53. package/src/molecules/editor/extensions/image-group/image-group-extension.ts +1 -1
  54. package/src/molecules/editor/extensions/image-group/image-group-utils.ts +1 -1
  55. package/src/molecules/editor/extensions/image-group/useImageGroupDialog.ts +2 -2
  56. package/src/molecules/editor/extensions/link/LinkEditorPopup.vue +217 -74
  57. package/src/molecules/editor/extensions/link/link-commands.ts +26 -5
  58. package/src/molecules/editor/extensions/link/link-extension.ts +2 -2
  59. package/src/molecules/editor/extensions/link/link-paste-plugin.ts +1 -1
  60. package/src/molecules/editor/extensions/link/link-popup-controller.ts +47 -3
  61. package/src/molecules/editor/extensions/link/link-shortcut-plugin.ts +7 -2
  62. package/src/molecules/editor/extensions/media-drop/media-drop-extension.ts +136 -0
  63. package/src/molecules/editor/extensions/mention/mention-extension.ts +1 -1
  64. package/src/molecules/editor/extensions/shared/media-node-ops.ts +3 -3
  65. package/src/molecules/editor/extensions/shared/media-plugin.ts +9 -40
  66. package/src/molecules/editor/extensions/shared/media-upload-engine.test.ts +10 -10
  67. package/src/molecules/editor/extensions/shared/media-upload-engine.ts +8 -8
  68. package/src/molecules/editor/extensions/shared/media-upload-types.ts +22 -3
  69. package/src/molecules/editor/extensions/shared/suggestion-helpers.ts +21 -1
  70. package/src/molecules/editor/extensions/shared/suggestion-renderer.ts +1 -1
  71. package/src/molecules/editor/extensions/shared/suggestion-types.ts +1 -1
  72. package/src/molecules/editor/extensions/shared/upload-types.ts +1 -1
  73. package/src/molecules/editor/extensions/slash-commands/SlashCommandsList.vue +2 -2
  74. package/src/molecules/editor/extensions/slash-commands/slash-commands-extension.ts +2 -2
  75. package/src/molecules/editor/extensions/suggestion/SuggestionList.vue +18 -10
  76. package/src/molecules/editor/extensions/suggestion/SuggestionListItem.vue +2 -2
  77. package/src/molecules/editor/extensions/suggestion/createSuggestionExtension.ts +6 -2
  78. package/src/molecules/editor/extensions/suggestion/index.ts +1 -1
  79. package/src/molecules/editor/extensions/table/table-cell-color.ts +113 -0
  80. package/src/molecules/editor/extensions/table/table-navigation.ts +331 -0
  81. package/src/molecules/editor/extensions/table/table-selection-overlay.ts +114 -0
  82. package/src/molecules/editor/extensions/tag/tag-extension.ts +1 -1
  83. package/src/molecules/editor/extensions/toc-node/TocItem.vue +2 -2
  84. package/src/molecules/editor/extensions/toc-node/TocNodeView.vue +6 -6
  85. package/src/molecules/editor/extensions/toc-node/toc-render.ts +2 -2
  86. package/src/molecules/editor/extensions/toc-node/toc-scroll-controller.ts +1 -1
  87. package/src/molecules/editor/extensions/video/video-config.ts +2 -2
  88. package/src/molecules/editor/extensions/video/video-extension.ts +14 -6
  89. package/src/molecules/editor/extensions.ts +29 -3
  90. package/src/molecules/editor/index.ts +7 -0
  91. package/src/molecules/editor/kits.ts +26 -9
  92. package/src/molecules/editor/menu.ts +112 -0
  93. package/src/molecules/editor/style.css +71 -2
  94. package/tailwind/preset.js +4 -0
package/package.json CHANGED
@@ -1,9 +1,15 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.3",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
+ "imports": {
8
+ "#components/*": "./src/components/*",
9
+ "#molecules/*": "./src/molecules/*",
10
+ "#composables/*": "./src/composables/*",
11
+ "#utils/*": "./src/utils/*"
12
+ },
7
13
  "scripts": {
8
14
  "test": "vitest --run",
9
15
  "test:coverage": "vitest --run --coverage",
@@ -1,296 +1,304 @@
1
- <template>
2
- <TooltipProvider>
3
- <TooltipRoot>
4
- <TooltipTrigger as-child>
5
- <component
6
- :is="Root"
7
- v-bind="$attrs"
8
- :class="buttonClasses"
9
- :aria-label="label"
10
- ref="rootRef"
11
- >
12
- <LoadingIndicator
13
- v-if="loading"
14
- :class="{
15
- 'h-3 w-3': size == 'sm',
16
- 'h-[13.5px] w-[13.5px]': size == 'md',
17
- 'h-[15px] w-[15px]': size == 'lg',
18
- 'h-4.5 w-4.5': size == 'xl' || size == '2xl',
19
- }"
20
- />
21
- <slot name="prefix" v-else-if="$slots['prefix'] || iconLeft">
22
- <span
23
- v-if="iconLeft && typeof iconLeft === 'string' && iconLeft.startsWith('lucide-')"
24
- :class="[iconLeft, lucideSlotClasses]"
25
- aria-hidden="true"
26
- />
27
- <FeatherIcon
28
- v-else-if="iconLeft && typeof iconLeft === 'string'"
29
- :name="iconLeft"
30
- :class="slotClasses"
31
- aria-hidden="true"
32
- />
33
- <component v-else-if="iconLeft" :is="iconLeft" :class="slotClasses" />
34
- </slot>
35
-
36
- <template v-if="loading && loadingText">{{ loadingText }}</template>
37
- <template v-else-if="isIconButton && !loading">
38
- <span
39
- v-if="icon && typeof icon === 'string' && icon.startsWith('lucide-')"
40
- :class="[icon, lucideSlotClasses]"
41
- aria-hidden="true"
42
- />
43
- <FeatherIcon
44
- v-else-if="icon && typeof icon === 'string'"
45
- :name="icon"
46
- :class="slotClasses"
47
- />
48
- <component v-else-if="icon" :is="icon" :class="slotClasses" />
49
- <slot name="icon" v-else-if="$slots.icon" />
50
- <div v-else-if="hasLucideIconInDefaultSlot" :class="slotClasses">
51
- <slot>{{ label }}</slot>
52
- </div>
53
- </template>
54
- <span v-else :class="{ 'sr-only': isIconButton }" class="truncate">
55
- <slot>{{ label }}</slot>
56
- </span>
57
-
58
- <slot name="suffix">
59
- <span
60
- v-if="iconRight && typeof iconRight === 'string' && iconRight.startsWith('lucide-')"
61
- :class="[iconRight, lucideSlotClasses]"
62
- aria-hidden="true"
63
- />
64
- <FeatherIcon
65
- v-else-if="iconRight && typeof iconRight === 'string'"
66
- :name="iconRight"
67
- :class="slotClasses"
68
- aria-hidden="true"
69
- />
70
- <component
71
- v-else-if="iconRight"
72
- :is="iconRight"
73
- :class="slotClasses"
74
- />
75
- </slot>
76
- </component>
77
- </TooltipTrigger>
78
- <TooltipBubble v-if="tooltip?.length" :text="tooltip" />
79
- </TooltipRoot>
80
- </TooltipProvider>
81
- </template>
82
- <script lang="ts" setup>
83
- import { computed, h, ref, useSlots, watchEffect } from 'vue'
84
- import { TooltipProvider, TooltipRoot, TooltipTrigger } from 'reka-ui'
1
+ <script lang="ts">
2
+ import {
3
+ computed,
4
+ defineComponent,
5
+ h,
6
+ ref,
7
+ watchEffect,
8
+ type Component,
9
+ type SlotsType,
10
+ type VNode,
11
+ } from 'vue'
12
+ import {
13
+ TooltipProvider,
14
+ TooltipRoot,
15
+ TooltipTrigger,
16
+ injectTooltipProviderContext,
17
+ } from 'reka-ui'
18
+ import { RouterLink } from 'vue-router'
85
19
  import FeatherIcon from '../FeatherIcon.vue'
86
20
  import LoadingIndicator from '../LoadingIndicator.vue'
87
21
  import TooltipBubble from '../Tooltip/TooltipBubble.vue'
88
- import { RouterLink } from 'vue-router'
89
22
  import { warnFeatherIconUsage } from '../../utils/iconString'
90
- import type { ButtonProps, ThemeVariant } from './types'
91
-
92
- defineOptions({ inheritAttrs: false })
93
-
94
- const props = withDefaults(defineProps<ButtonProps>(), {
95
- theme: 'gray',
96
- size: 'sm',
97
- variant: 'subtle',
98
- loading: false,
99
- disabled: false,
100
- type: 'button',
101
- })
102
-
103
- watchEffect(() => {
104
- warnFeatherIconUsage('Button', 'icon', props.icon)
105
- warnFeatherIconUsage('Button', 'iconLeft', props.iconLeft)
106
- warnFeatherIconUsage('Button', 'iconRight', props.iconRight)
107
- })
108
-
109
- const slots = useSlots()
110
-
111
- const buttonClasses = computed(() => {
112
- let solidClasses = {
113
- gray: 'text-ink-white bg-surface-gray-7 hover:bg-surface-gray-6 active:bg-surface-gray-5',
114
- blue: 'text-ink-white bg-blue-500 hover:bg-surface-blue-3 active:bg-blue-700',
115
- green:
116
- 'text-ink-white bg-surface-green-3 hover:bg-green-700 active:bg-green-800',
117
- red: 'text-ink-white bg-surface-red-5 hover:bg-surface-red-6 active:bg-surface-red-7',
118
- }[props.theme]
119
-
120
- let subtleClasses = {
121
- gray: 'text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4',
122
- blue: 'text-ink-blue-3 bg-surface-blue-2 hover:bg-blue-200 active:bg-blue-300',
123
- green:
124
- 'text-green-800 bg-surface-green-2 hover:bg-green-200 active:bg-green-300',
125
- red: 'text-red-700 bg-surface-red-2 hover:bg-surface-red-3 active:bg-surface-red-4',
126
- }[props.theme]
127
-
128
- let outlineClasses = {
129
- gray: 'text-ink-gray-8 bg-surface-white bg-surface-white border border-outline-gray-2 hover:border-outline-gray-3 active:border-outline-gray-3 active:bg-surface-gray-4',
130
- blue: 'text-ink-blue-3 bg-surface-white border border-outline-blue-1 hover:border-blue-400 active:border-blue-400 active:bg-blue-300',
131
- green:
132
- 'text-green-800 bg-surface-white border border-outline-green-2 hover:border-green-500 active:border-green-500 active:bg-green-300',
133
- red: 'text-red-700 bg-surface-white border border-outline-red-1 hover:border-outline-red-2 active:border-outline-red-2 active:bg-surface-red-3',
134
- }[props.theme]
135
-
136
- let ghostClasses = {
137
- gray: 'text-ink-gray-8 bg-transparent hover:bg-surface-gray-3 active:bg-surface-gray-4',
138
- blue: 'text-ink-blue-3 bg-transparent hover:bg-blue-200 active:bg-blue-300',
139
- green:
140
- 'text-green-800 bg-transparent hover:bg-green-200 active:bg-green-300',
141
- red: 'text-red-700 bg-transparent hover:bg-surface-red-3 active:bg-surface-red-4',
142
- }[props.theme]
143
-
144
- let focusClasses = {
145
- gray: 'focus-visible:ring focus-visible:ring-outline-gray-3',
146
- blue: 'focus-visible:ring focus-visible:ring-blue-400',
147
- green: 'focus-visible:ring focus-visible:ring-outline-green-2',
148
- red: 'focus-visible:ring focus-visible:ring-outline-red-2',
149
- }[props.theme]
150
-
151
- let variantClasses = {
152
- subtle: subtleClasses,
153
- solid: solidClasses,
154
- outline: outlineClasses,
155
- ghost: ghostClasses,
156
- }[props.variant]
157
-
158
- let themeVariant: ThemeVariant = `${props.theme}-${props.variant}`
159
-
160
- let disabledClassesMap: Record<ThemeVariant, string> = {
161
- 'gray-solid': 'bg-surface-gray-2 text-ink-gray-4',
162
- 'gray-subtle': 'bg-surface-gray-2 text-ink-gray-4',
163
- 'gray-outline':
164
- 'bg-surface-gray-2 text-ink-gray-4 border border-outline-gray-2',
165
- 'gray-ghost': 'text-ink-gray-4',
166
-
167
- 'blue-solid': 'bg-blue-300 text-ink-white',
168
- 'blue-subtle': 'bg-surface-blue-2 text-ink-blue-link',
169
- 'blue-outline':
170
- 'bg-surface-blue-2 text-ink-blue-link border border-outline-blue-1',
171
- 'blue-ghost': 'text-ink-blue-link',
172
-
173
- 'green-solid': 'bg-surface-green-2 text-ink-green-2',
174
- 'green-subtle': 'bg-surface-green-2 text-ink-green-2',
175
- 'green-outline':
176
- 'bg-surface-green-2 text-ink-green-2 border border-outline-green-2',
177
- 'green-ghost': 'text-ink-green-2',
178
-
179
- 'red-solid': 'bg-surface-red-2 text-ink-red-2',
180
- 'red-subtle': 'bg-surface-red-2 text-ink-red-2',
181
- 'red-outline':
182
- 'bg-surface-red-2 text-ink-red-2 border border-outline-red-1',
183
- 'red-ghost': 'text-ink-red-2',
184
- }
185
- let disabledClasses = disabledClassesMap[themeVariant]
186
-
187
- let sizeClasses = {
188
- sm: 'h-7 text-base px-2 rounded',
189
- md: 'h-8 text-base font-medium px-2.5 rounded',
190
- lg: 'h-10 text-lg font-medium px-3 rounded-md',
191
- xl: 'h-11.5 text-xl font-medium px-3.5 rounded-lg',
192
- '2xl': 'h-13 text-2xl font-medium px-3.5 rounded-xl',
193
- }[props.size]
194
-
195
- if (isIconButton.value) {
196
- sizeClasses = {
197
- sm: 'h-7 w-7 rounded',
198
- md: 'h-8 w-8 rounded',
199
- lg: 'h-10 w-10 rounded-md',
200
- xl: 'h-11.5 w-11.5 rounded-lg',
201
- '2xl': 'h-13 w-13 rounded-xl',
202
- }[props.size]
203
- }
204
-
205
- return [
206
- 'inline-flex items-center justify-center gap-2 transition-colors focus:outline-none shrink-0',
207
- isDisabled.value ? disabledClasses : variantClasses,
208
- focusClasses,
209
- sizeClasses,
210
- ]
211
- })
212
-
213
- const slotClasses = computed(() => {
214
- let classes = {
215
- sm: 'h-4',
216
- md: 'h-4.5',
217
- lg: 'h-5',
218
- xl: 'h-6',
219
- '2xl': 'h-6',
220
- }[props.size]
221
-
222
- return classes
223
- })
224
-
225
- const lucideSlotClasses = computed(() => {
226
- return {
227
- sm: 'size-4',
228
- md: 'size-4.5',
229
- lg: 'size-5',
230
- xl: 'size-6',
231
- '2xl': 'size-6',
232
- }[props.size]
233
- })
23
+ import { buttonProps, type ThemeVariant } from './types'
24
+
25
+ export default defineComponent({
26
+ name: 'Button',
27
+ inheritAttrs: false,
28
+ props: buttonProps,
29
+ slots: Object as SlotsType<{
30
+ /** Content shown before the button label (left icon / custom content) */
31
+ prefix: void
32
+ /** Icon-only content for icon buttons */
33
+ icon: void
34
+ /** Main button content (overrides `label`) */
35
+ default: void
36
+ /** Content shown after the button label (right icon / custom content) */
37
+ suffix: void
38
+ }>,
39
+ setup(props, { attrs, slots, expose }) {
40
+ watchEffect(() => {
41
+ warnFeatherIconUsage('Button', 'icon', props.icon)
42
+ warnFeatherIconUsage('Button', 'iconLeft', props.iconLeft)
43
+ warnFeatherIconUsage('Button', 'iconRight', props.iconRight)
44
+ })
234
45
 
235
- const isDisabled = computed(() => {
236
- return props.disabled || props.loading
237
- })
46
+ const isDisabled = computed(() => props.disabled || props.loading)
47
+ const hasTooltip = computed(() => Boolean(props.tooltip?.length))
238
48
 
239
- // Avoid "Maximum call stack size exceeded" error
240
- // when using <component is='button' /> inside <Button /> component
241
- // by using "render" function here to avoid conflicting html "button" component with
242
- // globally registered "Button" component in consumer apps
243
- const Root = computed(() => {
244
- if (!isDisabled.value && props.route) {
245
- return h(RouterLink, { to: props.route })
246
- }
49
+ // Reuse a surrounding <TooltipProvider> (button group) when present so the
50
+ // group's skip-delay applies to this button instead of a private provider.
51
+ const parentTooltipProvider = injectTooltipProviderContext(null)
247
52
 
248
- if (!isDisabled.value && props.link) {
249
- return h('a', {
250
- href: props.link,
251
- target: '_blank',
252
- rel: 'noreferrer noopener',
53
+ // Render as an icon button when the default slot is exactly one lucide-* icon.
54
+ const hasLucideIconInDefaultSlot = computed(() => {
55
+ const content = slots.default?.()
56
+ if (!Array.isArray(content)) return false
57
+ const name = (content[0]?.type as { name?: string })?.name
58
+ return typeof name === 'string' && name.startsWith('lucide-')
253
59
  })
254
- }
255
60
 
256
- return h('button', { type: props.type, disabled: isDisabled.value })
257
- })
258
-
259
- const isIconButton = computed(() => {
260
- return props.icon || slots.icon || hasLucideIconInDefaultSlot.value
261
- })
262
-
263
- const hasLucideIconInDefaultSlot = computed(() => {
264
- if (!slots.default) return false
61
+ const isIconButton = computed(
62
+ () =>
63
+ Boolean(props.icon) ||
64
+ Boolean(slots.icon) ||
65
+ hasLucideIconInDefaultSlot.value,
66
+ )
67
+
68
+ const slotClasses = computed(
69
+ () =>
70
+ ({ xs: 'h-4', sm: 'h-4', md: 'h-4.5', lg: 'h-5', xl: 'h-6', '2xl': 'h-6' })[
71
+ props.size
72
+ ],
73
+ )
74
+
75
+ const lucideSlotClasses = computed(
76
+ () =>
77
+ ({
78
+ xs: 'size-4',
79
+ sm: 'size-4',
80
+ md: 'size-4.5',
81
+ lg: 'size-5',
82
+ xl: 'size-6',
83
+ '2xl': 'size-6',
84
+ })[props.size],
85
+ )
86
+
87
+ const buttonClasses = computed(() => {
88
+ const solidClasses = {
89
+ gray: 'text-ink-white bg-surface-gray-7 hover:bg-surface-gray-6 active:bg-surface-gray-5',
90
+ blue: 'text-ink-white bg-blue-500 hover:bg-surface-blue-3 active:bg-blue-700',
91
+ green:
92
+ 'text-ink-white bg-surface-green-3 hover:bg-green-700 active:bg-green-800',
93
+ red: 'text-ink-white bg-surface-red-5 hover:bg-surface-red-6 active:bg-surface-red-7',
94
+ }[props.theme]
95
+
96
+ const subtleClasses = {
97
+ gray: 'text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4',
98
+ blue: 'text-ink-blue-3 bg-surface-blue-2 hover:bg-blue-200 active:bg-blue-300',
99
+ green:
100
+ 'text-green-800 bg-surface-green-2 hover:bg-green-200 active:bg-green-300',
101
+ red: 'text-red-700 bg-surface-red-2 hover:bg-surface-red-3 active:bg-surface-red-4',
102
+ }[props.theme]
103
+
104
+ const outlineClasses = {
105
+ gray: 'text-ink-gray-8 bg-surface-white bg-surface-white border border-outline-gray-2 hover:border-outline-gray-3 active:border-outline-gray-3 active:bg-surface-gray-4',
106
+ blue: 'text-ink-blue-3 bg-surface-white border border-outline-blue-1 hover:border-blue-400 active:border-blue-400 active:bg-blue-300',
107
+ green:
108
+ 'text-green-800 bg-surface-white border border-outline-green-2 hover:border-green-500 active:border-green-500 active:bg-green-300',
109
+ red: 'text-red-700 bg-surface-white border border-outline-red-1 hover:border-outline-red-2 active:border-outline-red-2 active:bg-surface-red-3',
110
+ }[props.theme]
111
+
112
+ const ghostClasses = {
113
+ gray: 'text-ink-gray-8 bg-transparent hover:bg-surface-gray-3 active:bg-surface-gray-4',
114
+ blue: 'text-ink-blue-3 bg-transparent hover:bg-blue-200 active:bg-blue-300',
115
+ green:
116
+ 'text-green-800 bg-transparent hover:bg-green-200 active:bg-green-300',
117
+ red: 'text-red-700 bg-transparent hover:bg-surface-red-3 active:bg-surface-red-4',
118
+ }[props.theme]
119
+
120
+ const focusClasses = {
121
+ gray: 'focus-visible:ring focus-visible:ring-outline-gray-3',
122
+ blue: 'focus-visible:ring focus-visible:ring-blue-400',
123
+ green: 'focus-visible:ring focus-visible:ring-outline-green-2',
124
+ red: 'focus-visible:ring focus-visible:ring-outline-red-2',
125
+ }[props.theme]
126
+
127
+ const variantClasses = {
128
+ subtle: subtleClasses,
129
+ solid: solidClasses,
130
+ outline: outlineClasses,
131
+ ghost: ghostClasses,
132
+ }[props.variant]
133
+
134
+ const themeVariant: ThemeVariant = `${props.theme}-${props.variant}`
135
+
136
+ const disabledClassesMap: Record<ThemeVariant, string> = {
137
+ 'gray-solid': 'bg-surface-gray-2 text-ink-gray-4',
138
+ 'gray-subtle': 'bg-surface-gray-2 text-ink-gray-4',
139
+ 'gray-outline':
140
+ 'bg-surface-gray-2 text-ink-gray-4 border border-outline-gray-2',
141
+ 'gray-ghost': 'text-ink-gray-4',
142
+
143
+ 'blue-solid': 'bg-blue-300 text-ink-white',
144
+ 'blue-subtle': 'bg-surface-blue-2 text-ink-blue-link',
145
+ 'blue-outline':
146
+ 'bg-surface-blue-2 text-ink-blue-link border border-outline-blue-1',
147
+ 'blue-ghost': 'text-ink-blue-link',
148
+
149
+ 'green-solid': 'bg-surface-green-2 text-ink-green-2',
150
+ 'green-subtle': 'bg-surface-green-2 text-ink-green-2',
151
+ 'green-outline':
152
+ 'bg-surface-green-2 text-ink-green-2 border border-outline-green-2',
153
+ 'green-ghost': 'text-ink-green-2',
154
+
155
+ 'red-solid': 'bg-surface-red-2 text-ink-red-2',
156
+ 'red-subtle': 'bg-surface-red-2 text-ink-red-2',
157
+ 'red-outline':
158
+ 'bg-surface-red-2 text-ink-red-2 border border-outline-red-1',
159
+ 'red-ghost': 'text-ink-red-2',
160
+ }
161
+ const disabledClasses = disabledClassesMap[themeVariant]
162
+
163
+ const sizeClasses = isIconButton.value
164
+ ? {
165
+ xs: 'h-6 w-6 rounded',
166
+ sm: 'h-7 w-7 rounded',
167
+ md: 'h-8 w-8 rounded',
168
+ lg: 'h-10 w-10 rounded-md',
169
+ xl: 'h-11.5 w-11.5 rounded-lg',
170
+ '2xl': 'h-13 w-13 rounded-xl',
171
+ }[props.size]
172
+ : {
173
+ xs: 'h-6 text-sm px-1.5 rounded',
174
+ sm: 'h-7 text-base px-2 rounded',
175
+ md: 'h-8 text-base font-medium px-2.5 rounded',
176
+ lg: 'h-10 text-lg font-medium px-3 rounded-md',
177
+ xl: 'h-11.5 text-xl font-medium px-3.5 rounded-lg',
178
+ '2xl': 'h-13 text-2xl font-medium px-3.5 rounded-xl',
179
+ }[props.size]
180
+
181
+ return [
182
+ 'inline-flex items-center justify-center gap-2 transition-colors focus:outline-none shrink-0',
183
+ isDisabled.value ? disabledClasses : variantClasses,
184
+ focusClasses,
185
+ sizeClasses,
186
+ ]
187
+ })
265
188
 
266
- const slotContent = slots.default()
267
- if (!Array.isArray(slotContent)) return false
268
- // if the slot contains only one element and it's a lucide icon
269
- // render it as an icon button
270
- let firstVNode = slotContent[0]
271
- if (
272
- typeof firstVNode.type?.name == 'string' &&
273
- firstVNode.type?.name?.startsWith('lucide-')
274
- ) {
275
- return true
276
- }
277
- return false
189
+ const rootRef = ref()
190
+ expose({ rootRef })
191
+
192
+ // The dynamic root: router link, external anchor, or native button. Using the
193
+ // raw 'button' string (not <component :is>) sidesteps the historic recursion
194
+ // with a globally-registered <Button> in consumer apps.
195
+ const root = computed<{ is: Component | string; props: Record<string, unknown> }>(
196
+ () => {
197
+ if (!isDisabled.value && props.route) {
198
+ return { is: RouterLink, props: { to: props.route } }
199
+ }
200
+ if (!isDisabled.value && props.link) {
201
+ return {
202
+ is: 'a',
203
+ props: { href: props.link, target: '_blank', rel: 'noreferrer noopener' },
204
+ }
205
+ }
206
+ return { is: 'button', props: { type: props.type, disabled: isDisabled.value } }
207
+ },
208
+ )
209
+
210
+ /** Resolve an icon prop to a vnode: lucide class-span, FeatherIcon, or component. */
211
+ function renderIcon(
212
+ icon: string | Component | undefined,
213
+ featherHidden: boolean,
214
+ ): VNode | null {
215
+ if (!icon) return null
216
+ if (typeof icon === 'string') {
217
+ if (icon.startsWith('lucide-')) {
218
+ return h('span', {
219
+ class: [icon, lucideSlotClasses.value],
220
+ 'aria-hidden': 'true',
221
+ })
222
+ }
223
+ return h(FeatherIcon, {
224
+ name: icon,
225
+ class: slotClasses.value,
226
+ ...(featherHidden ? { 'aria-hidden': 'true' } : {}),
227
+ })
228
+ }
229
+ return h(icon, { class: slotClasses.value })
230
+ }
231
+
232
+ function renderPrefix() {
233
+ if (props.loading) {
234
+ return h(LoadingIndicator, {
235
+ class: {
236
+ 'h-3 w-3': props.size === 'xs' || props.size === 'sm',
237
+ 'h-[13.5px] w-[13.5px]': props.size === 'md',
238
+ 'h-[15px] w-[15px]': props.size === 'lg',
239
+ 'h-4.5 w-4.5': props.size === 'xl' || props.size === '2xl',
240
+ },
241
+ })
242
+ }
243
+ if (slots.prefix) return slots.prefix()
244
+ return renderIcon(props.iconLeft, true)
245
+ }
246
+
247
+ function renderMain() {
248
+ if (props.loading && props.loadingText) return props.loadingText
249
+ if (isIconButton.value && !props.loading) {
250
+ if (props.icon) return renderIcon(props.icon, false)
251
+ if (slots.icon) return slots.icon()
252
+ if (hasLucideIconInDefaultSlot.value) {
253
+ return h('div', { class: slotClasses.value }, slots.default?.() ?? props.label)
254
+ }
255
+ return null
256
+ }
257
+ return h(
258
+ 'span',
259
+ { class: ['truncate', { 'sr-only': isIconButton.value }] },
260
+ slots.default?.() ?? props.label,
261
+ )
262
+ }
263
+
264
+ function renderSuffix() {
265
+ if (slots.suffix) return slots.suffix()
266
+ return renderIcon(props.iconRight, true)
267
+ }
268
+
269
+ return () => {
270
+ const { class: attrClass, ...restAttrs } = attrs
271
+ const { is, props: rootProps } = root.value
272
+ const children = [renderPrefix(), renderMain(), renderSuffix()]
273
+ const mergedProps = {
274
+ ...rootProps,
275
+ ...restAttrs,
276
+ class: [attrClass, buttonClasses.value],
277
+ 'aria-label': props.label,
278
+ ref: rootRef,
279
+ }
280
+ const button =
281
+ typeof is === 'string'
282
+ ? h(is, mergedProps, children)
283
+ : h(is, mergedProps, { default: () => children })
284
+
285
+ if (!hasTooltip.value) return button
286
+
287
+ // Tooltip scaffolding renders only when a tooltip is set, so a bare button
288
+ // ships without any tooltip context, listeners, or pointerdown-to-close.
289
+ const tooltipRoot = h(TooltipRoot, null, {
290
+ default: () => [
291
+ h(TooltipTrigger, { asChild: true }, { default: () => button }),
292
+ h(TooltipBubble, { text: props.tooltip }),
293
+ ],
294
+ })
295
+
296
+ // Inside a button group, the provider already exists upstream — mounting
297
+ // our own here would isolate this button from the shared skip-delay.
298
+ return parentTooltipProvider
299
+ ? tooltipRoot
300
+ : h(TooltipProvider, null, { default: () => tooltipRoot })
301
+ }
302
+ },
278
303
  })
279
-
280
- const rootRef = ref()
281
- defineExpose({ rootRef })
282
-
283
- defineSlots<{
284
- /** Content shown before the button label (left icon / custom content) */
285
- prefix?: () => any
286
-
287
- /** Icon-only content for icon buttons */
288
- icon?: () => any
289
-
290
- /** Main button content (overrides `label`) */
291
- default?: () => any
292
-
293
- /** Content shown after the button label (right icon / custom content) */
294
- suffix?: () => any
295
- }>()
296
304
  </script>