@m3ui-vue/m3ui-vue 0.3.1 → 0.4.1

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 (83) hide show
  1. package/README.md +219 -59
  2. package/dist/{MMenuItem-C6RwHl-l.js → MMenuItem-FyttjOj_.js} +399 -207
  3. package/dist/MMenuItem-FyttjOj_.js.map +1 -0
  4. package/dist/components/MAutocomplete.vue.d.ts +2 -0
  5. package/dist/components/MBox.vue.d.ts +2 -2
  6. package/dist/components/MButton.vue.d.ts +4 -0
  7. package/dist/components/MButtonGroup.vue.d.ts +28 -0
  8. package/dist/components/MCarousel.vue.d.ts +21 -12
  9. package/dist/components/MColorPickerModal.vue.d.ts +18 -0
  10. package/dist/components/MDataTable.vue.d.ts +1 -1
  11. package/dist/components/MDatePickerModal.vue.d.ts +21 -0
  12. package/dist/components/MDateRangePickerModal.vue.d.ts +24 -0
  13. package/dist/components/MDialog.vue.d.ts +9 -1
  14. package/dist/components/MExpansionPanel.vue.d.ts +3 -2
  15. package/dist/components/MFab.vue.d.ts +14 -2
  16. package/dist/components/MFlex.vue.d.ts +2 -2
  17. package/dist/components/MIconButton.vue.d.ts +4 -2
  18. package/dist/components/MInfiniteScroll.vue.d.ts +1 -1
  19. package/dist/components/MList.vue.d.ts +2 -0
  20. package/dist/components/MLoadingOverlay.vue.d.ts +1 -1
  21. package/dist/components/MMenu.vue.d.ts +3 -1
  22. package/dist/components/MMenuItem.vue.d.ts +14 -3
  23. package/dist/components/MMultiAutocomplete.vue.d.ts +2 -0
  24. package/dist/components/MMultiSelect.vue.d.ts +2 -0
  25. package/dist/components/MNavigationDrawer.vue.d.ts +1 -1
  26. package/dist/components/MNotificationHost.vue.d.ts +3 -0
  27. package/dist/components/MSelect.vue.d.ts +2 -0
  28. package/dist/components/MSlider.vue.d.ts +15 -4
  29. package/dist/components/MSplitButton.vue.d.ts +39 -0
  30. package/dist/components/MSplitter.vue.d.ts +1 -1
  31. package/dist/components/MStack.vue.d.ts +2 -2
  32. package/dist/components/MTable.vue.d.ts +1 -1
  33. package/dist/components/MTimePickerModal.vue.d.ts +21 -0
  34. package/dist/components/MToolbar.vue.d.ts +30 -0
  35. package/dist/components/MTooltip.vue.d.ts +8 -2
  36. package/dist/components/MWindow.vue.d.ts +2 -2
  37. package/dist/composables/useDevice.d.ts +3 -0
  38. package/dist/composables/useNotification.d.ts +64 -0
  39. package/dist/composables/useToast.d.ts +8 -0
  40. package/dist/index.d.ts +12 -0
  41. package/dist/m3ui-vue.css +1 -1
  42. package/dist/m3ui.js +5222 -2994
  43. package/dist/m3ui.js.map +1 -1
  44. package/dist/rich-text-editor.js +10 -10
  45. package/dist/styles.css +1 -1
  46. package/package.json +1 -1
  47. package/src/components/MAutocomplete.vue +109 -13
  48. package/src/components/MButton.vue +38 -16
  49. package/src/components/MButtonGroup.vue +158 -0
  50. package/src/components/MCarousel.vue +223 -101
  51. package/src/components/MColorPicker.vue +1 -4
  52. package/src/components/MColorPickerModal.vue +276 -0
  53. package/src/components/MDatePicker.vue +1 -4
  54. package/src/components/MDatePickerModal.vue +265 -0
  55. package/src/components/MDateRangePicker.vue +1 -4
  56. package/src/components/MDateRangePickerModal.vue +264 -0
  57. package/src/components/MDialog.vue +38 -1
  58. package/src/components/MExpansionPanel.vue +10 -32
  59. package/src/components/MFab.vue +92 -11
  60. package/src/components/MIconButton.vue +36 -6
  61. package/src/components/MList.vue +6 -3
  62. package/src/components/MListItem.vue +9 -3
  63. package/src/components/MMenu.vue +10 -5
  64. package/src/components/MMenuItem.vue +93 -10
  65. package/src/components/MMultiAutocomplete.vue +105 -10
  66. package/src/components/MMultiSelect.vue +106 -13
  67. package/src/components/MNotificationHost.vue +163 -0
  68. package/src/components/MSelect.vue +99 -16
  69. package/src/components/MSlider.vue +367 -134
  70. package/src/components/MSnackbar.vue +3 -0
  71. package/src/components/MSplitButton.vue +240 -0
  72. package/src/components/MTabs.vue +82 -39
  73. package/src/components/MTagInput.vue +11 -1
  74. package/src/components/MTimePicker.vue +1 -4
  75. package/src/components/MTimePickerModal.vue +458 -0
  76. package/src/components/MToolbar.vue +75 -0
  77. package/src/components/MTooltip.vue +60 -10
  78. package/src/components/_MContextMenuPanel.vue +2 -2
  79. package/src/composables/useDevice.ts +29 -0
  80. package/src/composables/useNotification.ts +93 -0
  81. package/src/composables/useToast.ts +42 -5
  82. package/src/index.ts +12 -0
  83. package/dist/MMenuItem-C6RwHl-l.js.map +0 -1
