@m3ui-vue/m3ui-vue 0.1.0 → 0.1.2

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 (64) hide show
  1. package/README.md +80 -23
  2. package/dist/MIcon-CaEooCmZ.js +20 -0
  3. package/dist/MIcon-CaEooCmZ.js.map +1 -0
  4. package/dist/_plugin-vue_export-helper-B3ysoDQm.js +8 -0
  5. package/dist/chart.d.ts +1 -0
  6. package/dist/chart.js +141 -0
  7. package/dist/chart.js.map +1 -0
  8. package/dist/code-editor.d.ts +2 -0
  9. package/dist/code-editor.js +379 -0
  10. package/dist/code-editor.js.map +1 -0
  11. package/dist/components/MButton.vue.d.ts +1 -1
  12. package/dist/components/MCalendar.vue.d.ts +1 -1
  13. package/dist/components/MChart.vue.d.ts +2 -3
  14. package/dist/components/MCodeEditor.vue.d.ts +3 -1
  15. package/dist/components/MContainer.vue.d.ts +1 -1
  16. package/dist/components/MDataTable.vue.d.ts +1 -1
  17. package/dist/components/MFab.vue.d.ts +0 -2
  18. package/dist/components/MIconButton.vue.d.ts +1 -1
  19. package/dist/components/MMultiSelect.vue.d.ts +1 -1
  20. package/dist/components/MProgressBar.vue.d.ts +1 -1
  21. package/dist/components/MRichTextEditor.vue.d.ts +1 -1
  22. package/dist/components/MScheduler.vue.d.ts +1 -1
  23. package/dist/components/MSelect.vue.d.ts +1 -1
  24. package/dist/components/MSkeleton.vue.d.ts +1 -1
  25. package/dist/components/MSpotlightSearch.vue.d.ts +1 -1
  26. package/dist/components/MStack.vue.d.ts +2 -2
  27. package/dist/components/MTerminal.vue.d.ts +6 -6
  28. package/dist/components/MTextField.vue.d.ts +1 -1
  29. package/dist/components/MTooltip.vue.d.ts +1 -1
  30. package/dist/dist-Dsrzt6J5.js +1192 -0
  31. package/dist/dist-Dsrzt6J5.js.map +1 -0
  32. package/dist/index.d.ts +0 -6
  33. package/dist/m3ui-vue.css +2 -0
  34. package/dist/m3ui.js +2738 -3367
  35. package/dist/m3ui.js.map +1 -1
  36. package/dist/markdown.d.ts +1 -0
  37. package/dist/markdown.js +41 -0
  38. package/dist/markdown.js.map +1 -0
  39. package/dist/rich-text-editor.d.ts +1 -0
  40. package/dist/rich-text-editor.js +215 -0
  41. package/dist/rich-text-editor.js.map +1 -0
  42. package/dist/styles/theme.css +3 -0
  43. package/dist/styles.css +2 -0
  44. package/dist/terminal.d.ts +1 -0
  45. package/dist/terminal.js +97 -0
  46. package/dist/terminal.js.map +1 -0
  47. package/package.json +28 -2
  48. package/src/chart.ts +1 -0
  49. package/src/code-editor.ts +2 -0
  50. package/src/components/MAlert.vue +1 -1
  51. package/src/components/MChart.vue +54 -47
  52. package/src/components/MCodeEditor.vue +149 -44
  53. package/src/components/MFab.vue +64 -48
  54. package/src/components/MMarkdown.vue +24 -17
  55. package/src/components/MMultiSelect.vue +3 -2
  56. package/src/components/MRichTextEditor.vue +101 -67
  57. package/src/components/MTerminal.vue +10 -8
  58. package/src/components/MTooltip.vue +8 -1
  59. package/src/index.ts +6 -6
  60. package/src/markdown.ts +1 -0
  61. package/src/rich-text-editor.ts +1 -0
  62. package/src/styles/theme.css +3 -0
  63. package/src/terminal.ts +1 -0
  64. package/dist/m3ui.css +0 -2
