@m3ui-vue/m3ui-vue 0.1.0

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 (185) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +102 -0
  3. package/dist/components/MAlert.vue.d.ts +27 -0
  4. package/dist/components/MAppBar.vue.d.ts +24 -0
  5. package/dist/components/MAvatar.vue.d.ts +9 -0
  6. package/dist/components/MBadge.vue.d.ts +22 -0
  7. package/dist/components/MBottomSheet.vue.d.ts +26 -0
  8. package/dist/components/MBreadcrumbs.vue.d.ts +19 -0
  9. package/dist/components/MButton.vue.d.ts +32 -0
  10. package/dist/components/MCalendar.vue.d.ts +23 -0
  11. package/dist/components/MCard.vue.d.ts +28 -0
  12. package/dist/components/MChart.vue.d.ts +13 -0
  13. package/dist/components/MCheckbox.vue.d.ts +26 -0
  14. package/dist/components/MChip.vue.d.ts +33 -0
  15. package/dist/components/MCodeEditor.vue.d.ts +35 -0
  16. package/dist/components/MColorPicker.vue.d.ts +18 -0
  17. package/dist/components/MCommandPalette.vue.d.ts +29 -0
  18. package/dist/components/MConfirmDialog.vue.d.ts +23 -0
  19. package/dist/components/MContainer.vue.d.ts +24 -0
  20. package/dist/components/MContextMenu.vue.d.ts +35 -0
  21. package/dist/components/MDataTable.vue.d.ts +83 -0
  22. package/dist/components/MDatePicker.vue.d.ts +21 -0
  23. package/dist/components/MDateRangePicker.vue.d.ts +24 -0
  24. package/dist/components/MDialog.vue.d.ts +30 -0
  25. package/dist/components/MDivider.vue.d.ts +11 -0
  26. package/dist/components/MDragDropList.vue.d.ts +40 -0
  27. package/dist/components/MEmptyState.vue.d.ts +21 -0
  28. package/dist/components/MExpansionPanel.vue.d.ts +28 -0
  29. package/dist/components/MFab.vue.d.ts +28 -0
  30. package/dist/components/MFileUpload.vue.d.ts +25 -0
  31. package/dist/components/MGrid.vue.d.ts +26 -0
  32. package/dist/components/MHotkeys.vue.d.ts +16 -0
  33. package/dist/components/MIcon.vue.d.ts +9 -0
  34. package/dist/components/MIconButton.vue.d.ts +14 -0
  35. package/dist/components/MInfiniteScroll.vue.d.ts +34 -0
  36. package/dist/components/MJsonEditor.vue.d.ts +17 -0
  37. package/dist/components/MJsonViewer.vue.d.ts +14 -0
  38. package/dist/components/MKanban.vue.d.ts +53 -0
  39. package/dist/components/MLoadingOverlay.vue.d.ts +28 -0
  40. package/dist/components/MMarkdown.vue.d.ts +11 -0
  41. package/dist/components/MMasonry.vue.d.ts +23 -0
  42. package/dist/components/MMenu.vue.d.ts +27 -0
  43. package/dist/components/MMenuItem.vue.d.ts +16 -0
  44. package/dist/components/MMultiSelect.vue.d.ts +34 -0
  45. package/dist/components/MNavigationBar.vue.d.ts +18 -0
  46. package/dist/components/MNavigationDrawer.vue.d.ts +41 -0
  47. package/dist/components/MNavigationRail.vue.d.ts +32 -0
  48. package/dist/components/MPagination.vue.d.ts +12 -0
  49. package/dist/components/MProgressBar.vue.d.ts +13 -0
  50. package/dist/components/MRadio.vue.d.ts +17 -0
  51. package/dist/components/MRadioGroup.vue.d.ts +24 -0
  52. package/dist/components/MRating.vue.d.ts +23 -0
  53. package/dist/components/MResult.vue.d.ts +20 -0
  54. package/dist/components/MRichTextEditor.vue.d.ts +17 -0
  55. package/dist/components/MScheduler.vue.d.ts +35 -0
  56. package/dist/components/MSegmentedButton.vue.d.ts +24 -0
  57. package/dist/components/MSelect.vue.d.ts +29 -0
  58. package/dist/components/MSideSheet.vue.d.ts +28 -0
  59. package/dist/components/MSkeleton.vue.d.ts +14 -0
  60. package/dist/components/MSlider.vue.d.ts +24 -0
  61. package/dist/components/MSnackbar.vue.d.ts +3 -0
  62. package/dist/components/MSpinner.vue.d.ts +10 -0
  63. package/dist/components/MSplitter.vue.d.ts +26 -0
  64. package/dist/components/MSpotlightSearch.vue.d.ts +34 -0
  65. package/dist/components/MStack.vue.d.ts +30 -0
  66. package/dist/components/MStatCard.vue.d.ts +24 -0
  67. package/dist/components/MStepper.vue.d.ts +33 -0
  68. package/dist/components/MSwitch.vue.d.ts +14 -0
  69. package/dist/components/MTable.vue.d.ts +73 -0
  70. package/dist/components/MTabs.vue.d.ts +20 -0
  71. package/dist/components/MTerminal.vue.d.ts +25 -0
  72. package/dist/components/MTextField.vue.d.ts +41 -0
  73. package/dist/components/MTimePicker.vue.d.ts +20 -0
  74. package/dist/components/MTimeline.vue.d.ts +31 -0
  75. package/dist/components/MTooltip.vue.d.ts +21 -0
  76. package/dist/components/MTopAppBar.vue.d.ts +29 -0
  77. package/dist/components/MTour.vue.d.ts +19 -0
  78. package/dist/components/MTransferList.vue.d.ts +23 -0
  79. package/dist/components/MTree.vue.d.ts +68 -0
  80. package/dist/components/MTreeTable.vue.d.ts +57 -0
  81. package/dist/components/MVirtualTable.vue.d.ts +40 -0
  82. package/dist/components/_MContextMenuPanel.vue.d.ts +13 -0
  83. package/dist/components/_MTreeNode.vue.d.ts +26 -0
  84. package/dist/composables/useColorPalette.d.ts +11 -0
  85. package/dist/composables/useFieldBg.d.ts +13 -0
  86. package/dist/composables/useTheme.d.ts +5 -0
  87. package/dist/composables/useToast.d.ts +59 -0
  88. package/dist/index.d.ts +112 -0
  89. package/dist/m3ui.css +2 -0
  90. package/dist/m3ui.js +7432 -0
  91. package/dist/m3ui.js.map +1 -0
  92. package/dist/plugin.d.ts +9 -0
  93. package/dist/styles/palettes.css +1253 -0
  94. package/dist/styles/theme.css +249 -0
  95. package/package.json +166 -0
  96. package/src/components/MAlert.vue +69 -0
  97. package/src/components/MAppBar.vue +40 -0
  98. package/src/components/MAvatar.vue +21 -0
  99. package/src/components/MBadge.vue +46 -0
  100. package/src/components/MBottomSheet.vue +113 -0
  101. package/src/components/MBreadcrumbs.vue +52 -0
  102. package/src/components/MButton.vue +111 -0
  103. package/src/components/MCalendar.vue +173 -0
  104. package/src/components/MCard.vue +56 -0
  105. package/src/components/MChart.vue +158 -0
  106. package/src/components/MCheckbox.vue +48 -0
  107. package/src/components/MChip.vue +87 -0
  108. package/src/components/MCodeEditor.vue +179 -0
  109. package/src/components/MColorPicker.vue +305 -0
  110. package/src/components/MCommandPalette.vue +213 -0
  111. package/src/components/MConfirmDialog.vue +43 -0
  112. package/src/components/MContainer.vue +36 -0
  113. package/src/components/MContextMenu.vue +66 -0
  114. package/src/components/MDataTable.vue +376 -0
  115. package/src/components/MDatePicker.vue +253 -0
  116. package/src/components/MDateRangePicker.vue +265 -0
  117. package/src/components/MDialog.vue +90 -0
  118. package/src/components/MDivider.vue +26 -0
  119. package/src/components/MDragDropList.vue +111 -0
  120. package/src/components/MEmptyState.vue +40 -0
  121. package/src/components/MExpansionPanel.vue +112 -0
  122. package/src/components/MFab.vue +220 -0
  123. package/src/components/MFileUpload.vue +206 -0
  124. package/src/components/MGrid.vue +99 -0
  125. package/src/components/MHotkeys.vue +122 -0
  126. package/src/components/MIcon.vue +9 -0
  127. package/src/components/MIconButton.vue +49 -0
  128. package/src/components/MInfiniteScroll.vue +68 -0
  129. package/src/components/MJsonEditor.vue +118 -0
  130. package/src/components/MJsonViewer.vue +106 -0
  131. package/src/components/MKanban.vue +147 -0
  132. package/src/components/MLoadingOverlay.vue +52 -0
  133. package/src/components/MMarkdown.vue +123 -0
  134. package/src/components/MMasonry.vue +87 -0
  135. package/src/components/MMenu.vue +113 -0
  136. package/src/components/MMenuItem.vue +15 -0
  137. package/src/components/MMultiSelect.vue +306 -0
  138. package/src/components/MNavigationBar.vue +62 -0
  139. package/src/components/MNavigationDrawer.vue +157 -0
  140. package/src/components/MNavigationRail.vue +80 -0
  141. package/src/components/MPagination.vue +37 -0
  142. package/src/components/MProgressBar.vue +200 -0
  143. package/src/components/MRadio.vue +89 -0
  144. package/src/components/MRadioGroup.vue +41 -0
  145. package/src/components/MRating.vue +108 -0
  146. package/src/components/MResult.vue +62 -0
  147. package/src/components/MRichTextEditor.vue +199 -0
  148. package/src/components/MScheduler.vue +225 -0
  149. package/src/components/MSegmentedButton.vue +75 -0
  150. package/src/components/MSelect.vue +259 -0
  151. package/src/components/MSideSheet.vue +112 -0
  152. package/src/components/MSkeleton.vue +60 -0
  153. package/src/components/MSlider.vue +188 -0
  154. package/src/components/MSnackbar.vue +244 -0
  155. package/src/components/MSpinner.vue +122 -0
  156. package/src/components/MSplitter.vue +97 -0
  157. package/src/components/MSpotlightSearch.vue +244 -0
  158. package/src/components/MStack.vue +67 -0
  159. package/src/components/MStatCard.vue +56 -0
  160. package/src/components/MStepper.vue +161 -0
  161. package/src/components/MSwitch.vue +63 -0
  162. package/src/components/MTable.vue +404 -0
  163. package/src/components/MTabs.vue +97 -0
  164. package/src/components/MTerminal.vue +146 -0
  165. package/src/components/MTextField.vue +180 -0
  166. package/src/components/MTimePicker.vue +227 -0
  167. package/src/components/MTimeline.vue +117 -0
  168. package/src/components/MTooltip.vue +82 -0
  169. package/src/components/MTopAppBar.vue +62 -0
  170. package/src/components/MTour.vue +226 -0
  171. package/src/components/MTransferList.vue +181 -0
  172. package/src/components/MTree.vue +164 -0
  173. package/src/components/MTreeTable.vue +159 -0
  174. package/src/components/MVirtualTable.vue +155 -0
  175. package/src/components/_MContextMenuPanel.vue +129 -0
  176. package/src/components/_MTreeNode.vue +171 -0
  177. package/src/composables/useColorPalette.ts +60 -0
  178. package/src/composables/useFieldBg.ts +91 -0
  179. package/src/composables/useTheme.ts +55 -0
  180. package/src/composables/useToast.ts +51 -0
  181. package/src/env.d.ts +1 -0
  182. package/src/index.ts +119 -0
  183. package/src/plugin.ts +18 -0
  184. package/src/styles/palettes.css +1253 -0
  185. package/src/styles/theme.css +249 -0
