@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,244 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import { useToast } from "../composables/useToast";
4
+ import MIcon from "./MIcon.vue";
5
+
6
+ const { toasts, position, dismiss } = useToast();
7
+
8
+ const isTop = computed(() => position.value.startsWith("top"));
9
+
10
+ const containerClass = computed(() => {
11
+ const base = "pointer-events-none fixed z-[300] flex flex-col";
12
+ switch (position.value) {
13
+ case "top-left":
14
+ return `${base} top-4 left-4 items-start`;
15
+ case "top-center":
16
+ return `${base} top-4 left-1/2 -translate-x-1/2 items-center`;
17
+ case "top-right":
18
+ return `${base} top-4 right-4 items-end`;
19
+ case "bottom-left":
20
+ return `${base} bottom-4 left-4 items-start`;
21
+ case "bottom-right":
22
+ return `${base} bottom-4 right-4 items-end`;
23
+ default:
24
+ return `${base} bottom-4 left-1/2 -translate-x-1/2 items-center`;
25
+ }
26
+ });
27
+
28
+ type VariantStyle = {
29
+ container: string;
30
+ icon: string;
31
+ iconName: string;
32
+ action: string;
33
+ close: string;
34
+ progress: string;
35
+ };
36
+
37
+ const variantStyles: Record<string, VariantStyle> = {
38
+ info: {
39
+ // secondary-container tokens are adaptive light/dark out of the box
40
+ container:
41
+ "bg-secondary-container text-on-secondary-container ring-1 ring-inset ring-on-secondary-container/8",
42
+ icon: "text-on-secondary-container/70",
43
+ iconName: "info",
44
+ action: "text-on-secondary-container hover:bg-on-secondary-container/10",
45
+ close: "text-on-secondary-container/60 hover:bg-on-secondary-container/10",
46
+ progress: "bg-on-secondary-container/25",
47
+ },
48
+ success: {
49
+ container:
50
+ "bg-[#dcfce7] text-[#14532d] ring-1 ring-inset ring-[#14532d]/10 dark:bg-[#052e16] dark:text-[#bbf7d0] dark:ring-white/8",
51
+ icon: "text-[#16a34a] dark:text-[#4ade80]",
52
+ iconName: "check_circle",
53
+ action: "text-[#166534] hover:bg-[#14532d]/10 dark:text-[#86efac] dark:hover:bg-white/10",
54
+ close: "text-[#14532d]/50 hover:bg-[#14532d]/10 dark:text-[#bbf7d0]/50 dark:hover:bg-white/10",
55
+ progress: "bg-[#16a34a]/35 dark:bg-[#4ade80]/30",
56
+ },
57
+ warning: {
58
+ container:
59
+ "bg-[#fefce8] text-[#713f12] ring-1 ring-inset ring-[#713f12]/10 dark:bg-[#2d1a00] dark:text-[#fde68a] dark:ring-white/8",
60
+ icon: "text-[#d97706] dark:text-[#fcd34d]",
61
+ iconName: "warning",
62
+ action: "text-[#92400e] hover:bg-[#713f12]/10 dark:text-[#fcd34d] dark:hover:bg-white/10",
63
+ close: "text-[#713f12]/50 hover:bg-[#713f12]/10 dark:text-[#fde68a]/50 dark:hover:bg-white/10",
64
+ progress: "bg-[#d97706]/35 dark:bg-[#fcd34d]/30",
65
+ },
66
+ error: {
67
+ // error-container tokens are adaptive light/dark out of the box
68
+ container:
69
+ "bg-error-container text-on-error-container ring-1 ring-inset ring-on-error-container/8",
70
+ icon: "text-error dark:text-[#fca5a5]",
71
+ iconName: "error",
72
+ action: "text-on-error-container hover:bg-on-error-container/10",
73
+ close: "text-on-error-container/60 hover:bg-on-error-container/10",
74
+ progress: "bg-on-error-container/25",
75
+ },
76
+ };
77
+
78
+ const getVariantStyle = (variant: string): VariantStyle =>
79
+ variantStyles[variant] ?? variantStyles.info!;
80
+ </script>
81
+
82
+ <template>
83
+ <div :class="containerClass">
84
+ <TransitionGroup :name="isTop ? 'm3-toast-top' : 'm3-toast-bot'">
85
+ <div v-for="t in toasts" :key="t.id" class="toast-row w-full min-w-64 max-w-xs">
86
+ <div
87
+ class="toast-inner pointer-events-auto relative flex items-center gap-3 overflow-hidden rounded-2xl px-4 py-4 shadow-elevation-2"
88
+ :class="getVariantStyle(t.variant).container"
89
+ >
90
+ <MIcon
91
+ :name="getVariantStyle(t.variant).iconName"
92
+ :size="20"
93
+ class="shrink-0"
94
+ :class="getVariantStyle(t.variant).icon"
95
+ />
96
+
97
+ <p class="flex-1 text-body-medium leading-snug">{{ t.message }}</p>
98
+
99
+ <div class="flex shrink-0 items-center gap-0.5">
100
+ <button
101
+ v-if="t.action"
102
+ type="button"
103
+ class="cursor-pointer rounded px-2 py-1 text-label-medium font-semibold transition-colors"
104
+ :class="getVariantStyle(t.variant).action"
105
+ @click="
106
+ () => {
107
+ t.action!.onClick();
108
+ dismiss(t.id);
109
+ }
110
+ "
111
+ >
112
+ {{ t.action.label }}
113
+ </button>
114
+
115
+ <button
116
+ type="button"
117
+ class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full transition-colors"
118
+ :class="getVariantStyle(t.variant).close"
119
+ aria-label="Cerrar"
120
+ @click="dismiss(t.id)"
121
+ >
122
+ <MIcon name="close" :size="18" />
123
+ </button>
124
+ </div>
125
+
126
+ <!-- Countdown progress bar -->
127
+ <div
128
+ v-if="t.duration > 0"
129
+ class="absolute right-0 bottom-0 left-0 h-0.5 origin-left"
130
+ :class="getVariantStyle(t.variant).progress"
131
+ :style="{ animation: `m3-toast-progress ${t.duration}ms linear forwards` }"
132
+ />
133
+ </div>
134
+ </div>
135
+ </TransitionGroup>
136
+ </div>
137
+ </template>
138
+
139
+ <style scoped>
140
+ /*
141
+ .toast-row is a grid container — animating grid-template-rows: 1fr → 0fr
142
+ collapses height smoothly without position:absolute, so sibling toasts
143
+ shift up gracefully instead of jumping.
144
+ */
145
+ .toast-row {
146
+ display: grid;
147
+ grid-template-rows: 1fr;
148
+ padding-bottom: 8px;
149
+ }
150
+ .toast-row > .toast-inner {
151
+ min-height: 0; /* required for 0fr collapse */
152
+ }
153
+
154
+ /* ─── Bottom toasts ─────────────────────────────────────────────── */
155
+ .m3-toast-bot-enter-active {
156
+ transition:
157
+ grid-template-rows 220ms cubic-bezier(0.2, 0, 0, 1),
158
+ padding-bottom 220ms cubic-bezier(0.2, 0, 0, 1);
159
+ overflow: hidden;
160
+ }
161
+ .m3-toast-bot-enter-active > .toast-inner {
162
+ transition:
163
+ opacity 180ms ease,
164
+ transform 220ms cubic-bezier(0.2, 0, 0, 1);
165
+ }
166
+ .m3-toast-bot-enter-from {
167
+ grid-template-rows: 0fr;
168
+ padding-bottom: 0;
169
+ }
170
+ .m3-toast-bot-enter-from > .toast-inner {
171
+ opacity: 0;
172
+ transform: translateY(20px) scale(0.94);
173
+ }
174
+
175
+ .m3-toast-bot-leave-active {
176
+ transition:
177
+ grid-template-rows 300ms cubic-bezier(0.2, 0, 0, 1),
178
+ padding-bottom 300ms cubic-bezier(0.2, 0, 0, 1);
179
+ overflow: hidden;
180
+ }
181
+ .m3-toast-bot-leave-active > .toast-inner {
182
+ transition:
183
+ opacity 180ms ease,
184
+ transform 180ms ease;
185
+ }
186
+ .m3-toast-bot-leave-to {
187
+ grid-template-rows: 0fr;
188
+ padding-bottom: 0;
189
+ }
190
+ .m3-toast-bot-leave-to > .toast-inner {
191
+ opacity: 0;
192
+ transform: scale(0.92);
193
+ }
194
+
195
+ /* ─── Top toasts ────────────────────────────────────────────────── */
196
+ .m3-toast-top-enter-active {
197
+ transition:
198
+ grid-template-rows 220ms cubic-bezier(0.2, 0, 0, 1),
199
+ padding-bottom 220ms cubic-bezier(0.2, 0, 0, 1);
200
+ overflow: hidden;
201
+ }
202
+ .m3-toast-top-enter-active > .toast-inner {
203
+ transition:
204
+ opacity 180ms ease,
205
+ transform 220ms cubic-bezier(0.2, 0, 0, 1);
206
+ }
207
+ .m3-toast-top-enter-from {
208
+ grid-template-rows: 0fr;
209
+ padding-bottom: 0;
210
+ }
211
+ .m3-toast-top-enter-from > .toast-inner {
212
+ opacity: 0;
213
+ transform: translateY(-20px) scale(0.94);
214
+ }
215
+
216
+ .m3-toast-top-leave-active {
217
+ transition:
218
+ grid-template-rows 300ms cubic-bezier(0.2, 0, 0, 1),
219
+ padding-bottom 300ms cubic-bezier(0.2, 0, 0, 1);
220
+ overflow: hidden;
221
+ }
222
+ .m3-toast-top-leave-active > .toast-inner {
223
+ transition:
224
+ opacity 180ms ease,
225
+ transform 180ms ease;
226
+ }
227
+ .m3-toast-top-leave-to {
228
+ grid-template-rows: 0fr;
229
+ padding-bottom: 0;
230
+ }
231
+ .m3-toast-top-leave-to > .toast-inner {
232
+ opacity: 0;
233
+ transform: scale(0.92);
234
+ }
235
+
236
+ @keyframes m3-toast-progress {
237
+ from {
238
+ transform: scaleX(1);
239
+ }
240
+ to {
241
+ transform: scaleX(0);
242
+ }
243
+ }
244
+ </style>
@@ -0,0 +1,122 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+
4
+ const props = withDefaults(
5
+ defineProps<{
6
+ size?: number;
7
+ wavy?: boolean;
8
+ }>(),
9
+ { size: 20, wavy: false },
10
+ );
11
+
12
+ const STROKE = 3;
13
+ const BUMPS = 9;
14
+
15
+ // amp fraction of r = 0.25 → max radius = r * 1.25
16
+ // Constrain so that max_r + STROKE/2 ≤ size/2 - 1 (1px margin from edge)
17
+ const r = computed(() => (props.size / 2 - 1 - STROKE / 2) / 1.25);
18
+ const cx = computed(() => props.size / 2);
19
+
20
+ // Build the full bumpy-circle path and its total length.
21
+ const wavyData = computed(() => {
22
+ const CX = cx.value;
23
+ const R = r.value;
24
+ const amp = R * 0.08;
25
+ const segs = BUMPS * 24; // smooth curve
26
+
27
+ const pts: string[] = [];
28
+ let len = 0;
29
+ let px = 0,
30
+ py = 0;
31
+
32
+ for (let i = 0; i <= segs; i++) {
33
+ const theta = (2 * Math.PI * i) / segs - Math.PI / 2;
34
+ const rr = R + amp * Math.sin(BUMPS * theta);
35
+ const x = CX + rr * Math.cos(theta);
36
+ const y = CX + rr * Math.sin(theta);
37
+ if (i > 0) len += Math.sqrt((x - px) ** 2 + (y - py) ** 2);
38
+ pts.push(`${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`);
39
+ px = x;
40
+ py = y;
41
+ }
42
+
43
+ // Visible arc ~58% of the circumference, gap fills the rest.
44
+ const visible = len * 0.58;
45
+ const gap = len - visible;
46
+ const dash = `${visible.toFixed(1)} ${gap.toFixed(1)}`;
47
+
48
+ // The wave "travels" by shifting dashoffset over exactly one full length,
49
+ // so the crests slide around the path independently of the rotation.
50
+ return { path: pts.join("") + "Z", dash, len: len.toFixed(1) };
51
+ });
52
+ </script>
53
+
54
+ <template>
55
+ <span
56
+ class="inline-flex shrink-0 items-center justify-center"
57
+ :style="{ width: `${size}px`, height: `${size}px` }"
58
+ role="status"
59
+ aria-label="Cargando"
60
+ >
61
+ <!-- Standard circular spinner -->
62
+ <span
63
+ v-if="!wavy"
64
+ class="block h-full w-full animate-spin rounded-full border-2 border-current border-t-transparent"
65
+ />
66
+
67
+ <!-- Wavy spinner (M3 Expressive): the whole shape rotates AND the wave
68
+ travels along the stroke via dashoffset, giving the snake-like flow. -->
69
+ <svg
70
+ v-else
71
+ :width="size"
72
+ :height="size"
73
+ :viewBox="`0 0 ${size} ${size}`"
74
+ fill="none"
75
+ class="animate-[m3-wavy-spin_2.8s_linear_infinite]"
76
+ :style="`transform-origin: ${cx}px ${cx}px`"
77
+ >
78
+ <path
79
+ :d="wavyData.path"
80
+ stroke="currentColor"
81
+ :stroke-width="STROKE"
82
+ stroke-linecap="round"
83
+ :stroke-dasharray="wavyData.dash"
84
+ class="animate-[m3-wavy-travel_2s_linear_infinite]"
85
+ :style="{ '--m3-wave-len': wavyData.len }"
86
+ />
87
+ </svg>
88
+ </span>
89
+ </template>
90
+
91
+ <style>
92
+ /* The SVG element rotates the whole bumpy circle. */
93
+ @keyframes m3-wavy-spin {
94
+ from {
95
+ transform: rotate(0deg);
96
+ }
97
+ to {
98
+ transform: rotate(360deg);
99
+ }
100
+ }
101
+
102
+ /* The stroke's dashoffset slides by one full path length, so the crests
103
+ appear to crawl along the circle — the "snake" motion of M3 Expressive.
104
+ Negative direction makes the wave travel forward relative to the spin. */
105
+ @keyframes m3-wavy-travel {
106
+ from {
107
+ stroke-dashoffset: 0;
108
+ }
109
+ to {
110
+ stroke-dashoffset: calc(var(--m3-wave-len) * -1px);
111
+ }
112
+ }
113
+
114
+ @media (prefers-reduced-motion: reduce) {
115
+ .animate-\[m3-wavy-spin_2\.8s_linear_infinite\] {
116
+ animation: m3-wavy-spin 2.8s linear infinite;
117
+ }
118
+ .animate-\[m3-wavy-travel_2s_linear_infinite\] {
119
+ animation: none !important;
120
+ }
121
+ }
122
+ </style>
@@ -0,0 +1,97 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onBeforeUnmount } from 'vue'
3
+
4
+ const props = withDefaults(
5
+ defineProps<{
6
+ direction?: 'horizontal' | 'vertical'
7
+ initialSplit?: number
8
+ min?: number
9
+ max?: number
10
+ }>(),
11
+ { direction: 'horizontal', initialSplit: 50, min: 10, max: 90 },
12
+ )
13
+
14
+ const split = ref(props.initialSplit)
15
+ const dragging = ref(false)
16
+ const containerRef = ref<HTMLElement | null>(null)
17
+
18
+ const isHorizontal = computed(() => props.direction === 'horizontal')
19
+
20
+ const panelAStyle = computed(() =>
21
+ isHorizontal.value
22
+ ? { width: `${split.value}%` }
23
+ : { height: `${split.value}%` },
24
+ )
25
+
26
+ const panelBStyle = computed(() =>
27
+ isHorizontal.value
28
+ ? { width: `${100 - split.value}%` }
29
+ : { height: `${100 - split.value}%` },
30
+ )
31
+
32
+ function onPointerDown(e: PointerEvent) {
33
+ dragging.value = true
34
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
35
+ }
36
+
37
+ function onPointerMove(e: PointerEvent) {
38
+ if (!dragging.value || !containerRef.value) return
39
+
40
+ const rect = containerRef.value.getBoundingClientRect()
41
+ let pct: number
42
+
43
+ if (isHorizontal.value) {
44
+ pct = ((e.clientX - rect.left) / rect.width) * 100
45
+ } else {
46
+ pct = ((e.clientY - rect.top) / rect.height) * 100
47
+ }
48
+
49
+ split.value = Math.min(props.max, Math.max(props.min, pct))
50
+ }
51
+
52
+ function onPointerUp() {
53
+ dragging.value = false
54
+ }
55
+
56
+ onBeforeUnmount(() => {
57
+ dragging.value = false
58
+ })
59
+ </script>
60
+
61
+ <template>
62
+ <div
63
+ ref="containerRef"
64
+ class="flex overflow-hidden"
65
+ :class="[
66
+ isHorizontal ? 'flex-row' : 'flex-col',
67
+ dragging && 'select-none',
68
+ ]"
69
+ style="height: 100%"
70
+ >
71
+ <div class="overflow-auto" :style="panelAStyle">
72
+ <slot name="first" />
73
+ </div>
74
+
75
+ <div
76
+ class="z-10 flex shrink-0 items-center justify-center transition-colors"
77
+ :class="[
78
+ isHorizontal
79
+ ? 'w-2 cursor-col-resize flex-col'
80
+ : 'h-2 cursor-row-resize flex-row',
81
+ dragging ? 'bg-primary/20' : 'bg-outline-variant/40 hover:bg-primary/12',
82
+ ]"
83
+ @pointerdown="onPointerDown"
84
+ @pointermove="onPointerMove"
85
+ @pointerup="onPointerUp"
86
+ >
87
+ <div
88
+ class="rounded-full bg-outline"
89
+ :class="isHorizontal ? 'h-6 w-1' : 'h-1 w-6'"
90
+ />
91
+ </div>
92
+
93
+ <div class="overflow-auto" :style="panelBStyle">
94
+ <slot name="second" />
95
+ </div>
96
+ </div>
97
+ </template>
@@ -0,0 +1,244 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
3
+ import MIcon from './MIcon.vue'
4
+ import MSpinner from './MSpinner.vue'
5
+
6
+ export interface SpotlightResult {
7
+ id: string | number
8
+ title: string
9
+ description?: string
10
+ icon?: string
11
+ category?: string
12
+ }
13
+
14
+ const props = withDefaults(
15
+ defineProps<{
16
+ modelValue: boolean
17
+ results?: SpotlightResult[]
18
+ placeholder?: string
19
+ loading?: boolean
20
+ noResultsText?: string
21
+ hotkey?: string
22
+ debounce?: number
23
+ }>(),
24
+ {
25
+ results: () => [],
26
+ placeholder: 'Buscar...',
27
+ loading: false,
28
+ noResultsText: 'No se encontraron resultados',
29
+ hotkey: '/',
30
+ debounce: 0,
31
+ },
32
+ )
33
+
34
+ const emit = defineEmits<{
35
+ 'update:modelValue': [boolean]
36
+ search: [string]
37
+ select: [SpotlightResult]
38
+ }>()
39
+
40
+ const query = ref('')
41
+ const activeIndex = ref(0)
42
+ const inputRef = ref<HTMLInputElement | null>(null)
43
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null
44
+
45
+ const hasQuery = computed(() => query.value.trim().length > 0)
46
+
47
+ const grouped = computed(() => {
48
+ const map = new Map<string, SpotlightResult[]>()
49
+ for (const r of props.results) {
50
+ const cat = r.category ?? ''
51
+ if (!map.has(cat)) map.set(cat, [])
52
+ map.get(cat)!.push(r)
53
+ }
54
+ return map
55
+ })
56
+
57
+ function close() {
58
+ query.value = ''
59
+ activeIndex.value = 0
60
+ emit('update:modelValue', false)
61
+ }
62
+
63
+ function selectResult(result: SpotlightResult) {
64
+ emit('select', result)
65
+ close()
66
+ }
67
+
68
+ function emitSearch() {
69
+ if (debounceTimer) clearTimeout(debounceTimer)
70
+ if (props.debounce > 0) {
71
+ debounceTimer = setTimeout(() => emit('search', query.value), props.debounce)
72
+ } else {
73
+ emit('search', query.value)
74
+ }
75
+ }
76
+
77
+ function onKeydown(e: KeyboardEvent) {
78
+ const len = props.results.length
79
+ if (e.key === 'ArrowDown') {
80
+ e.preventDefault()
81
+ activeIndex.value = len ? (activeIndex.value + 1) % len : 0
82
+ scrollToActive()
83
+ } else if (e.key === 'ArrowUp') {
84
+ e.preventDefault()
85
+ activeIndex.value = len ? (activeIndex.value - 1 + len) % len : 0
86
+ scrollToActive()
87
+ } else if (e.key === 'Enter' && len) {
88
+ e.preventDefault()
89
+ selectResult(props.results[activeIndex.value]!)
90
+ } else if (e.key === 'Escape') {
91
+ close()
92
+ }
93
+ }
94
+
95
+ function scrollToActive() {
96
+ nextTick(() => {
97
+ const el = document.querySelector('[data-spot-active="true"]')
98
+ el?.scrollIntoView({ block: 'nearest' })
99
+ })
100
+ }
101
+
102
+ function onGlobalKeydown(e: KeyboardEvent) {
103
+ const tag = (e.target as HTMLElement).tagName
104
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable) return
105
+ if (e.key === props.hotkey && !e.metaKey && !e.ctrlKey && !e.altKey) {
106
+ e.preventDefault()
107
+ emit('update:modelValue', true)
108
+ }
109
+ }
110
+
111
+ watch(
112
+ () => props.modelValue,
113
+ (open) => {
114
+ if (open) {
115
+ document.body.style.overflow = 'hidden'
116
+ nextTick(() => inputRef.value?.focus())
117
+ } else {
118
+ document.body.style.overflow = ''
119
+ }
120
+ },
121
+ )
122
+
123
+ watch(query, () => {
124
+ activeIndex.value = 0
125
+ emitSearch()
126
+ })
127
+
128
+ onMounted(() => document.addEventListener('keydown', onGlobalKeydown))
129
+ onBeforeUnmount(() => {
130
+ document.removeEventListener('keydown', onGlobalKeydown)
131
+ if (debounceTimer) clearTimeout(debounceTimer)
132
+ })
133
+ </script>
134
+
135
+ <template>
136
+ <Teleport to="body">
137
+ <Transition name="m3-spot">
138
+ <div
139
+ v-if="modelValue"
140
+ class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[12vh]"
141
+ @click.self="close"
142
+ >
143
+ <div class="spot-box flex w-full max-w-xl flex-col overflow-hidden rounded-2xl bg-surface-container-high shadow-elevation-3">
144
+ <!-- Search bar -->
145
+ <div class="flex items-center gap-3 px-5 py-1">
146
+ <MIcon name="search" :size="24" class="shrink-0 text-primary" />
147
+ <input
148
+ ref="inputRef"
149
+ v-model="query"
150
+ type="text"
151
+ :placeholder="placeholder"
152
+ class="h-14 flex-1 bg-transparent text-title-medium text-on-surface outline-none placeholder:text-on-surface-variant/50"
153
+ @keydown="onKeydown"
154
+ />
155
+ <MSpinner v-if="loading" :size="20" class="shrink-0 text-primary" />
156
+ <button
157
+ v-else-if="hasQuery"
158
+ type="button"
159
+ class="flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-on-surface-variant hover:bg-on-surface/8"
160
+ @click="query = ''"
161
+ >
162
+ <MIcon name="close" :size="18" />
163
+ </button>
164
+ </div>
165
+
166
+ <!-- Results -->
167
+ <div v-if="hasQuery" class="max-h-96 overflow-y-auto border-t border-outline-variant">
168
+ <template v-if="results.length">
169
+ <template v-for="[category, items] in grouped" :key="category">
170
+ <p v-if="category" class="px-5 pt-4 pb-1 text-label-small font-medium tracking-wide text-on-surface-variant uppercase">
171
+ {{ category }}
172
+ </p>
173
+ <button
174
+ v-for="item in items"
175
+ :key="item.id"
176
+ type="button"
177
+ :data-spot-active="results.indexOf(item) === activeIndex || undefined"
178
+ class="flex w-full cursor-pointer items-center gap-3 px-5 py-3 text-left transition-colors"
179
+ :class="results.indexOf(item) === activeIndex ? 'bg-primary/12' : 'hover:bg-on-surface/4'"
180
+ @click="selectResult(item)"
181
+ @pointerenter="activeIndex = results.indexOf(item)"
182
+ >
183
+ <div
184
+ v-if="item.icon"
185
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary-container"
186
+ >
187
+ <MIcon :name="item.icon" :size="20" class="text-on-primary-container" />
188
+ </div>
189
+ <div class="min-w-0 flex-1">
190
+ <p class="truncate text-body-medium text-on-surface">{{ item.title }}</p>
191
+ <p v-if="item.description" class="truncate text-body-small text-on-surface-variant">
192
+ {{ item.description }}
193
+ </p>
194
+ </div>
195
+ <MIcon name="arrow_forward" :size="16" class="shrink-0 text-on-surface-variant/40" />
196
+ </button>
197
+ </template>
198
+ </template>
199
+ <div v-else-if="!loading" class="flex flex-col items-center gap-2 py-10">
200
+ <MIcon name="search_off" :size="40" class="text-on-surface-variant/40" />
201
+ <p class="text-body-medium text-on-surface-variant">{{ noResultsText }}</p>
202
+ </div>
203
+ </div>
204
+
205
+ <!-- Hints -->
206
+ <div class="flex items-center gap-4 border-t border-outline-variant px-5 py-2">
207
+ <span class="flex items-center gap-1 text-label-small text-on-surface-variant">
208
+ <kbd class="rounded bg-surface-container px-1 py-0.5">↑↓</kbd> navegar
209
+ </span>
210
+ <span class="flex items-center gap-1 text-label-small text-on-surface-variant">
211
+ <kbd class="rounded bg-surface-container px-1 py-0.5">↵</kbd> abrir
212
+ </span>
213
+ <span class="flex items-center gap-1 text-label-small text-on-surface-variant">
214
+ <kbd class="rounded bg-surface-container px-1 py-0.5">esc</kbd> cerrar
215
+ </span>
216
+ </div>
217
+ </div>
218
+ </div>
219
+ </Transition>
220
+ </Teleport>
221
+ </template>
222
+
223
+ <style scoped>
224
+ .m3-spot-enter-active,
225
+ .m3-spot-leave-active {
226
+ transition: opacity 0.15s ease;
227
+ }
228
+ .m3-spot-enter-from,
229
+ .m3-spot-leave-to {
230
+ opacity: 0;
231
+ }
232
+ .m3-spot-enter-active .spot-box,
233
+ .m3-spot-leave-active .spot-box {
234
+ transition: transform 0.15s ease, opacity 0.15s ease;
235
+ }
236
+ .m3-spot-enter-from .spot-box {
237
+ transform: scale(0.96) translateY(-8px);
238
+ opacity: 0;
239
+ }
240
+ .m3-spot-leave-to .spot-box {
241
+ transform: scale(0.98);
242
+ opacity: 0;
243
+ }
244
+ </style>