@@ -1,49 +1,22 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, watch, onMounted } from 'vue'
3
- import {
4
- Chart as ChartJS,
5
- CategoryScale,
6
- LinearScale,
7
- PointElement,
8
- LineElement,
9
- BarElement,
10
- ArcElement,
11
- RadialLinearScale,
12
- Filler,
13
- Tooltip,
14
- Legend,
15
- Title,
16
- type ChartData,
17
- type ChartOptions,
18
- } from 'chart.js'
19
- import { Line, Bar, Pie, Doughnut, Radar } from 'vue-chartjs'
20
-
21
- ChartJS.register(
22
- CategoryScale,
23
- LinearScale,
24
- PointElement,
25
- LineElement,
26
- BarElement,
27
- ArcElement,
28
- RadialLinearScale,
29
- Filler,
30
- Tooltip,
31
- Legend,
32
- Title,
33
- )
2
+ import { computed, ref, watch, onMounted, onBeforeUnmount, shallowRef, type Component } from 'vue'
34
3
 
35
4
  type ChartType = 'line' | 'bar' | 'pie' | 'doughnut' | 'radar'
36
5
 
37
6
  const props = withDefaults(
38
7
  defineProps<{
39
8
  type: ChartType
40
- data: ChartData<any>
41
- options?: ChartOptions<any>
9
+ data: Record<string, any>
10
+ options?: Record<string, any>
42
11
  height?: string
43
12
  }>(),
44
13
  { height: '300px' },
45
14
  )
46
15
 