@@ -0,0 +1,111 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from 'vue'
3
+ import MIcon from './MIcon.vue'
4
+
5
+ export interface DragDropItem {
6
+ id: string | number
7
+ [key: string]: any
8
+ }
9
+
10
+ const props = withDefaults(
11
+ defineProps<{
12
+ modelValue: DragDropItem[]
13
+ handle?: boolean
14
+ }>(),
15
+ { handle: false },
16
+ )
17
+
18
+ const emit = defineEmits<{
19
+ 'update:modelValue': [DragDropItem[]]
20
+ reorder: [{ from: number; to: number; items: DragDropItem[] }]
21
+ }>()
22
+
23
+ const dragIndex = ref<number | null>(null)
24
+ const overIndex = ref<number | null>(null)
25
+
26
+ function onDragStart(e: DragEvent, index: number) {
27
+ dragIndex.value = index
28
+ if (e.dataTransfer) {
29
+ e.dataTransfer.effectAllowed = 'move'
30
+ e.dataTransfer.setData('text/plain', String(index))
31
+ }
32
+ }
33
+
34
+ function onDragOver(e: DragEvent, index: number) {
35
+ e.preventDefault()
36
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
37
+ overIndex.value = index
38
+ }
39
+
40
+ function onDragLeave() {
41
+ overIndex.value = null
42
+ }
43
+
44
+ function onDrop(e: DragEvent, toIndex: number) {
45
+ e.preventDefault()
46
+ const fromIndex = dragIndex.value
47
+ if (fromIndex === null || fromIndex === toIndex) {
48
+ reset()
49
+ return
50
+ }
51
+
52
+ const items = [...props.modelValue]
53
+ const moved = items.splice(fromIndex, 1)[0]!
54
+ items.splice(toIndex, 0, moved)
55
+
56
+ emit('update:modelValue', items)
57
+ emit('reorder', { from: fromIndex, to: toIndex, items })
58
+ reset()
59
+ }
60
+
61
+ function onDragEnd() {
62
+ reset()
63
+ }
64
+
65
+ function reset() {
66
+ dragIndex.value = null
67
+ overIndex.value = null
68
+ }
69
+
70
+ function getItemClass(index: number) {
71
+ if (dragIndex.value === index) return 'opacity-30'
72
+ if (overIndex.value === index && dragIndex.value !== null) return 'ring-2 ring-primary ring-inset'
73
+ return ''
74
+ }
75
+ </script>
76
+
77
+ <template>
78
+ <div class="flex flex-col" role="listbox">
79
+ <div
80
+ v-for="(item, index) in modelValue"
81
+ :key="item.id"
82
+ :draggable="!handle"
83
+ class="group flex items-center gap-2 rounded-lg px-3 py-2 transition-all"
84
+ :class="[
85
+ getItemClass(index),
86
+ !handle && 'cursor-grab active:cursor-grabbing',
87
+ ]"
88
+ @dragstart="!handle && onDragStart($event, index)"
89
+ @dragover="onDragOver($event, index)"
90
+ @dragleave="onDragLeave"
91
+ @drop="onDrop($event, index)"
92
+ @dragend="onDragEnd"
93
+ role="option"
94
+ >
95
+ <div
96
+ v-if="handle"
97
+ class="flex shrink-0 cursor-grab items-center justify-center rounded p-0.5 text-on-surface-variant/50 transition-colors hover:text-on-surface-variant active:cursor-grabbing"
98
+ draggable="true"
99
+ @dragstart="onDragStart($event, index)"
100
+ >
101
+ <MIcon name="drag_indicator" :size="20" />
102
+ </div>
103
+
104
+ <div class="min-w-0 flex-1">
105
+ <slot :item="item" :index="index">
106
+ <span class="text-body-medium text-on-surface">{{ item.id }}</span>
107
+ </slot>
108
+ </div>
109
+ </div>
110
+ </div>
111
+ </template>
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts">
2
+ import MIcon from './MIcon.vue'
3
+
4
+ withDefaults(defineProps<{
5
+ icon?: string
6
+ title: string
7
+ description?: string
8
+ compact?: boolean
9
+ }>(), { icon: 'inbox' })
10
+ </script>
11
+
12
+ <template>
13
+ <div
14
+ class="flex flex-col items-center justify-center text-center"
15
+ :class="compact ? 'gap-2 py-6' : 'gap-3 py-14'"
16
+ >
17
+ <div
18
+ class="flex items-center justify-center rounded-full bg-surface-container-high text-on-surface-variant"
19
+ :class="compact ? 'h-12 w-12' : 'h-16 w-16'"
20
+ >
21
+ <MIcon :name="icon" :size="compact ? 24 : 32" />
22
+ </div>
23
+ <h3
24
+ class="font-medium text-on-surface"
25
+ :class="compact ? 'text-title-small' : 'text-title-medium'"
26
+ >
27
+ {{ title }}
28
+ </h3>
29
+ <p
30
+ v-if="description"
31
+ class="max-w-sm text-on-surface-variant"
32
+ :class="compact ? 'text-body-small' : 'text-body-medium'"
33
+ >
34
+ {{ description }}
35
+ </p>
36
+ <div v-if="$slots.actions" :class="compact ? 'mt-1' : 'mt-2'">
37
+ <slot name="actions" />
38
+ </div>
39
+ </div>
40
+ </template>
@@ -0,0 +1,112 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import MIcon from './MIcon.vue'
4
+
5
+ const props = withDefaults(defineProps<{
6
+ title: string
7
+ subtitle?: string
8
+ icon?: string
9
+ modelValue?: boolean
10
+ disabled?: boolean
11
+ variant?: 'outlined' | 'filled' | 'elevated'
12
+ }>(), { disabled: false, variant: 'outlined' })
13
+
14
+ const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
15
+
16
+ const internal = ref(false)
17
+ const isOpen = computed(() =>
18
+ props.modelValue !== undefined ? props.modelValue : internal.value,
19
+ )
20
+
21
+ function toggle() {
22
+ if (props.disabled) return
23
+ const next = !isOpen.value
24
+ if (props.modelValue !== undefined) emit('update:modelValue', next)
25
+ else internal.value = next
26
+ }
27
+
28
+ const wrapperClass = computed(() => {
29
+ if (props.variant === 'filled') return 'bg-surface-container-low rounded-md'
30
+ if (props.variant === 'elevated') return 'bg-surface-container-low rounded-md shadow-elevation-1'
31
+ return 'rounded-md border border-outline-variant'
32
+ })
33
+ </script>
34
+
35
+ <template>
36
+ <div :class="wrapperClass" class="overflow-hidden">
37
+ <!-- Header / trigger -->
38
+ <button
39
+ type="button"
40
+ class="flex w-full items-center gap-4 px-5 py-4 text-left transition-colors duration-150 focus-visible:outline-none"
41
+ :class="[
42
+ disabled ? 'cursor-not-allowed opacity-[0.38]' : 'cursor-pointer hover:bg-on-surface/4',
43
+ isOpen ? 'bg-on-surface/4' : '',
44
+ ]"
45
+ :aria-expanded="isOpen"
46
+ :disabled="disabled"
47
+ @click="toggle"
48
+ >
49
+ <MIcon v-if="icon" :name="icon" :size="22" class="shrink-0 text-on-surface-variant" />
50
+ <div class="flex-1 min-w-0">
51
+ <p class="text-body-large font-medium text-on-surface">{{ title }}</p>
52
+ <p v-if="subtitle" class="text-body-small text-on-surface-variant">{{ subtitle }}</p>
53
+ </div>
54
+ <MIcon
55
+ name="expand_more"
56
+ :size="22"
57
+ class="shrink-0 text-on-surface-variant transition-transform duration-200"
58
+ :class="isOpen ? 'rotate-180' : ''"
59
+ />
60
+ </button>
61
+
62
+ <!-- Content with height animation -->
63
+ <Transition name="expand">
64
+ <div v-if="isOpen" class="expand-grid">
65
+ <div class="expand-body border-t border-outline-variant/60 px-5 py-4">
66
+ <slot />
67
+ </div>
68
+ </div>
69
+ </Transition>
70
+ </div>
71
+ </template>
72
+
73
+ <style scoped>
74
+ /*
75
+ grid-template-rows: 0fr → 1fr expands to the exact content height,
76
+ so the animation is always proportional — no max-height overshoot.
77
+ */
78
+ .expand-grid {
79
+ display: grid;
80
+ grid-template-rows: 1fr;
81
+ }
82
+ .expand-body {
83
+ min-height: 0; /* required for 0fr to actually collapse */
84
+ overflow: hidden;
85
+ }
86
+
87
+ .expand-enter-active {
88
+ transition: grid-template-rows 280ms cubic-bezier(0.2, 0, 0, 1);
89
+ }
90
+ .expand-enter-active > .expand-body {
91
+ transition: opacity 220ms ease;
92
+ }
93
+ .expand-enter-from {
94
+ grid-template-rows: 0fr;
95
+ }
96
+ .expand-enter-from > .expand-body {
97
+ opacity: 0;
98
+ }
99
+
100
+ .expand-leave-active {
101
+ transition: grid-template-rows 220ms cubic-bezier(0.4, 0, 1, 1);
102
+ }
103
+ .expand-leave-active > .expand-body {
104
+ transition: opacity 150ms ease;
105
+ }
106
+ .expand-leave-to {
107
+ grid-template-rows: 0fr;
108
+ }
109
+ .expand-leave-to > .expand-body {
110
+ opacity: 0;
111
+ }
112
+ </style>
@@ -0,0 +1,220 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, onUnmounted, ref } from 'vue'
3
+ import MIcon from './MIcon.vue'
4
+
5
+ export interface SpeedDialItem {
6
+ icon: string
7
+ label?: string
8
+ onClick?: () => void
9
+ }
10
+
11
+ const props = withDefaults(
12
+ defineProps<{
13
+ icon: string
14
+ label?: string
15
+ color?: 'primary' | 'secondary' | 'tertiary' | 'surface'
16
+ size?: 'small' | 'regular' | 'large'
17
+ disabled?: boolean
18
+ /** Speed-dial child items. If provided, clicking the FAB toggles them instead of emitting click. */
19
+ items?: SpeedDialItem[]
20
+ /** Direction the speed-dial items expand toward. */
21
+ direction?: 'up' | 'down' | 'left' | 'right' | 'radial'
22
+ }>(),
23
+ {
24
+ color: 'primary',
25
+ size: 'regular',
26
+ disabled: false,
27
+ direction: 'up',
28
+ },
29
+ )
30
+
31
+ const emit = defineEmits<{ click: [MouseEvent] }>()
32
+
33
+ const open = ref(false)
34
+ const containerEl = ref<HTMLElement>()
35
+
36
+ const hasItems = computed(() => !!props.items?.length)
37
+
38
+ const colorMap: Record<string, string> = {
39
+ primary: 'bg-primary-container text-on-primary-container',
40
+ secondary: 'bg-secondary-container text-on-secondary-container',
41
+ tertiary: 'bg-tertiary-container text-on-tertiary-container',
42
+ surface: 'bg-surface-container-high text-primary',
43
+ }
44
+
45
+ const fabSizeClasses = computed(() => {
46
+ if (props.label) return 'h-14 rounded-2xl px-4 gap-3'
47
+ switch (props.size) {
48
+ case 'small': return 'h-10 w-10 rounded-lg'
49
+ case 'large': return 'h-24 w-24 rounded-[28px]'
50
+ default: return 'h-14 w-14 rounded-2xl'
51
+ }
52
+ })
53
+
54
+ const fabIconSize = computed(() => {
55
+ if (props.label) return 24
56
+ switch (props.size) {
57
+ case 'small': return 20
58
+ case 'large': return 36
59
+ default: return 24
60
+ }
61
+ })
62
+
63
+ // FAB height in px — used to position items relative to the container
64
+ const fabPx = computed(() => {
65
+ if (props.label) return 56
66
+ switch (props.size) {
67
+ case 'small': return 40
68
+ case 'large': return 96
69
+ default: return 56
70
+ }
71
+ })
72
+
73
+ // Item size (always small-FAB-sized): 40px
74
+ const ITEM_PX = 40
75
+ const ITEM_GAP = 8
76
+
77
+ function itemStyle(index: number): Record<string, string> {
78
+ const count = props.items?.length ?? 0
79
+ // Stagger delay: open = forward order, close = reverse order
80
+ const delay = open.value
81
+ ? `${index * 35}ms`
82
+ : `${(count - 1 - index) * 35}ms`
83
+
84
+ const transition = `transform 220ms cubic-bezier(0.2,0,0,1) ${delay}, opacity 180ms ease ${delay}`
85
+
86
+ if (props.direction === 'radial') {
87
+ const angle = (2 * Math.PI * index) / count - Math.PI / 2
88
+ const r = 80
89
+ const dx = (Math.cos(angle) * r).toFixed(1)
90
+ const dy = (Math.sin(angle) * r).toFixed(1)
91
+ return {
92
+ position: 'absolute',
93
+ top: '50%',
94
+ left: '50%',
95
+ marginTop: `${-ITEM_PX / 2}px`,
96
+ marginLeft: `${-ITEM_PX / 2}px`,
97
+ transform: open.value ? `translate(${dx}px, ${dy}px) scale(1)` : 'translate(0,0) scale(0)',
98
+ opacity: open.value ? '1' : '0',
99
+ transition,
100
+ pointerEvents: open.value ? 'auto' : 'none',
101
+ }
102
+ }
103
+
104
+ // Linear directions: offset from the container edge
105
+ const step = ITEM_PX + ITEM_GAP
106
+ const base = fabPx.value + ITEM_GAP + index * step
107
+
108
+ const offsetMap: Record<string, Record<string, string>> = {
109
+ up: { bottom: `${base}px`, left: '50%', marginLeft: `${-ITEM_PX / 2}px` },
110
+ down: { top: `${base}px`, left: '50%', marginLeft: `${-ITEM_PX / 2}px` },
111
+ left: { right: `${base}px`, top: '50%', marginTop: `${-ITEM_PX / 2}px` },
112
+ right: { left: `${base}px`, top: '50%', marginTop: `${-ITEM_PX / 2}px` },
113
+ }
114
+
115
+ const translateFrom: Record<string, string> = {
116
+ up: 'translateY(12px) scale(0.75)',
117
+ down: 'translateY(-12px) scale(0.75)',
118
+ left: 'translateX(12px) scale(0.75)',
119
+ right: 'translateX(-12px) scale(0.75)',
120
+ }
121
+
122
+ return {
123
+ position: 'absolute',
124
+ ...offsetMap[props.direction] ?? offsetMap.up,
125
+ transform: open.value ? 'translate(0,0) scale(1)' : (translateFrom[props.direction] ?? 'scale(0.75)'),
126
+ opacity: open.value ? '1' : '0',
127
+ transition,
128
+ pointerEvents: open.value ? 'auto' : 'none',
129
+ }
130
+ }
131
+
132
+ // Label only makes sense for up/down; placed to the left of the item button
133
+ const showLabel = computed(() => props.direction === 'up' || props.direction === 'down')
134
+
135
+ function createRipple(event: PointerEvent | MouseEvent, target?: HTMLElement) {
136
+ const button = (target ?? event.currentTarget) as HTMLElement
137
+ const rect = button.getBoundingClientRect()
138
+ const d = Math.max(rect.width, rect.height) * 2
139
+ const el = document.createElement('span')
140
+ el.className = 'm3-ripple'
141
+ el.style.cssText = `width:${d}px;height:${d}px;top:${event.clientY - rect.top - d / 2}px;left:${event.clientX - rect.left - d / 2}px`
142
+ button.appendChild(el)
143
+ el.addEventListener('animationend', () => el.remove(), { once: true })
144
+ }
145
+
146
+ function handleFabClick(e: PointerEvent) {
147
+ if (hasItems.value) {
148
+ open.value = !open.value
149
+ } else {
150
+ emit('click', e)
151
+ }
152
+ }
153
+
154
+ function handleItemClick(e: PointerEvent, item: SpeedDialItem, buttonEl: HTMLElement) {
155
+ createRipple(e, buttonEl)
156
+ open.value = false
157
+ item.onClick?.()
158
+ }
159
+
160
+ function onDocClick(e: MouseEvent) {
161
+ if (!open.value) return
162
+ if (containerEl.value && !containerEl.value.contains(e.target as Node)) {
163
+ open.value = false
164
+ }
165
+ }
166
+
167
+ onMounted(() => document.addEventListener('click', onDocClick, true))
168
+ onUnmounted(() => document.removeEventListener('click', onDocClick, true))
169
+ </script>
170
+
171
+ <template>
172
+ <div ref="containerEl" class="relative inline-flex items-center justify-center">
173
+ <!-- Speed-dial items (absolutely positioned outside the container) -->
174
+ <template v-if="hasItems">
175
+ <div
176
+ v-for="(item, i) in items"
177
+ :key="i"
178
+ :style="itemStyle(i)"
179
+ class="flex items-center gap-3"
180
+ :class="showLabel ? 'flex-row-reverse' : ''"
181
+ >
182
+ <!-- Label pill (up/down only) -->
183
+ <span
184
+ v-if="item.label && showLabel"
185
+ class="whitespace-nowrap rounded-md bg-surface-container-high px-3 py-1.5 text-label-medium text-on-surface shadow-elevation-1"
186
+ >
187
+ {{ item.label }}
188
+ </span>
189
+
190
+ <!-- Mini FAB button -->
191
+ <button
192
+ type="button"
193
+ class="relative flex cursor-pointer items-center justify-center overflow-hidden rounded-lg shadow-elevation-1 transition-shadow duration-150 hover:shadow-elevation-2 active:shadow-elevation-1 before:content-[''] before:pointer-events-none before:absolute before:inset-0 before:bg-current before:opacity-0 before:transition-opacity before:duration-150 hover:before:opacity-[0.08] active:before:opacity-[0.12]"
194
+ :class="colorMap[color]"
195
+ :style="{ width: `${ITEM_PX}px`, height: `${ITEM_PX}px` }"
196
+ @pointerdown="(e) => handleItemClick(e, item, e.currentTarget as HTMLElement)"
197
+ >
198
+ <MIcon :name="item.icon" :size="20" />
199
+ </button>
200
+ </div>
201
+ </template>
202
+
203
+ <!-- Main FAB -->
204
+ <button
205
+ type="button"
206
+ class="relative inline-flex cursor-pointer items-center justify-center overflow-hidden shadow-elevation-1 transition-shadow duration-150 hover:shadow-elevation-2 active:shadow-elevation-1 disabled:cursor-not-allowed disabled:opacity-[0.38] before:content-[''] before:pointer-events-none before:absolute before:inset-0 before:bg-current before:opacity-0 before:transition-opacity before:duration-150 hover:before:opacity-[0.08] active:before:opacity-[0.12]"
207
+ :class="[colorMap[color], fabSizeClasses]"
208
+ :disabled="disabled"
209
+ @pointerdown="(e) => { createRipple(e); handleFabClick(e) }"
210
+ >
211
+ <MIcon
212
+ :name="icon"
213
+ :size="fabIconSize"
214
+ class="transition-transform duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]"
215
+ :class="hasItems && open ? 'rotate-45' : ''"
216
+ />
217
+ <span v-if="label" class="text-label-large font-medium">{{ label }}</span>
218
+ </button>
219
+ </div>
220
+ </template>
@@ -0,0 +1,206 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from 'vue'
3
+ import MIcon from './MIcon.vue'
4
+ import MIconButton from './MIconButton.vue'
5
+ import MSpinner from './MSpinner.vue'
6
+
7
+ export interface UploadFile {
8
+ file: File
9
+ id: string
10
+ progress: number
11
+ status: 'pending' | 'uploading' | 'done' | 'error'
12
+ preview?: string
13
+ }
14
+
15
+ const props = withDefaults(
16
+ defineProps<{
17
+ accept?: string
18
+ multiple?: boolean
19
+ maxSize?: number
20
+ disabled?: boolean
21
+ }>(),
22
+ { multiple: false, disabled: false },
23
+ )
24
+
25
+ const emit = defineEmits<{
26
+ select: [UploadFile[]]
27
+ remove: [UploadFile]
28
+ }>()
29
+
30
+ const files = ref<UploadFile[]>([])
31
+ const dragging = ref(false)
32
+ const inputRef = ref<HTMLInputElement | null>(null)
33
+
34
+ const acceptList = computed(() =>
35
+ props.accept ? props.accept.split(',').map((s) => s.trim()) : null,
36
+ )
37
+
38
+ function isAccepted(file: File) {
39
+ if (!acceptList.value) return true
40
+ return acceptList.value.some((a) => {
41
+ if (a.startsWith('.')) return file.name.toLowerCase().endsWith(a.toLowerCase())
42
+ if (a.endsWith('/*')) return file.type.startsWith(a.replace('/*', '/'))
43
+ return file.type === a
44
+ })
45
+ }
46
+
47
+ function formatSize(bytes: number) {
48
+ if (bytes < 1024) return `${bytes} B`
49
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
50
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
51
+ }
52
+
53
+ function processFiles(fileList: FileList | File[]) {
54
+ const arr = Array.from(fileList)
55
+ const valid = arr.filter((f) => {
56
+ if (!isAccepted(f)) return false
57
+ if (props.maxSize && f.size > props.maxSize) return false
58
+ return true
59
+ })
60
+
61
+ const entries: UploadFile[] = valid.map((f) => {
62
+ const entry: UploadFile = {
63
+ file: f,
64
+ id: crypto.randomUUID(),
65
+ progress: 0,
66
+ status: 'pending',
67
+ }
68
+ if (f.type.startsWith('image/')) {
69
+ entry.preview = URL.createObjectURL(f)
70
+ }
71
+ return entry
72
+ })
73
+
74
+ if (props.multiple) {
75
+ files.value.push(...entries)
76
+ } else {
77
+ files.value.forEach((f) => f.preview && URL.revokeObjectURL(f.preview))
78
+ files.value = entries.slice(0, 1)
79
+ }
80
+
81
+ emit('select', entries)
82
+ }
83
+
84
+ function onDrop(e: DragEvent) {
85
+ dragging.value = false
86
+ if (props.disabled || !e.dataTransfer?.files.length) return
87
+ processFiles(e.dataTransfer.files)
88
+ }
89
+
90
+ function onFileInput(e: Event) {
91
+ const input = e.target as HTMLInputElement
92
+ if (input.files?.length) processFiles(input.files)
93
+ input.value = ''
94
+ }
95
+
96
+ function removeFile(entry: UploadFile) {
97
+ if (entry.preview) URL.revokeObjectURL(entry.preview)
98
+ files.value = files.value.filter((f) => f.id !== entry.id)
99
+ emit('remove', entry)
100
+ }
101
+
102
+ function openPicker() {
103
+ if (!props.disabled) inputRef.value?.click()
104
+ }
105
+ </script>
106
+
107
+ <template>
108
+ <div class="flex flex-col gap-3">
109
+ <!-- Drop zone -->
110
+ <div
111
+ class="relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-6 transition-colors duration-150"
112
+ :class="[
113
+ disabled
114
+ ? 'cursor-not-allowed border-outline-variant/50 bg-surface-container/30 opacity-60'
115
+ : dragging
116
+ ? 'border-primary bg-primary-container/20'
117
+ : 'border-outline-variant bg-surface-container-lowest hover:border-primary/60 hover:bg-surface-container',
118
+ ]"
119
+ @click="openPicker"
120
+ @dragenter.prevent="dragging = true"
121
+ @dragover.prevent="dragging = true"
122
+ @dragleave.prevent="dragging = false"
123
+ @drop.prevent="onDrop"
124
+ >
125
+ <MIcon
126
+ :name="dragging ? 'downloading' : 'cloud_upload'"
127
+ :size="40"
128
+ class="text-on-surface-variant"
129
+ />
130
+ <div class="text-center">
131
+ <p class="text-body-large text-on-surface">
132
+ Arrastra archivos aquí o <span class="font-medium text-primary">selecciona</span>
133
+ </p>
134
+ <p v-if="accept || maxSize" class="mt-1 text-body-small text-on-surface-variant">
135
+ <span v-if="accept">{{ accept }}</span>
136
+ <span v-if="accept && maxSize"> · </span>
137
+ <span v-if="maxSize">Máx. {{ formatSize(maxSize) }}</span>
138
+ </p>
139
+ </div>
140
+ </div>
141
+
142
+ <input
143
+ ref="inputRef"
144
+ type="file"
145
+ class="hidden"
146
+ :accept="accept"
147
+ :multiple="multiple"
148
+ :disabled="disabled"
149
+ @change="onFileInput"
150
+ />
151
+
152
+ <!-- File list -->
153
+ <TransitionGroup
154
+ name="m3-file"
155
+ tag="div"
156
+ class="flex flex-col gap-2"
157
+ >
158
+ <div
159
+ v-for="entry in files"
160
+ :key="entry.id"
161
+ class="flex items-center gap-3 rounded-lg bg-surface-container p-3"
162
+ >
163
+ <!-- Preview / icon -->
164
+ <div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-md bg-surface-container-high">
165
+ <img v-if="entry.preview" :src="entry.preview" class="h-full w-full object-cover" />
166
+ <MIcon v-else name="description" :size="24" class="text-on-surface-variant" />
167
+ </div>
168
+
169
+ <!-- Info -->
170
+ <div class="min-w-0 flex-1">
171
+ <p class="truncate text-body-medium text-on-surface">{{ entry.file.name }}</p>
172
+ <p class="text-body-small text-on-surface-variant">{{ formatSize(entry.file.size) }}</p>
173
+ <!-- Progress bar -->
174
+ <div
175
+ v-if="entry.status === 'uploading'"
176
+ class="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-surface-container-highest"
177
+ >
178
+ <div
179
+ class="h-full rounded-full bg-primary transition-[width] duration-300"
180
+ :style="{ width: `${entry.progress}%` }"
181
+ />
182
+ </div>
183
+ </div>
184
+
185
+ <!-- Status -->
186
+ <MSpinner v-if="entry.status === 'uploading'" :size="20" />
187
+ <MIcon v-else-if="entry.status === 'done'" name="check_circle" :size="20" class="text-success" />
188
+ <MIcon v-else-if="entry.status === 'error'" name="error" :size="20" class="text-error" />
189
+
190
+ <MIconButton icon="close" label="Eliminar" :size="32" @click="removeFile(entry)" />
191
+ </div>
192
+ </TransitionGroup>
193
+ </div>
194
+ </template>
195
+
196
+ <style scoped>
197
+ .m3-file-enter-active,
198
+ .m3-file-leave-active {
199
+ transition: all 0.2s ease;
200
+ }
201
+ .m3-file-enter-from,
202
+ .m3-file-leave-to {
203
+ opacity: 0;
204
+ transform: translateY(-8px);
205
+ }
206
+ </style>