@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,179 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, onMounted, onBeforeUnmount, computed } from 'vue'
3
+ import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from '@codemirror/view'
4
+ import { EditorState } from '@codemirror/state'
5
+ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
6
+ import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language'
7
+ import { javascript } from '@codemirror/lang-javascript'
8
+ import { json } from '@codemirror/lang-json'
9
+ import { html } from '@codemirror/lang-html'
10
+ import { css } from '@codemirror/lang-css'
11
+ import { python } from '@codemirror/lang-python'
12
+ import { oneDark } from '@codemirror/theme-one-dark'
13
+
14
+ type Language = 'javascript' | 'typescript' | 'json' | 'html' | 'css' | 'python' | 'plain'
15
+
16
+ const props = withDefaults(
17
+ defineProps<{
18
+ modelValue: string
19
+ language?: Language
20
+ readonly?: boolean
21
+ lineNumbers?: boolean
22
+ theme?: 'light' | 'dark'
23
+ minHeight?: string
24
+ maxHeight?: string
25
+ placeholder?: string
26
+ }>(),
27
+ {
28
+ language: 'javascript',
29
+ readonly: false,
30
+ lineNumbers: true,
31
+ theme: 'light',
32
+ minHeight: '200px',
33
+ maxHeight: '600px',
34
+ },
35
+ )
36
+
37
+ const emit = defineEmits<{ 'update:modelValue': [string] }>()
38
+
39
+ const containerRef = ref<HTMLElement | null>(null)
40
+ let view: EditorView | null = null
41
+
42
+ const langLabel = computed(() => {
43
+ const labels: Record<Language, string> = {
44
+ javascript: 'JavaScript',
45
+ typescript: 'TypeScript',
46
+ json: 'JSON',
47
+ html: 'HTML',
48
+ css: 'CSS',
49
+ python: 'Python',
50
+ plain: 'Texto',
51
+ }
52
+ return labels[props.language]
53
+ })
54
+
55
+ function getLangExtension() {
56
+ switch (props.language) {
57
+ case 'javascript': return javascript()
58
+ case 'typescript': return javascript({ typescript: true })
59
+ case 'json': return json()
60
+ case 'html': return html()
61
+ case 'css': return css()
62
+ case 'python': return python()
63
+ default: return []
64
+ }
65
+ }
66
+
67
+ function buildExtensions() {
68
+ const exts = [
69
+ keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
70
+ history(),
71
+ bracketMatching(),
72
+ indentOnInput(),
73
+ foldGutter(),
74
+ highlightActiveLine(),
75
+ highlightActiveLineGutter(),
76
+ syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
77
+ getLangExtension(),
78
+ EditorView.updateListener.of((update) => {
79
+ if (update.docChanged) emit('update:modelValue', update.state.doc.toString())
80
+ }),
81
+ EditorState.readOnly.of(props.readonly),
82
+ ]
83
+
84
+ if (props.lineNumbers) exts.push(lineNumbers())
85
+ if (props.theme === 'dark') exts.push(oneDark)
86
+
87
+ return exts
88
+ }
89
+
90
+ function createEditor() {
91
+ if (!containerRef.value) return
92
+ view?.destroy()
93
+
94
+ view = new EditorView({
95
+ state: EditorState.create({
96
+ doc: props.modelValue,
97
+ extensions: buildExtensions(),
98
+ }),
99
+ parent: containerRef.value,
100
+ })
101
+ }
102
+
103
+ onMounted(createEditor)
104
+
105
+ watch(() => props.modelValue, (val) => {
106
+ if (view && view.state.doc.toString() !== val) {
107
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: val } })
108
+ }
109
+ })
110
+
111
+ watch([() => props.language, () => props.theme, () => props.readonly, () => props.lineNumbers], createEditor)
112
+
113
+ onBeforeUnmount(() => view?.destroy())
114
+ </script>
115
+
116
+ <template>
117
+ <div class="overflow-hidden rounded-lg border border-outline-variant">
118
+ <!-- Header bar -->
119
+ <div class="flex items-center justify-between border-b border-outline-variant bg-surface-container px-4 py-2">
120
+ <span class="text-label-medium text-on-surface-variant">{{ langLabel }}</span>
121
+ <slot name="actions" />
122
+ </div>
123
+
124
+ <!-- Editor -->
125
+ <div
126
+ ref="containerRef"
127
+ class="code-editor-container overflow-auto bg-surface"
128
+ :style="{ minHeight, maxHeight }"
129
+ />
130
+ </div>
131
+ </template>
132
+
133
+ <style scoped>
134
+ .code-editor-container :deep(.cm-editor) {
135
+ height: 100%;
136
+ min-height: inherit;
137
+ font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
138
+ font-size: 0.875rem;
139
+ }
140
+
141
+ .code-editor-container :deep(.cm-editor.cm-focused) {
142
+ outline: none;
143
+ }
144
+
145
+ .code-editor-container :deep(.cm-scroller) {
146
+ min-height: inherit;
147
+ }
148
+
149
+ .code-editor-container :deep(.cm-gutters) {
150
+ background: var(--color-surface-container);
151
+ border-right: 1px solid var(--color-outline-variant);
152
+ color: var(--color-on-surface-variant);
153
+ }
154
+
155
+ .code-editor-container :deep(.cm-activeLineGutter) {
156
+ background: var(--color-surface-container-high);
157
+ }
158
+
159
+ .code-editor-container :deep(.cm-activeLine) {
160
+ background: var(--color-surface-container-lowest);
161
+ }
162
+
163
+ .code-editor-container :deep(.cm-selectionBackground) {
164
+ background: var(--color-primary-container) !important;
165
+ }
166
+
167
+ .code-editor-container :deep(.cm-cursor) {
168
+ border-left-color: var(--color-primary);
169
+ }
170
+
171
+ .code-editor-container :deep(.cm-matchingBracket) {
172
+ background: var(--color-tertiary-container);
173
+ color: var(--color-on-tertiary-container);
174
+ }
175
+
176
+ .code-editor-container :deep(.cm-foldGutter span) {
177
+ color: var(--color-on-surface-variant);
178
+ }
179
+ </style>
@@ -0,0 +1,305 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
3
+ import MIcon from './MIcon.vue'
4
+ import { useFieldBg } from '../composables/useFieldBg'
5
+
6
+ const props = withDefaults(defineProps<{
7
+ modelValue: string
8
+ label?: string
9
+ presets?: string[]
10
+ disabled?: boolean
11
+ error?: string
12
+ hint?: string
13
+ fieldBg?: string
14
+ }>(), {
15
+ presets: () => [
16
+ '#f44336', '#e91e63', '#9c27b0', '#673ab7', '#3f51b5', '#2196f3',
17
+ '#03a9f4', '#00bcd4', '#009688', '#4caf50', '#8bc34a', '#cddc39',
18
+ '#ffeb3b', '#ffc107', '#ff9800', '#ff5722', '#795548', '#607d8b',
19
+ ],
20
+ })
21
+
22
+ const emit = defineEmits<{ 'update:modelValue': [string] }>()
23
+
24
+ const open = ref(false)
25
+ const triggerEl = ref<HTMLElement | null>(null)
26
+ const panelEl = ref<HTMLElement | null>(null)
27
+ const dropPos = ref({ top: '0px', left: '0px' })
28
+ const { resolvedFieldBg } = useFieldBg(triggerEl, () => props.fieldBg)
29
+
30
+ const satBrightEl = ref<HTMLElement>()
31
+ const draggingSB = ref(false)
32
+
33
+ // HSV state
34
+ const hue = ref(0)
35
+ const sat = ref(100)
36
+ const bright = ref(100)
37
+
38
+ function hexToHsv(hex: string) {
39
+ let c = hex.replace('#', '')
40
+ if (c.length === 3) c = c[0]!+c[0]!+c[1]!+c[1]!+c[2]!+c[2]!
41
+ const r = parseInt(c.substring(0, 2), 16) / 255
42
+ const g = parseInt(c.substring(2, 4), 16) / 255
43
+ const b = parseInt(c.substring(4, 6), 16) / 255
44
+ const max = Math.max(r, g, b), min = Math.min(r, g, b)
45
+ const d = max - min
46
+ let h = 0
47
+ if (d !== 0) {
48
+ if (max === r) h = ((g - b) / d + 6) % 6
49
+ else if (max === g) h = (b - r) / d + 2
50
+ else h = (r - g) / d + 4
51
+ h *= 60
52
+ }
53
+ const s = max === 0 ? 0 : (d / max) * 100
54
+ const v = max * 100
55
+ return { h, s, v }
56
+ }
57
+
58
+ function hsvToHex(h: number, s: number, v: number) {
59
+ s /= 100; v /= 100
60
+ const c = v * s
61
+ const x = c * (1 - Math.abs((h / 60) % 2 - 1))
62
+ const m = v - c
63
+ let r = 0, g = 0, b = 0
64
+ if (h < 60) { r = c; g = x }
65
+ else if (h < 120) { r = x; g = c }
66
+ else if (h < 180) { g = c; b = x }
67
+ else if (h < 240) { g = x; b = c }
68
+ else if (h < 300) { r = x; b = c }
69
+ else { r = c; b = x }
70
+ const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0')
71
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`
72
+ }
73
+
74
+ function syncFromProp() {
75
+ const hsv = hexToHsv(props.modelValue)
76
+ hue.value = hsv.h
77
+ sat.value = hsv.s
78
+ bright.value = hsv.v
79
+ }
80
+ syncFromProp()
81
+
82
+ watch(() => props.modelValue, syncFromProp)
83
+
84
+ function emitColor() {
85
+ emit('update:modelValue', hsvToHex(hue.value, sat.value, bright.value))
86
+ }
87
+
88
+ const currentHex = computed(() => hsvToHex(hue.value, sat.value, bright.value))
89
+ const hueColor = computed(() => `hsl(${hue.value}, 100%, 50%)`)
90
+
91
+ function onSBPointerDown(e: PointerEvent) {
92
+ draggingSB.value = true
93
+ updateSB(e)
94
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
95
+ }
96
+ function onSBPointerMove(e: PointerEvent) {
97
+ if (!draggingSB.value) return
98
+ updateSB(e)
99
+ }
100
+ function onSBPointerUp() {
101
+ draggingSB.value = false
102
+ emitColor()
103
+ }
104
+ function updateSB(e: PointerEvent) {
105
+ if (!satBrightEl.value) return
106
+ const rect = satBrightEl.value.getBoundingClientRect()
107
+ sat.value = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100))
108
+ bright.value = Math.max(0, Math.min(100, (1 - (e.clientY - rect.top) / rect.height) * 100))
109
+ }
110
+
111
+ function onHueInput(e: Event) {
112
+ hue.value = Number((e.target as HTMLInputElement).value)
113
+ emitColor()
114
+ }
115
+
116
+ function selectPreset(color: string) {
117
+ emit('update:modelValue', color)
118
+ }
119
+
120
+ function onHexInput(e: Event) {
121
+ const v = (e.target as HTMLInputElement).value
122
+ if (/^#[0-9a-fA-F]{6}$/.test(v)) {
123
+ emit('update:modelValue', v)
124
+ }
125
+ }
126
+
127
+ function computeDropPos() {
128
+ if (!triggerEl.value) return
129
+ const rect = triggerEl.value.getBoundingClientRect()
130
+ const panelH = 380
131
+ const spaceBelow = window.innerHeight - rect.bottom - 8
132
+ const above = spaceBelow < panelH && rect.top > panelH
133
+ dropPos.value = {
134
+ top: above ? `${rect.top - 4 - panelH}px` : `${rect.bottom + 4}px`,
135
+ left: `${rect.left}px`,
136
+ }
137
+ }
138
+
139
+ function onOut(e: MouseEvent) {
140
+ const t = e.target as Node
141
+ if (triggerEl.value?.contains(t)) return
142
+ if (panelEl.value?.contains(t)) return
143
+ open.value = false
144
+ }
145
+
146
+ function onScroll(e: Event) {
147
+ if (!open.value) return
148
+ if (panelEl.value?.contains(e.target as Node)) return
149
+ if (!triggerEl.value) return
150
+ const rect = triggerEl.value.getBoundingClientRect()
151
+ if (rect.bottom < 0 || rect.top > window.innerHeight) { open.value = false; return }
152
+ computeDropPos()
153
+ }
154
+
155
+ watch(open, (v) => {
156
+ if (v) {
157
+ computeDropPos()
158
+ setTimeout(() => document.addEventListener('mousedown', onOut), 0)
159
+ } else {
160
+ document.removeEventListener('mousedown', onOut)
161
+ }
162
+ })
163
+
164
+ onMounted(() => window.addEventListener('scroll', onScroll, true))
165
+ onUnmounted(() => {
166
+ window.removeEventListener('scroll', onScroll, true)
167
+ document.removeEventListener('mousedown', onOut)
168
+ })
169
+ </script>
170
+
171
+ <template>
172
+ <div class="flex flex-col gap-1">
173
+ <div ref="triggerEl" class="relative mt-2" :style="{ '--field-bg': resolvedFieldBg }">
174
+ <button
175
+ type="button"
176
+ class="flex h-14 w-full items-center gap-3 rounded-sm border bg-transparent px-4 text-left text-body-large transition-[border-color,border-width] duration-150"
177
+ :class="[
178
+ disabled ? 'pointer-events-none opacity-[0.38]' : 'cursor-pointer',
179
+ open
180
+ ? error ? 'border-2 border-error' : 'border-2 border-primary'
181
+ : error ? 'border-error' : 'border-outline hover:border-on-surface',
182
+ ]"
183
+ @click="!disabled && (open = !open)"
184
+ >
185
+ <span
186
+ class="h-6 w-6 shrink-0 rounded-full border border-outline-variant"
187
+ :style="{ backgroundColor: modelValue }"
188
+ />
189
+ <span class="flex-1 font-mono text-on-surface">{{ modelValue }}</span>
190
+ <MIcon name="palette" :size="20" class="shrink-0 text-on-surface-variant" />
191
+ </button>
192
+ <label
193
+ v-if="label"
194
+ class="pointer-events-none absolute -top-2.5 left-3 bg-[var(--field-bg)] px-1 text-label-small transition-colors"
195
+ :class="open ? (error ? 'text-error' : 'text-primary') : error ? 'text-error' : 'text-on-surface-variant'"
196
+ >
197
+ {{ label }}
198
+ </label>
199
+ </div>
200
+
201
+ <p v-if="error" class="px-4 text-body-small text-error">{{ error }}</p>
202
+ <p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
203
+
204
+ <Teleport to="body">
205
+ <Transition
206
+ enter-active-class="transition-[opacity,transform] duration-150"
207
+ enter-from-class="opacity-0 -translate-y-1 scale-[0.98]"
208
+ leave-active-class="transition-[opacity,transform] duration-100"
209
+ leave-to-class="opacity-0 -translate-y-1 scale-[0.98]"
210
+ >
211
+ <div
212
+ v-if="open"
213
+ ref="panelEl"
214
+ class="fixed z-[500] w-[280px] rounded-lg bg-surface-container p-4 shadow-elevation-3"
215
+ :style="dropPos"
216
+ >
217
+ <!-- Saturation / Brightness area -->
218
+ <div
219
+ ref="satBrightEl"
220
+ class="relative mb-3 h-40 w-full cursor-crosshair overflow-hidden rounded-lg"
221
+ :style="{ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, ${hueColor})` }"
222
+ @pointerdown="onSBPointerDown"
223
+ @pointermove="onSBPointerMove"
224
+ @pointerup="onSBPointerUp"
225
+ >
226
+ <div
227
+ class="pointer-events-none absolute h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-elevation-1"
228
+ :style="{
229
+ left: `${sat}%`,
230
+ top: `${100 - bright}%`,
231
+ backgroundColor: currentHex,
232
+ }"
233
+ />
234
+ </div>
235
+
236
+ <!-- Hue slider -->
237
+ <div class="mb-3">
238
+ <input
239
+ type="range"
240
+ min="0"
241
+ max="360"
242
+ :value="hue"
243
+ class="hue-slider h-3 w-full cursor-pointer appearance-none rounded-full outline-none"
244
+ @input="onHueInput"
245
+ />
246
+ </div>
247
+
248
+ <!-- Preview + hex input -->
249
+ <div class="mb-3 flex items-center gap-3">
250
+ <span
251
+ class="h-9 w-9 shrink-0 rounded-full border border-outline-variant"
252
+ :style="{ backgroundColor: currentHex }"
253
+ />
254
+ <input
255
+ type="text"
256
+ :value="modelValue"
257
+ maxlength="7"
258
+ class="flex-1 rounded-sm border border-outline bg-transparent px-3 py-2 font-mono text-body-medium text-on-surface outline-none transition-colors focus:border-primary"
259
+ @input="onHexInput"
260
+ />
261
+ </div>
262
+
263
+ <!-- Presets -->
264
+ <div class="flex flex-wrap gap-1.5">
265
+ <button
266
+ v-for="color in presets"
267
+ :key="color"
268
+ type="button"
269
+ class="flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border transition-transform duration-100 hover:scale-110"
270
+ :class="color === modelValue ? 'border-on-surface' : 'border-transparent'"
271
+ :style="{ backgroundColor: color }"
272
+ @click="selectPreset(color)"
273
+ >
274
+ <MIcon v-if="color === modelValue" name="check" :size="14" class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.5)]" />
275
+ </button>
276
+ </div>
277
+ </div>
278
+ </Transition>
279
+ </Teleport>
280
+ </div>
281
+ </template>
282
+
283
+ <style scoped>
284
+ .hue-slider {
285
+ background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);
286
+ }
287
+ .hue-slider::-webkit-slider-thumb {
288
+ -webkit-appearance: none;
289
+ width: 16px;
290
+ height: 16px;
291
+ border-radius: 50%;
292
+ background: white;
293
+ box-shadow: 0 1px 3px rgba(0,0,0,0.4);
294
+ cursor: pointer;
295
+ }
296
+ .hue-slider::-moz-range-thumb {
297
+ width: 16px;
298
+ height: 16px;
299
+ border-radius: 50%;
300
+ background: white;
301
+ box-shadow: 0 1px 3px rgba(0,0,0,0.4);
302
+ border: none;
303
+ cursor: pointer;
304
+ }
305
+ </style>
@@ -0,0 +1,213 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
3
+ import MIcon from './MIcon.vue'
4
+
5
+ export interface CommandItem {
6
+ id: string
7
+ label: string
8
+ icon?: string
9
+ shortcut?: string
10
+ group?: string
11
+ disabled?: boolean
12
+ onSelect?: () => void
13
+ }
14
+
15
+ const props = withDefaults(
16
+ defineProps<{
17
+ modelValue: boolean
18
+ items: CommandItem[]
19
+ placeholder?: string
20
+ noResultsText?: string
21
+ hotkey?: string
22
+ }>(),
23
+ {
24
+ placeholder: 'Buscar comando...',
25
+ noResultsText: 'Sin resultados',
26
+ hotkey: 'k',
27
+ },
28
+ )
29
+
30
+ const emit = defineEmits<{
31
+ 'update:modelValue': [boolean]
32
+ select: [CommandItem]
33
+ }>()
34
+
35
+ const query = ref('')
36
+ const activeIndex = ref(0)
37
+ const inputRef = ref<HTMLInputElement | null>(null)
38
+
39
+ const filtered = computed(() => {
40
+ if (!query.value) return props.items.filter(i => !i.disabled)
41
+ const q = query.value.toLowerCase()
42
+ return props.items.filter(
43
+ i => !i.disabled && (i.label.toLowerCase().includes(q) || i.group?.toLowerCase().includes(q)),
44
+ )
45
+ })
46
+
47
+ const grouped = computed(() => {
48
+ const map = new Map<string, CommandItem[]>()
49
+ for (const item of filtered.value) {
50
+ const g = item.group ?? ''
51
+ if (!map.has(g)) map.set(g, [])
52
+ map.get(g)!.push(item)
53
+ }
54
+ return map
55
+ })
56
+
57
+ function open() {
58
+ emit('update:modelValue', true)
59
+ }
60
+
61
+ function close() {
62
+ query.value = ''
63
+ activeIndex.value = 0
64
+ emit('update:modelValue', false)
65
+ }
66
+
67
+ function selectItem(item: CommandItem) {
68
+ emit('select', item)
69
+ item.onSelect?.()
70
+ close()
71
+ }
72
+
73
+ function onKeydown(e: KeyboardEvent) {
74
+ if (e.key === 'ArrowDown') {
75
+ e.preventDefault()
76
+ activeIndex.value = (activeIndex.value + 1) % filtered.value.length
77
+ scrollToActive()
78
+ } else if (e.key === 'ArrowUp') {
79
+ e.preventDefault()
80
+ activeIndex.value = (activeIndex.value - 1 + filtered.value.length) % filtered.value.length
81
+ scrollToActive()
82
+ } else if (e.key === 'Enter' && filtered.value.length) {
83
+ e.preventDefault()
84
+ selectItem(filtered.value[activeIndex.value]!)
85
+ } else if (e.key === 'Escape') {
86
+ close()
87
+ }
88
+ }
89
+
90
+ function scrollToActive() {
91
+ nextTick(() => {
92
+ const el = document.querySelector('[data-cmd-active="true"]')
93
+ el?.scrollIntoView({ block: 'nearest' })
94
+ })
95
+ }
96
+
97
+ function onGlobalKeydown(e: KeyboardEvent) {
98
+ if ((e.metaKey || e.ctrlKey) && e.key === props.hotkey) {
99
+ e.preventDefault()
100
+ if (props.modelValue) close()
101
+ else open()
102
+ }
103
+ }
104
+
105
+ watch(
106
+ () => props.modelValue,
107
+ (open) => {
108
+ if (open) {
109
+ document.body.style.overflow = 'hidden'
110
+ nextTick(() => inputRef.value?.focus())
111
+ } else {
112
+ document.body.style.overflow = ''
113
+ }
114
+ },
115
+ )
116
+
117
+ watch(query, () => { activeIndex.value = 0 })
118
+
119
+ onMounted(() => document.addEventListener('keydown', onGlobalKeydown))
120
+ onBeforeUnmount(() => document.removeEventListener('keydown', onGlobalKeydown))
121
+ </script>
122
+
123
+ <template>
124
+ <Teleport to="body">
125
+ <Transition name="m3-cmd">
126
+ <div
127
+ v-if="modelValue"
128
+ class="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-[15vh]"
129
+ @click.self="close"
130
+ >
131
+ <div class="cmd-box flex w-full max-w-lg flex-col overflow-hidden rounded-xl bg-surface-container-high shadow-elevation-3">
132
+ <!-- Search input -->
133
+ <div class="flex items-center gap-3 border-b border-outline-variant px-4">
134
+ <MIcon name="search" :size="20" class="shrink-0 text-on-surface-variant" />
135
+ <input
136
+ ref="inputRef"
137
+ v-model="query"
138
+ type="text"
139
+ :placeholder="placeholder"
140
+ class="h-12 flex-1 bg-transparent text-body-large text-on-surface outline-none placeholder:text-on-surface-variant/50"
141
+ @keydown="onKeydown"
142
+ />
143
+ <kbd class="rounded bg-surface-container px-1.5 py-0.5 text-label-small text-on-surface-variant">
144
+ ESC
145
+ </kbd>
146
+ </div>
147
+
148
+ <!-- Results -->
149
+ <div class="max-h-80 overflow-y-auto py-2">
150
+ <template v-if="filtered.length">
151
+ <template v-for="[group, items] in grouped" :key="group">
152
+ <p v-if="group" class="px-4 pt-3 pb-1 text-label-small font-medium tracking-wide text-on-surface-variant uppercase">
153
+ {{ group }}
154
+ </p>
155
+ <button
156
+ v-for="(item, i) in items"
157
+ :key="item.id"
158
+ type="button"
159
+ :data-cmd-active="filtered.indexOf(item) === activeIndex || undefined"
160
+ class="flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left transition-colors"
161
+ :class="filtered.indexOf(item) === activeIndex ? 'bg-primary/12 text-primary' : 'text-on-surface hover:bg-on-surface/4'"
162
+ @click="selectItem(item)"
163
+ @pointerenter="activeIndex = filtered.indexOf(item)"
164
+ >
165
+ <MIcon v-if="item.icon" :name="item.icon" :size="20" class="shrink-0 opacity-70" />
166
+ <span class="flex-1 truncate text-body-medium">{{ item.label }}</span>
167
+ <kbd v-if="item.shortcut" class="rounded bg-surface-container px-1.5 py-0.5 text-label-small text-on-surface-variant">
168
+ {{ item.shortcut }}
169
+ </kbd>
170
+ </button>
171
+ </template>
172
+ </template>
173
+ <p v-else class="px-4 py-6 text-center text-body-medium text-on-surface-variant">
174
+ {{ noResultsText }}
175
+ </p>
176
+ </div>
177
+
178
+ <!-- Footer -->
179
+ <div class="flex items-center gap-4 border-t border-outline-variant px-4 py-2">
180
+ <span class="flex items-center gap-1 text-label-small text-on-surface-variant">
181
+ <kbd class="rounded bg-surface-container px-1 py-0.5">↑↓</kbd> navegar
182
+ </span>
183
+ <span class="flex items-center gap-1 text-label-small text-on-surface-variant">
184
+ <kbd class="rounded bg-surface-container px-1 py-0.5">↵</kbd> seleccionar
185
+ </span>
186
+ <span class="flex items-center gap-1 text-label-small text-on-surface-variant">
187
+ <kbd class="rounded bg-surface-container px-1 py-0.5">esc</kbd> cerrar
188
+ </span>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ </Transition>
193
+ </Teleport>
194
+ </template>
195
+
196
+ <style scoped>
197
+ .m3-cmd-enter-active,
198
+ .m3-cmd-leave-active {
199
+ transition: opacity 0.15s ease;
200
+ }
201
+ .m3-cmd-enter-from,
202
+ .m3-cmd-leave-to {
203
+ opacity: 0;
204
+ }
205
+ .m3-cmd-enter-active .cmd-box,
206
+ .m3-cmd-leave-active .cmd-box {
207
+ transition: transform 0.15s ease;
208
+ }
209
+ .m3-cmd-enter-from .cmd-box,
210
+ .m3-cmd-leave-to .cmd-box {
211
+ transform: scale(0.95) translateY(-10px);
212
+ }
213
+ </style>