16
+ const ready = ref(false)
17
+ const chartComponent = shallowRef<Component | null>(null)
18
+ const componentMap = shallowRef<Record<string, Component>>({})
19
+
47
20
  function getM3Colors() {
48
21
  const style = getComputedStyle(document.documentElement)
49
22
  const get = (v: string) => style.getPropertyValue(v).trim()
@@ -59,19 +32,57 @@ function getM3Colors() {
59
32
 
60
33
  const m3Colors = ref(getM3Colors())
61
34
 
62
- onMounted(() => { m3Colors.value = getM3Colors() })
35
+ let themeObserver: MutationObserver | null = null
36
+
37
+ onMounted(async () => {
38
+ m3Colors.value = getM3Colors()
39
+
40
+ themeObserver = new MutationObserver(() => { m3Colors.value = getM3Colors() })
41
+ themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
63
42
 
64
- const themeObserver = ref<MutationObserver | null>(null)
65
- onMounted(() => {
66
- themeObserver.value = new MutationObserver(() => { m3Colors.value = getM3Colors() })
67
- themeObserver.value.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
43
+ const [chartjs, vueChartjs] = await Promise.all([
44
+ import('chart.js'),
45
+ import('vue-chartjs'),
46
+ ])
47
+
48
+ chartjs.Chart.register(
49
+ chartjs.CategoryScale,
50
+ chartjs.LinearScale,
51
+ chartjs.PointElement,
52
+ chartjs.LineElement,
53
+ chartjs.BarElement,
54
+ chartjs.ArcElement,
55
+ chartjs.RadialLinearScale,
56
+ chartjs.Filler,
57
+ chartjs.Tooltip,
58
+ chartjs.Legend,
59
+ chartjs.Title,
60
+ )
61
+
62
+ componentMap.value = {
63
+ line: vueChartjs.Line,
64
+ bar: vueChartjs.Bar,
65
+ pie: vueChartjs.Pie,
66
+ doughnut: vueChartjs.Doughnut,
67
+ radar: vueChartjs.Radar,
68
+ }
69
+
70
+ ready.value = true
68
71
  })
69
72
 
70
- watch(() => m3Colors.value, () => {}, { deep: true })
73
+ onBeforeUnmount(() => { themeObserver?.disconnect() })
74
+
75
+ watch(() => props.type, () => {
76
+ if (ready.value) chartComponent.value = componentMap.value[props.type] ?? null
77
+ }, { immediate: true })
78
+
79
+ watch(ready, () => {
80
+ chartComponent.value = componentMap.value[props.type] ?? null
81
+ })
71
82
 
72
- const mergedOptions = computed<ChartOptions<any>>(() => {
83
+ const mergedOptions = computed<Record<string, any>>(() => {
73
84
  const c = m3Colors.value
74
- const base: ChartOptions<any> = {
85
+ const base: Record<string, any> = {
75
86
  responsive: true,
76
87
  maintainAspectRatio: false,
77
88
  plugins: {
@@ -140,16 +151,12 @@ function deepMerge(target: any, source: any): any {
140
151
  }
141
152
  return output
142
153
  }
143
-
144
- const chartComponent = computed(() => {
145
- const map = { line: Line, bar: Bar, pie: Pie, doughnut: Doughnut, radar: Radar }
146
- return map[props.type]
147
- })
148
154
  </script>
149
155
 
150
156
  <template>
151
157
  <div class="rounded-lg border border-outline-variant bg-surface p-4" :style="{ height }">
152
158
  <component
159
+ v-if="ready && chartComponent"
153
160
  :is="chartComponent"
154
161
  :data="data"
155
162
  :options="mergedOptions"
@@ -1,17 +1,8 @@
1
1
  <script setup lang="ts">
2
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'
3
+ import MIcon from './MIcon.vue'
4
+
5
+ type Language = 'javascript' | 'typescript' | 'json' | 'html' | 'css' | 'python' | 'vue' | 'plain'
15
6
 
16
7
  const props = withDefaults(
17
8
  defineProps<{
@@ -23,6 +14,7 @@ const props = withDefaults(
23
14
  minHeight?: string
24
15
  maxHeight?: string
25
16
  placeholder?: string
17
+ wrap?: boolean
26
18
  }>(),
27
19
  {
28
20
  language: 'javascript',
@@ -31,13 +23,22 @@ const props = withDefaults(
31
23
  theme: 'light',
32
24
  minHeight: '200px',
33
25
  maxHeight: '600px',
26
+ wrap: true,
34
27
  },
35
28
  )
36
29
 
37
30
  const emit = defineEmits<{ 'update:modelValue': [string] }>()
38
31
 
39
32
  const containerRef = ref<HTMLElement | null>(null)
40
- let view: EditorView | null = null
33
+ let view: any = null
34
+ let cmModules: any = null
35
+ const copied = ref(false)
36
+
37
+ async function copyCode() {
38
+ await navigator.clipboard.writeText(props.modelValue)
39
+ copied.value = true
40
+ setTimeout(() => { copied.value = false }, 1500)
41
+ }
41
42
 
42
43
  const langLabel = computed(() => {
43
44
  const labels: Record<Language, string> = {
@@ -47,54 +48,133 @@ const langLabel = computed(() => {
47
48
  html: 'HTML',
48
49
  css: 'CSS',
49
50
  python: 'Python',
51
+ vue: 'Vue',
50
52
  plain: 'Texto',
51
53
  }
52
54
  return labels[props.language]
53
55
  })
54
56
 
55
- function getLangExtension() {
57
+ async function loadModules() {
58
+ if (cmModules) return cmModules
59
+
60
+ const [viewMod, stateMod, commandsMod, languageMod, highlightMod, oneDarkMod, jsMod, jsonMod, htmlMod, cssMod, pyMod, vueMod] = await Promise.all([
61
+ import('@codemirror/view'),
62
+ import('@codemirror/state'),
63
+ import('@codemirror/commands'),
64
+ import('@codemirror/language'),
65
+ import('@lezer/highlight'),
66
+ import('@codemirror/theme-one-dark'),
67
+ import('@codemirror/lang-javascript'),
68
+ import('@codemirror/lang-json'),
69
+ import('@codemirror/lang-html'),
70
+ import('@codemirror/lang-css'),
71
+ import('@codemirror/lang-python'),
72
+ import('@codemirror/lang-vue'),
73
+ ])
74
+
75
+ cmModules = { viewMod, stateMod, commandsMod, languageMod, highlightMod, oneDarkMod, jsMod, jsonMod, htmlMod, cssMod, pyMod, vueMod }
76
+ return cmModules
77
+ }
78
+
79
+ function buildM3HighlightStyle(languageMod: any, tags: any) {
80
+ return languageMod.HighlightStyle.define([
81
+ { tag: tags.keyword, color: 'var(--color-primary)' },
82
+ { tag: tags.controlKeyword, color: 'var(--color-primary)', fontWeight: '500' },
83
+ { tag: tags.operatorKeyword, color: 'var(--color-primary)' },
84
+ { tag: tags.definitionKeyword, color: 'var(--color-primary)' },
85
+ { tag: tags.moduleKeyword, color: 'var(--color-primary)' },
86
+
87
+ { tag: tags.string, color: 'var(--color-tertiary)' },
88
+ { tag: tags.regexp, color: 'var(--color-tertiary)' },
89
+
90
+ { tag: tags.number, color: 'var(--color-error)' },
91
+ { tag: tags.bool, color: 'var(--color-error)' },
92
+
93
+ { tag: tags.function(tags.variableName), color: 'var(--color-secondary)' },
94
+ { tag: tags.function(tags.definition(tags.variableName)), color: 'var(--color-secondary)', fontWeight: '500' },
95
+
96
+ { tag: tags.typeName, color: 'var(--color-primary)', fontStyle: 'italic' },
97
+ { tag: tags.className, color: 'var(--color-primary)', fontStyle: 'italic' },
98
+ { tag: tags.namespace, color: 'var(--color-on-surface-variant)' },
99
+
100
+ { tag: tags.propertyName, color: 'var(--color-on-surface)' },
101
+ { tag: tags.definition(tags.propertyName), color: 'var(--color-on-surface)' },
102
+
103
+ { tag: tags.variableName, color: 'var(--color-on-surface)' },
104
+ { tag: tags.definition(tags.variableName), color: 'var(--color-on-surface)' },
105
+
106
+ { tag: tags.comment, color: 'var(--color-outline)', fontStyle: 'italic' },
107
+ { tag: tags.lineComment, color: 'var(--color-outline)', fontStyle: 'italic' },
108
+ { tag: tags.blockComment, color: 'var(--color-outline)', fontStyle: 'italic' },
109
+
110
+ { tag: tags.meta, color: 'var(--color-on-surface-variant)' },
111
+ { tag: tags.tagName, color: 'var(--color-primary)' },
112
+ { tag: tags.attributeName, color: 'var(--color-tertiary)' },
113
+ { tag: tags.attributeValue, color: 'var(--color-secondary)' },
114
+
115
+ { tag: tags.atom, color: 'var(--color-error)' },
116
+ { tag: tags.null, color: 'var(--color-error)' },
117
+
118
+ { tag: tags.punctuation, color: 'var(--color-on-surface-variant)' },
119
+ { tag: tags.bracket, color: 'var(--color-on-surface-variant)' },
120
+ { tag: tags.operator, color: 'var(--color-on-surface-variant)' },
121
+ { tag: tags.separator, color: 'var(--color-on-surface-variant)' },
122
+ ])
123
+ }
124
+
125
+ function getLangExtension(mods: any) {
56
126
  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()
127
+ case 'javascript': return mods.jsMod.javascript()
128
+ case 'typescript': return mods.jsMod.javascript({ typescript: true })
129
+ case 'json': return mods.jsonMod.json()
130
+ case 'html': return mods.htmlMod.html()
131
+ case 'css': return mods.cssMod.css()
132
+ case 'python': return mods.pyMod.python()
133
+ case 'vue': return mods.vueMod.vue()
63
134
  default: return []
64
135
  }
65
136
  }
66
137
 
67
- function buildExtensions() {
138
+ function buildExtensions(mods: any) {
139
+ const { viewMod, stateMod, commandsMod, languageMod, highlightMod, oneDarkMod } = mods
140
+
141
+ const m3Style = buildM3HighlightStyle(languageMod, highlightMod.tags)
142
+
68
143
  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) => {
144
+ viewMod.keymap.of([...commandsMod.defaultKeymap, ...commandsMod.historyKeymap, commandsMod.indentWithTab]),
145
+ commandsMod.history(),
146
+ languageMod.bracketMatching(),
147
+ languageMod.indentOnInput(),
148
+ languageMod.foldGutter(),
149
+ viewMod.highlightActiveLine(),
150
+ viewMod.highlightActiveLineGutter(),
151
+ languageMod.syntaxHighlighting(m3Style),
152
+ languageMod.syntaxHighlighting(languageMod.defaultHighlightStyle, { fallback: true }),
153
+ getLangExtension(mods),
154
+ viewMod.EditorView.updateListener.of((update: any) => {
79
155
  if (update.docChanged) emit('update:modelValue', update.state.doc.toString())
80
156
  }),
81
- EditorState.readOnly.of(props.readonly),
157
+ stateMod.EditorState.readOnly.of(props.readonly),
82
158
  ]
83
159
 
84
- if (props.lineNumbers) exts.push(lineNumbers())
85
- if (props.theme === 'dark') exts.push(oneDark)
160
+ if (props.wrap) exts.push(viewMod.EditorView.lineWrapping)
161
+ if (props.lineNumbers) exts.push(viewMod.lineNumbers())
162
+ if (props.theme === 'dark') exts.push(oneDarkMod.oneDark)
86
163
 
87
164
  return exts
88
165
  }
89
166
 
90
- function createEditor() {
167
+ async function createEditor() {
91
168
  if (!containerRef.value) return
169
+ const mods = await loadModules()
170
+ const { viewMod, stateMod } = mods
171
+
92
172
  view?.destroy()
93
173
 
94
- view = new EditorView({
95
- state: EditorState.create({
174
+ view = new viewMod.EditorView({
175
+ state: stateMod.EditorState.create({
96
176
  doc: props.modelValue,
97
- extensions: buildExtensions(),
177
+ extensions: buildExtensions(mods),
98
178
  }),
99
179
  parent: containerRef.value,
100
180
  })
@@ -108,7 +188,7 @@ watch(() => props.modelValue, (val) => {
108
188
  }
109
189
  })
110
190
 
111
- watch([() => props.language, () => props.theme, () => props.readonly, () => props.lineNumbers], createEditor)
191
+ watch([() => props.language, () => props.theme, () => props.readonly, () => props.lineNumbers, () => props.wrap], createEditor)
112
192
 
113
193
  onBeforeUnmount(() => view?.destroy())
114
194
  </script>
@@ -118,13 +198,24 @@ onBeforeUnmount(() => view?.destroy())
118
198
  <!-- Header bar -->
119
199
  <div class="flex items-center justify-between border-b border-outline-variant bg-surface-container px-4 py-2">
120
200
  <span class="text-label-medium text-on-surface-variant">{{ langLabel }}</span>
121
- <slot name="actions" />
201
+ <div class="flex items-center gap-2">
202
+ <slot name="actions" />
203
+ <button
204
+ type="button"
205
+ class="flex h-7 cursor-pointer items-center gap-1.5 rounded-md px-2 text-label-medium text-on-surface-variant transition-colors hover:bg-on-surface/8"
206
+ :title="copied ? 'Copied!' : 'Copy code'"
207
+ @click="copyCode"
208
+ >
209
+ <MIcon :name="copied ? 'check' : 'content_copy'" :size="16" :class="copied ? 'text-primary' : ''" />
210
+ <span v-if="copied" class="text-primary">Copied</span>
211
+ </button>
212
+ </div>
122
213
  </div>
123
214
 
124
215
  <!-- Editor -->
125
216
  <div
126
217
  ref="containerRef"
127
- class="code-editor-container overflow-auto bg-surface"
218
+ class="code-editor-container overflow-auto bg-surface text-on-surface"
128
219
  :style="{ minHeight, maxHeight }"
129
220
  />
130
221
  </div>
@@ -134,8 +225,9 @@ onBeforeUnmount(() => view?.destroy())
134
225
  .code-editor-container :deep(.cm-editor) {
135
226
  height: 100%;
136
227
  min-height: inherit;
137
- font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
138
- font-size: 0.875rem;
228
+ font-family: 'Roboto Mono', 'Fira Code', 'Consolas', monospace;
229
+ font-size: 0.8125rem;
230
+ line-height: 1.6;
139
231
  }
140
232
 
141
233
  .code-editor-container :deep(.cm-editor.cm-focused) {
@@ -146,14 +238,25 @@ onBeforeUnmount(() => view?.destroy())
146
238
  min-height: inherit;
147
239
  }
148
240
 
241
+ .code-editor-container :deep(.cm-content) {
242
+ padding: 12px 0;
243
+ }
244
+
245
+ .code-editor-container :deep(.cm-line) {
246
+ padding: 0 16px;
247
+ }
248
+
149
249
  .code-editor-container :deep(.cm-gutters) {
150
250
  background: var(--color-surface-container);
151
251
  border-right: 1px solid var(--color-outline-variant);
152
- color: var(--color-on-surface-variant);
252
+ color: var(--color-outline);
253
+ font-size: 0.75rem;
254
+ padding: 0 4px;
153
255
  }
154
256
 
155
257
  .code-editor-container :deep(.cm-activeLineGutter) {
156
258
  background: var(--color-surface-container-high);
259
+ color: var(--color-on-surface-variant);
157
260
  }
158
261
 
159
262
  .code-editor-container :deep(.cm-activeLine) {
@@ -166,11 +269,13 @@ onBeforeUnmount(() => view?.destroy())
166
269
 
167
270
  .code-editor-container :deep(.cm-cursor) {
168
271
  border-left-color: var(--color-primary);
272
+ border-left-width: 2px;
169
273
  }
170
274
 
171
275
  .code-editor-container :deep(.cm-matchingBracket) {
172
276
  background: var(--color-tertiary-container);
173
277
  color: var(--color-on-tertiary-container);
278
+ border-radius: 2px;
174
279
  }
175
280
 
176
281
  .code-editor-container :deep(.cm-foldGutter span) {
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, onUnmounted, ref } from 'vue'
2
+ import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
3
3
  import MIcon from './MIcon.vue'
4
4
 
5
5
  export interface SpeedDialItem {
@@ -15,9 +15,7 @@ const props = withDefaults(
15
15
  color?: 'primary' | 'secondary' | 'tertiary' | 'surface'
16
16
  size?: 'small' | 'regular' | 'large'
17
17
  disabled?: boolean
18
- /** Speed-dial child items. If provided, clicking the FAB toggles them instead of emitting click. */
19
18
  items?: SpeedDialItem[]
20
- /** Direction the speed-dial items expand toward. */
21
19
  direction?: 'up' | 'down' | 'left' | 'right' | 'radial'
22
20
  }>(),
23
21
  {
@@ -31,7 +29,7 @@ const props = withDefaults(
31
29
  const emit = defineEmits<{ click: [MouseEvent] }>()
32
30
 
33
31
  const open = ref(false)
34
- const containerEl = ref<HTMLElement>()
32
+ const fabEl = ref<HTMLElement>()
35
33
 
36
34
  const hasItems = computed(() => !!props.items?.length)
37
35
 
@@ -60,7 +58,6 @@ const fabIconSize = computed(() => {
60
58
  }
61
59
  })
62
60
 
63
- // FAB height in px — used to position items relative to the container
64
61
  const fabPx = computed(() => {
65
62
  if (props.label) return 56
66
63
  switch (props.size) {
@@ -70,17 +67,24 @@ const fabPx = computed(() => {
70
67
  }
71
68
  })
72
69
 
73
- // Item size (always small-FAB-sized): 40px
74
70
  const ITEM_PX = 40
75
71
  const ITEM_GAP = 8
76
72
 
73
+ function getRect(): DOMRect | null {
74
+ return fabEl.value?.getBoundingClientRect() ?? null
75
+ }
76
+
77
77
  function itemStyle(index: number): Record<string, string> {
78
+ const rect = getRect()
79
+ if (!rect) return { position: 'fixed', opacity: '0', pointerEvents: 'none' }
80
+
81
+ const cx = rect.left + rect.width / 2
82
+ const cy = rect.top + rect.height / 2
78
83
  const count = props.items?.length ?? 0
79
- // Stagger delay: open = forward order, close = reverse order
84
+
80
85
  const delay = open.value
81
86
  ? `${index * 35}ms`
82
87
  : `${(count - 1 - index) * 35}ms`
83
-
84
88
  const transition = `transform 220ms cubic-bezier(0.2,0,0,1) ${delay}, opacity 180ms ease ${delay}`
85
89
 
86
90
  if (props.direction === 'radial') {
@@ -89,27 +93,25 @@ function itemStyle(index: number): Record<string, string> {
89
93
  const dx = (Math.cos(angle) * r).toFixed(1)
90
94
  const dy = (Math.sin(angle) * r).toFixed(1)
91
95
  return {
92
- position: 'absolute',
93
- top: '50%',
94
- left: '50%',
95
- marginTop: `${-ITEM_PX / 2}px`,
96
- marginLeft: `${-ITEM_PX / 2}px`,
96
+ position: 'fixed',
97
+ top: `${cy - ITEM_PX / 2}px`,
98
+ left: `${cx - ITEM_PX / 2}px`,
97
99
  transform: open.value ? `translate(${dx}px, ${dy}px) scale(1)` : 'translate(0,0) scale(0)',
98
100
  opacity: open.value ? '1' : '0',
99
101
  transition,
100
102
  pointerEvents: open.value ? 'auto' : 'none',
103
+ zIndex: '1000',
101
104
  }
102
105
  }
103
106
 
104
- // Linear directions: offset from the container edge
105
107
  const step = ITEM_PX + ITEM_GAP
106
- const base = fabPx.value + ITEM_GAP + index * step
108
+ const offset = fabPx.value / 2 + ITEM_GAP + ITEM_PX / 2 + index * step
107
109
 
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` },
110
+ const posMap: Record<string, { top: string; left: string }> = {
111
+ up: { top: `${cy - offset - ITEM_PX / 2}px`, left: `${cx - ITEM_PX / 2}px` },
112
+ down: { top: `${cy + offset - ITEM_PX / 2}px`, left: `${cx - ITEM_PX / 2}px` },
113
+ left: { top: `${cy - ITEM_PX / 2}px`, left: `${cx - offset - ITEM_PX / 2}px` },
114
+ right: { top: `${cy - ITEM_PX / 2}px`, left: `${cx + offset - ITEM_PX / 2}px` },
113
115
  }
114
116
 
115
117
  const translateFrom: Record<string, string> = {
@@ -119,19 +121,27 @@ function itemStyle(index: number): Record<string, string> {
119
121
  right: 'translateX(-12px) scale(0.75)',
120
122
  }
121
123
 
124
+ const pos = posMap[props.direction] ?? posMap.up
125
+
122
126
  return {
123
- position: 'absolute',
124
- ...offsetMap[props.direction] ?? offsetMap.up,
127
+ position: 'fixed',
128
+ ...pos,
125
129
  transform: open.value ? 'translate(0,0) scale(1)' : (translateFrom[props.direction] ?? 'scale(0.75)'),
126
130
  opacity: open.value ? '1' : '0',
127
131
  transition,
128
132
  pointerEvents: open.value ? 'auto' : 'none',
133
+ zIndex: '1000',
129
134
  }
130
135
  }
131
136
 
132
- // Label only makes sense for up/down; placed to the left of the item button
133
137
  const showLabel = computed(() => props.direction === 'up' || props.direction === 'down')
134
138
 
139
+ // Force re-render to recalculate positions on scroll
140
+ const scrollTick = ref(0)
141
+ function onScroll() {
142
+ if (open.value) scrollTick.value++
143
+ }
144
+
135
145
  function createRipple(event: PointerEvent | MouseEvent, target?: HTMLElement) {
136
146
  const button = (target ?? event.currentTarget) as HTMLElement
137
147
  const rect = button.getBoundingClientRect()
@@ -159,19 +169,44 @@ function handleItemClick(e: PointerEvent, item: SpeedDialItem, buttonEl: HTMLEle
159
169
 
160
170
  function onDocClick(e: MouseEvent) {
161
171
  if (!open.value) return
162
- if (containerEl.value && !containerEl.value.contains(e.target as Node)) {
172
+ if (fabEl.value && !fabEl.value.contains(e.target as Node)) {
163
173
  open.value = false
164
174
  }
165
175
  }
166
176
 
167
- onMounted(() => document.addEventListener('click', onDocClick, true))
168
- onUnmounted(() => document.removeEventListener('click', onDocClick, true))
177
+ onMounted(() => {
178
+ document.addEventListener('click', onDocClick, true)
179
+ window.addEventListener('scroll', onScroll, true)
180
+ })
181
+ onUnmounted(() => {
182
+ document.removeEventListener('click', onDocClick, true)
183
+ window.removeEventListener('scroll', onScroll, true)
184
+ })
169
185
  </script>
170
186
 
171
187
  <template>
172
- <div ref="containerEl" class="relative inline-flex items-center justify-center">
173
- <!-- Speed-dial items (absolutely positioned outside the container) -->
188
+ <div ref="fabEl" class="relative inline-flex items-center justify-center">
189
+ <button
190
+ type="button"
191
+ 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]"
192
+ :class="[colorMap[color], fabSizeClasses]"
193
+ :disabled="disabled"
194
+ @pointerdown="(e) => { createRipple(e); handleFabClick(e) }"
195
+ >
196
+ <MIcon
197
+ :name="icon"
198
+ :size="fabIconSize"
199
+ class="transition-transform duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]"
200
+ :class="hasItems && open ? 'rotate-45' : ''"
201
+ />
202
+ <span v-if="label" class="text-label-large font-medium">{{ label }}</span>
203
+ </button>
204
+ </div>
205
+
206
+ <Teleport to="body">
174
207
  <template v-if="hasItems">
208
+ <!-- hidden dep on scrollTick to force style recalc -->
209
+ <span :data-tick="scrollTick" class="hidden" />
175
210
  <div
176
211
  v-for="(item, i) in items"
177
212
  :key="i"
@@ -179,7 +214,6 @@ onUnmounted(() => document.removeEventListener('click', onDocClick, true))
179
214
  class="flex items-center gap-3"
180
215
  :class="showLabel ? 'flex-row-reverse' : ''"
181
216
  >
182
- <!-- Label pill (up/down only) -->
183
217
  <span
184
218
  v-if="item.label && showLabel"
185
219
  class="whitespace-nowrap rounded-md bg-surface-container-high px-3 py-1.5 text-label-medium text-on-surface shadow-elevation-1"
@@ -187,7 +221,6 @@ onUnmounted(() => document.removeEventListener('click', onDocClick, true))
187
221
  {{ item.label }}
188
222
  </span>
189
223
 
190
- <!-- Mini FAB button -->
191
224
  <button
192
225
  type="button"
193
226
  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]"
@@ -199,22 +232,5 @@ onUnmounted(() => document.removeEventListener('click', onDocClick, true))
199
232
  </button>
200
233
  </div>
201
234
  </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>
235
+ </Teleport>
220
236
  </template>