@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.
- package/README.md +219 -59
- package/dist/{MMenuItem-C6RwHl-l.js → MMenuItem-FyttjOj_.js} +399 -207
- package/dist/MMenuItem-FyttjOj_.js.map +1 -0
- package/dist/components/MAutocomplete.vue.d.ts +2 -0
- package/dist/components/MBox.vue.d.ts +2 -2
- package/dist/components/MButton.vue.d.ts +4 -0
- package/dist/components/MButtonGroup.vue.d.ts +28 -0
- package/dist/components/MCarousel.vue.d.ts +21 -12
- package/dist/components/MColorPickerModal.vue.d.ts +18 -0
- package/dist/components/MDataTable.vue.d.ts +1 -1
- package/dist/components/MDatePickerModal.vue.d.ts +21 -0
- package/dist/components/MDateRangePickerModal.vue.d.ts +24 -0
- package/dist/components/MDialog.vue.d.ts +9 -1
- package/dist/components/MExpansionPanel.vue.d.ts +3 -2
- package/dist/components/MFab.vue.d.ts +14 -2
- package/dist/components/MFlex.vue.d.ts +2 -2
- package/dist/components/MIconButton.vue.d.ts +4 -2
- package/dist/components/MInfiniteScroll.vue.d.ts +1 -1
- package/dist/components/MList.vue.d.ts +2 -0
- package/dist/components/MLoadingOverlay.vue.d.ts +1 -1
- package/dist/components/MMenu.vue.d.ts +3 -1
- package/dist/components/MMenuItem.vue.d.ts +14 -3
- package/dist/components/MMultiAutocomplete.vue.d.ts +2 -0
- package/dist/components/MMultiSelect.vue.d.ts +2 -0
- package/dist/components/MNavigationDrawer.vue.d.ts +1 -1
- package/dist/components/MNotificationHost.vue.d.ts +3 -0
- package/dist/components/MSelect.vue.d.ts +2 -0
- package/dist/components/MSlider.vue.d.ts +15 -4
- package/dist/components/MSplitButton.vue.d.ts +39 -0
- package/dist/components/MSplitter.vue.d.ts +1 -1
- package/dist/components/MStack.vue.d.ts +2 -2
- package/dist/components/MTable.vue.d.ts +1 -1
- package/dist/components/MTimePickerModal.vue.d.ts +21 -0
- package/dist/components/MToolbar.vue.d.ts +30 -0
- package/dist/components/MTooltip.vue.d.ts +8 -2
- package/dist/components/MWindow.vue.d.ts +2 -2
- package/dist/composables/useDevice.d.ts +3 -0
- package/dist/composables/useNotification.d.ts +64 -0
- package/dist/composables/useToast.d.ts +8 -0
- package/dist/index.d.ts +12 -0
- package/dist/m3ui-vue.css +1 -1
- package/dist/m3ui.js +5222 -2994
- package/dist/m3ui.js.map +1 -1
- package/dist/rich-text-editor.js +10 -10
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/MAutocomplete.vue +109 -13
- package/src/components/MButton.vue +38 -16
- package/src/components/MButtonGroup.vue +158 -0
- package/src/components/MCarousel.vue +223 -101
- package/src/components/MColorPicker.vue +1 -4
- package/src/components/MColorPickerModal.vue +276 -0
- package/src/components/MDatePicker.vue +1 -4
- package/src/components/MDatePickerModal.vue +265 -0
- package/src/components/MDateRangePicker.vue +1 -4
- package/src/components/MDateRangePickerModal.vue +264 -0
- package/src/components/MDialog.vue +38 -1
- package/src/components/MExpansionPanel.vue +10 -32
- package/src/components/MFab.vue +92 -11
- package/src/components/MIconButton.vue +36 -6
- package/src/components/MList.vue +6 -3
- package/src/components/MListItem.vue +9 -3
- package/src/components/MMenu.vue +10 -5
- package/src/components/MMenuItem.vue +93 -10
- package/src/components/MMultiAutocomplete.vue +105 -10
- package/src/components/MMultiSelect.vue +106 -13
- package/src/components/MNotificationHost.vue +163 -0
- package/src/components/MSelect.vue +99 -16
- package/src/components/MSlider.vue +367 -134
- package/src/components/MSnackbar.vue +3 -0
- package/src/components/MSplitButton.vue +240 -0
- package/src/components/MTabs.vue +82 -39
- package/src/components/MTagInput.vue +11 -1
- package/src/components/MTimePicker.vue +1 -4
- package/src/components/MTimePickerModal.vue +458 -0
- package/src/components/MToolbar.vue +75 -0
- package/src/components/MTooltip.vue +60 -10
- package/src/components/_MContextMenuPanel.vue +2 -2
- package/src/composables/useDevice.ts +29 -0
- package/src/composables/useNotification.ts +93 -0
- package/src/composables/useToast.ts +42 -5
- package/src/index.ts +12 -0
- 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
|
-
|
|
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
|
-
<
|
|
64
|
-
<div
|
|
65
|
-
<div class="
|
|
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
|
-
</
|
|
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;
|
|
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>
|
package/src/components/MFab.vue
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, onMounted, onUnmounted, ref,
|
|
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)
|
|
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 (
|
|
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
|
-
|
|
179
|
-
|
|
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="
|
|
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
|
|
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: `${
|
|
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="
|
|
81
|
+
<MIcon :name="icon" :size="resolved.icon" />
|
|
52
82
|
</component>
|
|
53
83
|
</template>
|
package/src/components/MList.vue
CHANGED
|
@@ -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
|
|
32
|
-
props.
|
|
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
|
|