@@ -0,0 +1,264 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import MButton from './MButton.vue'
4
+ import MIconButton from './MIconButton.vue'
5
+ import { useLocale } from '../composables/useLocale'
6
+
7
+ export interface DateRange {
8
+ start: string | null
9
+ end: string | null
10
+ }
11
+
12
+ const props = withDefaults(defineProps<{
13
+ modelValue: DateRange
14
+ show?: boolean
15
+ min?: string
16
+ max?: string
17
+ locale?: string
18
+ title?: string
19
+ }>(), {
20
+ show: false,
21
+ locale: 'es-ES',
22
+ })
23
+
24
+ const locale = useLocale()
25
+ const emit = defineEmits<{
26
+ 'update:modelValue': [DateRange]
27
+ 'update:show': [boolean]
28
+ }>()
29
+
30
+ const picking = ref<'start' | 'end'>('start')
31
+ const start = ref<string | null>(null)
32
+ const end = ref<string | null>(null)
33
+ const hovered = ref<string | null>(null)
34
+ const viewDate = ref(new Date())
35
+
36
+ watch(() => props.show, (open) => {
37
+ if (open) {
38
+ start.value = props.modelValue?.start ?? null
39
+ end.value = props.modelValue?.end ?? null
40
+ picking.value = start.value && !end.value ? 'end' : 'start'
41
+ viewDate.value = props.modelValue?.start
42
+ ? new Date(props.modelValue.start + 'T00:00:00')
43
+ : new Date()
44
+ document.addEventListener('keydown', onKeydown)
45
+ document.body.style.overflow = 'hidden'
46
+ } else {
47
+ document.removeEventListener('keydown', onKeydown)
48
+ document.body.style.overflow = ''
49
+ }
50
+ })
51
+
52
+ // ── Calendar logic ─────────────────────────────────────────────────
53
+ const WEEKDAYS = computed(() => {
54
+ const f = new Intl.DateTimeFormat(props.locale, { weekday: 'narrow' })
55
+ return Array.from({ length: 7 }, (_, i) => f.format(new Date(2024, 0, i + 1)))
56
+ })
57
+
58
+ const monthLabel = computed(() =>
59
+ new Intl.DateTimeFormat(props.locale, { month: 'long', year: 'numeric' }).format(viewDate.value),
60
+ )
61
+
62
+ const calendarDays = computed(() => {
63
+ const y = viewDate.value.getFullYear()
64
+ const m = viewDate.value.getMonth()
65
+ const first = new Date(y, m, 1)
66
+ const startDay = (first.getDay() + 6) % 7
67
+ const daysInMonth = new Date(y, m + 1, 0).getDate()
68
+ const days: { date: number; current: boolean; iso: string; disabled: boolean }[] = []
69
+
70
+ const prevDays = new Date(y, m, 0).getDate()
71
+ for (let i = startDay - 1; i >= 0; i--) {
72
+ const d = prevDays - i
73
+ const iso = fmt(y, m - 1, d)
74
+ days.push({ date: d, current: false, iso, disabled: isOOR(iso) })
75
+ }
76
+ for (let d = 1; d <= daysInMonth; d++) {
77
+ const iso = fmt(y, m, d)
78
+ days.push({ date: d, current: true, iso, disabled: isOOR(iso) })
79
+ }
80
+ const remaining = 42 - days.length
81
+ for (let d = 1; d <= remaining; d++) {
82
+ const iso = fmt(y, m + 1, d)
83
+ days.push({ date: d, current: false, iso, disabled: isOOR(iso) })
84
+ }
85
+ return days
86
+ })
87
+
88
+ function fmt(y: number, m: number, d: number) {
89
+ const dt = new Date(y, m, d)
90
+ return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`
91
+ }
92
+
93
+ function isOOR(iso: string) {
94
+ if (props.min && iso < props.min) return true
95
+ if (props.max && iso > props.max) return true
96
+ return false
97
+ }
98
+
99
+ const todayIso = fmt(new Date().getFullYear(), new Date().getMonth(), new Date().getDate())
100
+
101
+ function prevMonth() {
102
+ const d = new Date(viewDate.value)
103
+ d.setMonth(d.getMonth() - 1)
104
+ viewDate.value = d
105
+ }
106
+ function nextMonth() {
107
+ const d = new Date(viewDate.value)
108
+ d.setMonth(d.getMonth() + 1)
109
+ viewDate.value = d
110
+ }
111
+
112
+ function selectDay(day: typeof calendarDays.value[0]) {
113
+ if (day.disabled) return
114
+ if (picking.value === 'start') {
115
+ start.value = day.iso
116
+ end.value = null
117
+ picking.value = 'end'
118
+ } else {
119
+ if (day.iso < start.value!) {
120
+ start.value = day.iso
121
+ end.value = null
122
+ } else {
123
+ end.value = day.iso
124
+ picking.value = 'start'
125
+ }
126
+ }
127
+ }
128
+
129
+ function isInRange(iso: string) {
130
+ const effectiveEnd = end.value ?? hovered.value
131
+ if (!start.value || !effectiveEnd) return false
132
+ const lo = start.value < effectiveEnd ? start.value : effectiveEnd
133
+ const hi = start.value < effectiveEnd ? effectiveEnd : start.value
134
+ return iso > lo && iso < hi
135
+ }
136
+
137
+ // ── Display ────────────────────────────────────────────────────────
138
+ const fmtDisplay = (iso: string | null) => {
139
+ if (!iso) return '—'
140
+ const d = new Date(iso + 'T00:00:00')
141
+ return new Intl.DateTimeFormat(props.locale, { month: 'short', day: 'numeric' }).format(d)
142
+ }
143
+
144
+ // ── Actions ────────────────────────────────────────────────────────
145
+ function cancel() { emit('update:show', false) }
146
+
147
+ function confirm() {
148
+ if (start.value && end.value) {
149
+ emit('update:modelValue', { start: start.value, end: end.value })
150
+ emit('update:show', false)
151
+ }
152
+ }
153
+
154
+ function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape') cancel() }
155
+ </script>
156
+
157
+ <template>
158
+ <Teleport to="body">
159
+ <Transition name="m3-drp-modal">
160
+ <div
161
+ v-if="show"
162
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
163
+ @click.self="cancel"
164
+ >
165
+ <div class="drp-box w-[360px] rounded-[28px] bg-surface-container-high shadow-elevation-3">
166
+ <!-- Header -->
167
+ <div class="px-6 pt-6 pb-4">
168
+ <p class="mb-3 text-label-medium text-on-surface-variant">
169
+ {{ title || locale.selectRange }}
170
+ </p>
171
+ <div class="flex items-center gap-3">
172
+ <div
173
+ class="flex-1 rounded-lg px-3 py-2 text-center transition-colors"
174
+ :class="picking === 'start' ? 'bg-primary-container text-on-primary-container' : 'bg-surface-container-highest text-on-surface'"
175
+ @click="picking = 'start'"
176
+ >
177
+ <p class="text-label-small text-on-surface-variant">{{ locale.pickStart }}</p>
178
+ <p class="text-title-medium font-medium capitalize">{{ fmtDisplay(start) }}</p>
179
+ </div>
180
+ <span class="text-on-surface-variant">→</span>
181
+ <div
182
+ class="flex-1 rounded-lg px-3 py-2 text-center transition-colors"
183
+ :class="picking === 'end' ? 'bg-primary-container text-on-primary-container' : 'bg-surface-container-highest text-on-surface'"
184
+ @click="start && (picking = 'end')"
185
+ >
186
+ <p class="text-label-small text-on-surface-variant">{{ locale.pickEnd }}</p>
187
+ <p class="text-title-medium font-medium capitalize">{{ fmtDisplay(end) }}</p>
188
+ </div>
189
+ </div>
190
+ </div>
191
+
192
+ <div class="h-px bg-outline-variant" />
193
+
194
+ <!-- Calendar -->
195
+ <div class="px-4 pt-3 pb-2">
196
+ <div class="mb-2 flex items-center justify-between">
197
+ <MIconButton icon="chevron_left" :label="locale.previousMonth" :size="36" @click="prevMonth" />
198
+ <span class="text-title-small font-medium capitalize text-on-surface">{{ monthLabel }}</span>
199
+ <MIconButton icon="chevron_right" :label="locale.nextMonth" :size="36" @click="nextMonth" />
200
+ </div>
201
+
202
+ <div class="mb-1 grid grid-cols-7 gap-0.5 text-center">
203
+ <span v-for="wd in WEEKDAYS" :key="wd" class="py-1 text-label-small font-medium text-on-surface-variant">
204
+ {{ wd }}
205
+ </span>
206
+ </div>
207
+
208
+ <div class="grid grid-cols-7 gap-0.5">
209
+ <button
210
+ v-for="(day, i) in calendarDays"
211
+ :key="i"
212
+ type="button"
213
+ class="flex h-10 w-full items-center justify-center text-body-medium transition-colors duration-100"
214
+ :class="[
215
+ day.disabled
216
+ ? 'cursor-not-allowed text-on-surface/25 rounded-full'
217
+ : day.iso === start || day.iso === end
218
+ ? 'bg-primary text-on-primary rounded-full'
219
+ : isInRange(day.iso)
220
+ ? 'bg-primary/12 text-on-surface cursor-pointer'
221
+ : day.iso === todayIso
222
+ ? 'border border-primary text-primary rounded-full cursor-pointer hover:bg-primary/8'
223
+ : day.current
224
+ ? 'cursor-pointer text-on-surface rounded-full hover:bg-on-surface/8'
225
+ : 'cursor-pointer text-on-surface-variant/50 rounded-full hover:bg-on-surface/4',
226
+ ]"
227
+ :disabled="day.disabled"
228
+ @mouseenter="picking === 'end' && (hovered = day.iso)"
229
+ @click="selectDay(day)"
230
+ >
231
+ {{ day.date }}
232
+ </button>
233
+ </div>
234
+ </div>
235
+
236
+ <!-- Footer -->
237
+ <div class="flex justify-end gap-2 px-6 py-4">
238
+ <MButton variant="text" @click="cancel">{{ locale.cancel }}</MButton>
239
+ <MButton variant="text" :disabled="!start || !end" @click="confirm">OK</MButton>
240
+ </div>
241
+ </div>
242
+ </div>
243
+ </Transition>
244
+ </Teleport>
245
+ </template>
246
+
247
+ <style scoped>
248
+ .m3-drp-modal-enter-active,
249
+ .m3-drp-modal-leave-active {
250
+ transition: opacity 0.15s ease;
251
+ }
252
+ .m3-drp-modal-enter-from,
253
+ .m3-drp-modal-leave-to {
254
+ opacity: 0;
255
+ }
256
+ .m3-drp-modal-enter-active .drp-box,
257
+ .m3-drp-modal-leave-active .drp-box {
258
+ transition: transform 0.15s ease;
259
+ }
260
+ .m3-drp-modal-enter-from .drp-box,
261
+ .m3-drp-modal-leave-to .drp-box {
262
+ transform: scale(0.95);
263
+ }
264
+ </style>
@@ -9,11 +9,13 @@ const props = withDefaults(
9
9
  title?: string
10
10
  maxWidth?: string
11
11
  persistent?: boolean
12
+ fullscreen?: boolean
12
13
  closeLabel?: string
13
14
  }>(),
14
15
  {
15
16
  maxWidth: 'max-w-md',
16
17
  persistent: false,
18
+ fullscreen: false,
17
19
  },
18
20
  )
19
21
 
@@ -46,7 +48,8 @@ watch(
46
48
 
47
49
  <template>
48
50
  <Teleport to="body">
49
- <Transition name="m3-dialog">
51
+ <!-- Basic dialog -->
52
+ <Transition v-if="!fullscreen" name="m3-dialog">
50
53
  <div
51
54
  v-if="modelValue"
52
55
  class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
@@ -71,6 +74,27 @@ watch(
71
74
  </div>
72
75
  </div>
73
76
  </Transition>
77
+
78
+ <!-- Fullscreen dialog -->
79
+ <Transition v-else name="m3-dialog-fs">
80
+ <div
81
+ v-if="modelValue"
82
+ class="dialog-fs fixed inset-0 z-50 flex flex-col bg-surface"
83
+ >
84
+ <div class="flex h-14 shrink-0 items-center gap-2 px-2">
85
+ <MIconButton v-if="!persistent" icon="close" :label="closeLabel ?? locale.close" @click="close" />
86
+ <h2 class="flex-1 text-title-large font-medium text-on-surface">
87
+ <slot name="title">{{ title }}</slot>
88
+ </h2>
89
+ <div v-if="$slots.actions" class="flex items-center gap-2">
90
+ <slot name="actions" />
91
+ </div>
92
+ </div>
93
+ <div class="flex-1 overflow-y-auto px-6 py-4 text-body-medium text-on-surface-variant">
94
+ <slot />
95
+ </div>
96
+ </div>
97
+ </Transition>
74
98
  </Teleport>
75
99
  </template>
76
100
 
@@ -91,4 +115,17 @@ watch(
91
115
  .m3-dialog-leave-to .dialog-box {
92
116
  transform: scale(0.95);
93
117
  }
118
+
119
+ .m3-dialog-fs-enter-active,
120
+ .m3-dialog-fs-leave-active {
121
+ transition: transform 0.25s cubic-bezier(0.2, 0, 0, 1), opacity 0.15s ease;
122
+ }
123
+ .m3-dialog-fs-enter-from {
124
+ transform: translateY(100%);
125
+ opacity: 0;
126
+ }
127
+ .m3-dialog-fs-leave-to {
128
+ transform: translateY(100%);
129
+ opacity: 0;
130
+ }
94
131
  </style>
@@ -9,7 +9,7 @@ const props = withDefaults(defineProps<{
9
9
  modelValue?: boolean
10
10
  disabled?: boolean
11
11
  variant?: 'outlined' | 'filled' | 'elevated'
12
- }>(), { disabled: false, variant: 'outlined' })
12
+ }>(), { modelValue: undefined, disabled: false, variant: 'outlined' })
13
13
 
14
14
  const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
15
15
 
@@ -60,13 +60,13 @@ const wrapperClass = computed(() => {
60
60
  </button>
61
61
 
62
62
  <!-- Content with height animation -->
63
- <Transition name="expand">
64
- <div v-if="isOpen" class="expand-grid">
65
- <div class="expand-body border-t border-outline-variant/60 px-5 py-4">
63
+ <div class="expand-grid" :class="isOpen ? 'expand-open' : ''">
64
+ <div class="expand-body">
65
+ <div class="border-t border-outline-variant/60 px-5 py-4">
66
66
  <slot />
67
67
  </div>
68
68
  </div>
69
- </Transition>
69
+ </div>
70
70
  </div>
71
71
  </template>
72
72
 
@@ -77,36 +77,14 @@ const wrapperClass = computed(() => {
77
77
  */
78
78
  .expand-grid {
79
79
  display: grid;
80
+ grid-template-rows: 0fr;
81
+ transition: grid-template-rows 300ms ease-in-out;
82
+ }
83
+ .expand-grid.expand-open {
80
84
  grid-template-rows: 1fr;
81
85
  }
82
86
  .expand-body {
83
- min-height: 0; /* required for 0fr to actually collapse */
87
+ min-height: 0;
84
88
  overflow: hidden;
85
89
  }
86
-
87
- .expand-enter-active {
88
- transition: grid-template-rows 280ms cubic-bezier(0.2, 0, 0, 1);
89
- }
90
- .expand-enter-active > .expand-body {
91
- transition: opacity 220ms ease;
92
- }
93
- .expand-enter-from {
94
- grid-template-rows: 0fr;
95
- }
96
- .expand-enter-from > .expand-body {
97
- opacity: 0;
98
- }
99
-
100
- .expand-leave-active {
101
- transition: grid-template-rows 220ms cubic-bezier(0.4, 0, 1, 1);
102
- }
103
- .expand-leave-active > .expand-body {
104
- transition: opacity 150ms ease;
105
- }
106
- .expand-leave-to {
107
- grid-template-rows: 0fr;
108
- }
109
- .expand-leave-to > .expand-body {
110
- opacity: 0;
111
- }
112
90
  </style>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
2
+ import { computed, onMounted, onUnmounted, ref, useSlots } from 'vue'
3
3
  import MIcon from './MIcon.vue'
4
4
 
5
5
  export interface SpeedDialItem {
@@ -34,10 +34,14 @@ const fabTag = computed(() => props.to ? 'RouterLink' : 'button')
34
34
 
35
35
  const emit = defineEmits<{ click: [MouseEvent] }>()
36
36
 
37
+ const slots = useSlots()
37
38
  const open = ref(false)
38
39
  const fabEl = ref<HTMLElement>()
40
+ const contentEl = ref<HTMLElement>()
39
41
 
40
42
  const hasItems = computed(() => !!props.items?.length)
43
+ const hasContent = computed(() => !!slots.content)
44
+ const isExpandable = computed(() => hasItems.value || hasContent.value)
41
45
 
42
46
  const colorMap: Record<string, string> = {
43
47
  primary: 'bg-primary-container text-on-primary-container',
@@ -142,10 +146,9 @@ function itemStyle(index: number): Record<string, string> {
142
146
 
143
147
  const showLabel = computed(() => props.direction === 'up' || props.direction === 'down')
144
148
 
145
- // Force re-render to recalculate positions on scroll
146
- const scrollTick = ref(0)
147
149
  function onScroll() {
148
- if (open.value) scrollTick.value++
150
+ if (!open.value) return
151
+ open.value = false
149
152
  }
150
153
 
151
154
  function createRipple(event: PointerEvent | MouseEvent, target?: HTMLElement) {
@@ -159,8 +162,49 @@ function createRipple(event: PointerEvent | MouseEvent, target?: HTMLElement) {
159
162
  el.addEventListener('animationend', () => el.remove(), { once: true })
160
163
  }
161
164
 
165
+ const contentStyle = ref<Record<string, string>>({})
166
+
167
+ function computeContentPos() {
168
+ const rect = fabEl.value?.getBoundingClientRect()
169
+ if (!rect) return
170
+ const gap = 8
171
+ const base: Record<string, string> = { position: 'fixed', zIndex: '1000' }
172
+ switch (props.direction) {
173
+ case 'up':
174
+ base.bottom = `${window.innerHeight - rect.top + gap}px`
175
+ base.left = `${rect.left}px`
176
+ base.minWidth = `${rect.width}px`
177
+ break
178
+ case 'down':
179
+ base.top = `${rect.bottom + gap}px`
180
+ base.left = `${rect.left}px`
181
+ base.minWidth = `${rect.width}px`
182
+ break
183
+ case 'left':
184
+ base.top = `${rect.top}px`
185
+ base.right = `${window.innerWidth - rect.left + gap}px`
186
+ break
187
+ case 'right':
188
+ base.top = `${rect.top}px`
189
+ base.left = `${rect.right + gap}px`
190
+ break
191
+ }
192
+ contentStyle.value = base
193
+ }
194
+
195
+ const contentTransformOrigin = computed(() => {
196
+ switch (props.direction) {
197
+ case 'up': return 'bottom center'
198
+ case 'down': return 'top center'
199
+ case 'left': return 'center right'
200
+ case 'right': return 'center left'
201
+ default: return 'bottom center'
202
+ }
203
+ })
204
+
162
205
  function handleFabClick(e: PointerEvent) {
163
- if (hasItems.value) {
206
+ if (isExpandable.value) {
207
+ if (!open.value) computeContentPos()
164
208
  open.value = !open.value
165
209
  } else {
166
210
  emit('click', e)
@@ -175,18 +219,28 @@ function handleItemClick(e: PointerEvent, item: SpeedDialItem, buttonEl: HTMLEle
175
219
 
176
220
  function onDocClick(e: MouseEvent) {
177
221
  if (!open.value || props.persistent) return
178
- if (fabEl.value && !fabEl.value.contains(e.target as Node)) {
179
- open.value = false
180
- }
222
+ const t = e.target as Node
223
+ if (fabEl.value?.contains(t)) return
224
+ if (contentEl.value?.contains(t)) return
225
+ open.value = false
181
226
  }
182
227
 
228
+ let observer: IntersectionObserver | null = null
229
+
183
230
  onMounted(() => {
184
231
  document.addEventListener('click', onDocClick, true)
185
232
  window.addEventListener('scroll', onScroll, true)
233
+ if (fabEl.value) {
234
+ observer = new IntersectionObserver(([entry]) => {
235
+ if (entry && !entry.isIntersecting && open.value) open.value = false
236
+ }, { threshold: 0 })
237
+ observer.observe(fabEl.value)
238
+ }
186
239
  })
187
240
  onUnmounted(() => {
188
241
  document.removeEventListener('click', onDocClick, true)
189
242
  window.removeEventListener('scroll', onScroll, true)
243
+ observer?.disconnect()
190
244
  })
191
245
  </script>
192
246
 
@@ -206,7 +260,7 @@ onUnmounted(() => {
206
260
  :name="icon"
207
261
  :size="fabIconSize"
208
262
  class="transition-transform duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]"
209
- :class="hasItems && open ? 'rotate-45' : ''"
263
+ :class="isExpandable && open ? 'rotate-45' : ''"
210
264
  />
211
265
  <span v-if="label" class="text-label-large font-medium">{{ label }}</span>
212
266
  </component>
@@ -214,8 +268,6 @@ onUnmounted(() => {
214
268
 
215
269
  <Teleport to="body">
216
270
  <template v-if="hasItems">
217
- <!-- hidden dep on scrollTick to force style recalc -->
218
- <span :data-tick="scrollTick" class="hidden" />
219
271
  <div
220
272
  v-for="(item, i) in items"
221
273
  :key="i"
@@ -243,5 +295,34 @@ onUnmounted(() => {
243
295
  </component>
244
296
  </div>
245
297
  </template>
298
+
299
+ <!-- Custom content panel -->
300
+ <Transition name="m3-fab-content">
301
+ <div
302
+ v-if="hasContent && open"
303
+ ref="contentEl"
304
+ class=""
305
+ :style="{ ...contentStyle, transformOrigin: contentTransformOrigin }"
306
+ >
307
+ <slot name="content" :close="() => open = false" />
308
+ </div>
309
+ </Transition>
246
310
  </Teleport>
247
311
  </template>
312
+
313
+ <style scoped>
314
+ .m3-fab-content-enter-active {
315
+ transition: opacity 200ms ease, transform 200ms cubic-bezier(0.2, 0, 0, 1);
316
+ }
317
+ .m3-fab-content-leave-active {
318
+ transition: opacity 150ms ease, transform 150ms ease;
319
+ }
320
+ .m3-fab-content-enter-from {
321
+ opacity: 0;
322
+ transform: scale(0.85);
323
+ }
324
+ .m3-fab-content-leave-to {
325
+ opacity: 0;
326
+ transform: scale(0.9);
327
+ }
328
+ </style>
@@ -7,23 +7,52 @@ const props = withDefaults(
7
7
  icon: string
8
8
  label?: string
9
9
  variant?: 'standard' | 'filled' | 'tonal' | 'outlined'
10
+ shape?: 'rounded' | 'squared'
11
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | number
10
12
  disabled?: boolean
11
- size?: number
12
13
  to?: string | Record<string, any>
13
14
  }>(),
14
15
  {
15
16
  variant: 'standard',
17
+ shape: 'rounded',
18
+ size: 'sm',
16
19
  disabled: false,
17
- size: 40,
18
20
  },
19
21
  )
20
22
 
21
23
  const tag = computed(() => props.to ? 'RouterLink' : 'button')
22
24
 
25
+ const sizeMap = {
26
+ xs: { px: 32, icon: 16 },
27
+ sm: { px: 40, icon: 20 },
28
+ md: { px: 48, icon: 24 },
29
+ lg: { px: 56, icon: 28 },
30
+ xl: { px: 64, icon: 32 },
31
+ }
32
+
33
+ const resolved = computed(() => {
34
+ if (typeof props.size === 'number') return { px: props.size, icon: Math.round(props.size * 0.55) }
35
+ return sizeMap[props.size] ?? sizeMap.sm
36
+ })
37
+
23
38
  const base =
24
- 'inline-flex shrink-0 items-center justify-center rounded-full transition-colors duration-150 cursor-pointer ' +
39
+ 'inline-flex shrink-0 items-center justify-center transition-colors duration-150 cursor-pointer ' +
25
40
  'disabled:cursor-not-allowed disabled:opacity-[0.38]'
26
41
 
42
+ const shapeClass = computed(() => props.shape === 'squared' ? 'rounded-md' : 'rounded-full')
43
+
44
+ function createRipple(event: PointerEvent) {
45
+ if (props.disabled) return
46
+ const button = event.currentTarget as HTMLElement
47
+ const rect = button.getBoundingClientRect()
48
+ const d = Math.max(rect.width, rect.height) * 2
49
+ const el = document.createElement('span')
50
+ el.className = 'm3-ripple'
51
+ el.style.cssText = `width:${d}px;height:${d}px;top:${event.clientY - rect.top - d / 2}px;left:${event.clientX - rect.left - d / 2}px`
52
+ button.appendChild(el)
53
+ el.addEventListener('animationend', () => el.remove(), { once: true })
54
+ }
55
+
27
56
  const variantClasses = computed(() => {
28
57
  switch (props.variant) {
29
58
  case 'filled':
@@ -45,9 +74,10 @@ const variantClasses = computed(() => {
45
74
  :type="to ? undefined : 'button'"
46
75
  :aria-label="label || undefined"
47
76
  :disabled="disabled"
48
- :class="[base, variantClasses]"
49
- :style="{ width: `${size}px`, height: `${size}px` }"
77
+ :class="[base, shapeClass, variantClasses, 'relative overflow-hidden']"
78
+ :style="{ width: `${resolved.px}px`, height: `${resolved.px}px` }"
79
+ @pointerdown="createRipple"
50
80
  >
51
- <MIcon :name="icon" :size="Math.round(size * 0.55)" />
81
+ <MIcon :name="icon" :size="resolved.icon" />
52
82
  </component>
53
83
  </template>
@@ -6,11 +6,12 @@ const props = withDefaults(
6
6
  dense?: boolean
7
7
  dividers?: boolean | 'inset'
8
8
  nav?: boolean
9
+ segmented?: boolean
9
10
  selectable?: boolean
10
11
  selected?: string | number | null
11
12
  lines?: 1 | 2 | 3
12
13
  }>(),
13
- { dense: false, dividers: false, nav: false, selectable: false },
14
+ { dense: false, dividers: false, nav: false, segmented: false, selectable: false },
14
15
  )
15
16
 
16
17
  const emit = defineEmits<{
@@ -20,6 +21,7 @@ const emit = defineEmits<{
20
21
  provide('m-list', {
21
22
  dense: computed(() => props.dense),
22
23
  nav: computed(() => props.nav),
24
+ segmented: computed(() => props.segmented),
23
25
  selectable: computed(() => props.selectable),
24
26
  selected: computed(() => props.selected),
25
27
  dividers: computed(() => props.dividers),
@@ -28,8 +30,9 @@ provide('m-list', {
28
30
  })
29
31
 
30
32
  const classes = computed(() => [
31
- 'flex flex-col py-2',
32
- props.nav && 'gap-0.5 px-3',
33
+ 'flex flex-col',
34
+ props.segmented ? 'gap-2 p-2' : 'py-2',
35
+ props.nav && !props.segmented && 'gap-0.5 px-3',
33
36
  ])
34
37
  </script>
35
38