@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.
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/components/MAlert.vue.d.ts +27 -0
- package/dist/components/MAppBar.vue.d.ts +24 -0
- package/dist/components/MAvatar.vue.d.ts +9 -0
- package/dist/components/MBadge.vue.d.ts +22 -0
- package/dist/components/MBottomSheet.vue.d.ts +26 -0
- package/dist/components/MBreadcrumbs.vue.d.ts +19 -0
- package/dist/components/MButton.vue.d.ts +32 -0
- package/dist/components/MCalendar.vue.d.ts +23 -0
- package/dist/components/MCard.vue.d.ts +28 -0
- package/dist/components/MChart.vue.d.ts +13 -0
- package/dist/components/MCheckbox.vue.d.ts +26 -0
- package/dist/components/MChip.vue.d.ts +33 -0
- package/dist/components/MCodeEditor.vue.d.ts +35 -0
- package/dist/components/MColorPicker.vue.d.ts +18 -0
- package/dist/components/MCommandPalette.vue.d.ts +29 -0
- package/dist/components/MConfirmDialog.vue.d.ts +23 -0
- package/dist/components/MContainer.vue.d.ts +24 -0
- package/dist/components/MContextMenu.vue.d.ts +35 -0
- package/dist/components/MDataTable.vue.d.ts +83 -0
- package/dist/components/MDatePicker.vue.d.ts +21 -0
- package/dist/components/MDateRangePicker.vue.d.ts +24 -0
- package/dist/components/MDialog.vue.d.ts +30 -0
- package/dist/components/MDivider.vue.d.ts +11 -0
- package/dist/components/MDragDropList.vue.d.ts +40 -0
- package/dist/components/MEmptyState.vue.d.ts +21 -0
- package/dist/components/MExpansionPanel.vue.d.ts +28 -0
- package/dist/components/MFab.vue.d.ts +28 -0
- package/dist/components/MFileUpload.vue.d.ts +25 -0
- package/dist/components/MGrid.vue.d.ts +26 -0
- package/dist/components/MHotkeys.vue.d.ts +16 -0
- package/dist/components/MIcon.vue.d.ts +9 -0
- package/dist/components/MIconButton.vue.d.ts +14 -0
- package/dist/components/MInfiniteScroll.vue.d.ts +34 -0
- package/dist/components/MJsonEditor.vue.d.ts +17 -0
- package/dist/components/MJsonViewer.vue.d.ts +14 -0
- package/dist/components/MKanban.vue.d.ts +53 -0
- package/dist/components/MLoadingOverlay.vue.d.ts +28 -0
- package/dist/components/MMarkdown.vue.d.ts +11 -0
- package/dist/components/MMasonry.vue.d.ts +23 -0
- package/dist/components/MMenu.vue.d.ts +27 -0
- package/dist/components/MMenuItem.vue.d.ts +16 -0
- package/dist/components/MMultiSelect.vue.d.ts +34 -0
- package/dist/components/MNavigationBar.vue.d.ts +18 -0
- package/dist/components/MNavigationDrawer.vue.d.ts +41 -0
- package/dist/components/MNavigationRail.vue.d.ts +32 -0
- package/dist/components/MPagination.vue.d.ts +12 -0
- package/dist/components/MProgressBar.vue.d.ts +13 -0
- package/dist/components/MRadio.vue.d.ts +17 -0
- package/dist/components/MRadioGroup.vue.d.ts +24 -0
- package/dist/components/MRating.vue.d.ts +23 -0
- package/dist/components/MResult.vue.d.ts +20 -0
- package/dist/components/MRichTextEditor.vue.d.ts +17 -0
- package/dist/components/MScheduler.vue.d.ts +35 -0
- package/dist/components/MSegmentedButton.vue.d.ts +24 -0
- package/dist/components/MSelect.vue.d.ts +29 -0
- package/dist/components/MSideSheet.vue.d.ts +28 -0
- package/dist/components/MSkeleton.vue.d.ts +14 -0
- package/dist/components/MSlider.vue.d.ts +24 -0
- package/dist/components/MSnackbar.vue.d.ts +3 -0
- package/dist/components/MSpinner.vue.d.ts +10 -0
- package/dist/components/MSplitter.vue.d.ts +26 -0
- package/dist/components/MSpotlightSearch.vue.d.ts +34 -0
- package/dist/components/MStack.vue.d.ts +30 -0
- package/dist/components/MStatCard.vue.d.ts +24 -0
- package/dist/components/MStepper.vue.d.ts +33 -0
- package/dist/components/MSwitch.vue.d.ts +14 -0
- package/dist/components/MTable.vue.d.ts +73 -0
- package/dist/components/MTabs.vue.d.ts +20 -0
- package/dist/components/MTerminal.vue.d.ts +25 -0
- package/dist/components/MTextField.vue.d.ts +41 -0
- package/dist/components/MTimePicker.vue.d.ts +20 -0
- package/dist/components/MTimeline.vue.d.ts +31 -0
- package/dist/components/MTooltip.vue.d.ts +21 -0
- package/dist/components/MTopAppBar.vue.d.ts +29 -0
- package/dist/components/MTour.vue.d.ts +19 -0
- package/dist/components/MTransferList.vue.d.ts +23 -0
- package/dist/components/MTree.vue.d.ts +68 -0
- package/dist/components/MTreeTable.vue.d.ts +57 -0
- package/dist/components/MVirtualTable.vue.d.ts +40 -0
- package/dist/components/_MContextMenuPanel.vue.d.ts +13 -0
- package/dist/components/_MTreeNode.vue.d.ts +26 -0
- package/dist/composables/useColorPalette.d.ts +11 -0
- package/dist/composables/useFieldBg.d.ts +13 -0
- package/dist/composables/useTheme.d.ts +5 -0
- package/dist/composables/useToast.d.ts +59 -0
- package/dist/index.d.ts +112 -0
- package/dist/m3ui.css +2 -0
- package/dist/m3ui.js +7432 -0
- package/dist/m3ui.js.map +1 -0
- package/dist/plugin.d.ts +9 -0
- package/dist/styles/palettes.css +1253 -0
- package/dist/styles/theme.css +249 -0
- package/package.json +166 -0
- package/src/components/MAlert.vue +69 -0
- package/src/components/MAppBar.vue +40 -0
- package/src/components/MAvatar.vue +21 -0
- package/src/components/MBadge.vue +46 -0
- package/src/components/MBottomSheet.vue +113 -0
- package/src/components/MBreadcrumbs.vue +52 -0
- package/src/components/MButton.vue +111 -0
- package/src/components/MCalendar.vue +173 -0
- package/src/components/MCard.vue +56 -0
- package/src/components/MChart.vue +158 -0
- package/src/components/MCheckbox.vue +48 -0
- package/src/components/MChip.vue +87 -0
- package/src/components/MCodeEditor.vue +179 -0
- package/src/components/MColorPicker.vue +305 -0
- package/src/components/MCommandPalette.vue +213 -0
- package/src/components/MConfirmDialog.vue +43 -0
- package/src/components/MContainer.vue +36 -0
- package/src/components/MContextMenu.vue +66 -0
- package/src/components/MDataTable.vue +376 -0
- package/src/components/MDatePicker.vue +253 -0
- package/src/components/MDateRangePicker.vue +265 -0
- package/src/components/MDialog.vue +90 -0
- package/src/components/MDivider.vue +26 -0
- package/src/components/MDragDropList.vue +111 -0
- package/src/components/MEmptyState.vue +40 -0
- package/src/components/MExpansionPanel.vue +112 -0
- package/src/components/MFab.vue +220 -0
- package/src/components/MFileUpload.vue +206 -0
- package/src/components/MGrid.vue +99 -0
- package/src/components/MHotkeys.vue +122 -0
- package/src/components/MIcon.vue +9 -0
- package/src/components/MIconButton.vue +49 -0
- package/src/components/MInfiniteScroll.vue +68 -0
- package/src/components/MJsonEditor.vue +118 -0
- package/src/components/MJsonViewer.vue +106 -0
- package/src/components/MKanban.vue +147 -0
- package/src/components/MLoadingOverlay.vue +52 -0
- package/src/components/MMarkdown.vue +123 -0
- package/src/components/MMasonry.vue +87 -0
- package/src/components/MMenu.vue +113 -0
- package/src/components/MMenuItem.vue +15 -0
- package/src/components/MMultiSelect.vue +306 -0
- package/src/components/MNavigationBar.vue +62 -0
- package/src/components/MNavigationDrawer.vue +157 -0
- package/src/components/MNavigationRail.vue +80 -0
- package/src/components/MPagination.vue +37 -0
- package/src/components/MProgressBar.vue +200 -0
- package/src/components/MRadio.vue +89 -0
- package/src/components/MRadioGroup.vue +41 -0
- package/src/components/MRating.vue +108 -0
- package/src/components/MResult.vue +62 -0
- package/src/components/MRichTextEditor.vue +199 -0
- package/src/components/MScheduler.vue +225 -0
- package/src/components/MSegmentedButton.vue +75 -0
- package/src/components/MSelect.vue +259 -0
- package/src/components/MSideSheet.vue +112 -0
- package/src/components/MSkeleton.vue +60 -0
- package/src/components/MSlider.vue +188 -0
- package/src/components/MSnackbar.vue +244 -0
- package/src/components/MSpinner.vue +122 -0
- package/src/components/MSplitter.vue +97 -0
- package/src/components/MSpotlightSearch.vue +244 -0
- package/src/components/MStack.vue +67 -0
- package/src/components/MStatCard.vue +56 -0
- package/src/components/MStepper.vue +161 -0
- package/src/components/MSwitch.vue +63 -0
- package/src/components/MTable.vue +404 -0
- package/src/components/MTabs.vue +97 -0
- package/src/components/MTerminal.vue +146 -0
- package/src/components/MTextField.vue +180 -0
- package/src/components/MTimePicker.vue +227 -0
- package/src/components/MTimeline.vue +117 -0
- package/src/components/MTooltip.vue +82 -0
- package/src/components/MTopAppBar.vue +62 -0
- package/src/components/MTour.vue +226 -0
- package/src/components/MTransferList.vue +181 -0
- package/src/components/MTree.vue +164 -0
- package/src/components/MTreeTable.vue +159 -0
- package/src/components/MVirtualTable.vue +155 -0
- package/src/components/_MContextMenuPanel.vue +129 -0
- package/src/components/_MTreeNode.vue +171 -0
- package/src/composables/useColorPalette.ts +60 -0
- package/src/composables/useFieldBg.ts +91 -0
- package/src/composables/useTheme.ts +55 -0
- package/src/composables/useToast.ts +51 -0
- package/src/env.d.ts +1 -0
- package/src/index.ts +119 -0
- package/src/plugin.ts +18 -0
- package/src/styles/palettes.css +1253 -0
- package/src/styles/theme.css +249 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import MIcon from './MIcon.vue'
|
|
4
|
+
|
|
5
|
+
export interface DragDropItem {
|
|
6
|
+
id: string | number
|
|
7
|
+
[key: string]: any
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const props = withDefaults(
|
|
11
|
+
defineProps<{
|
|
12
|
+
modelValue: DragDropItem[]
|
|
13
|
+
handle?: boolean
|
|
14
|
+
}>(),
|
|
15
|
+
{ handle: false },
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{
|
|
19
|
+
'update:modelValue': [DragDropItem[]]
|
|
20
|
+
reorder: [{ from: number; to: number; items: DragDropItem[] }]
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const dragIndex = ref<number | null>(null)
|
|
24
|
+
const overIndex = ref<number | null>(null)
|
|
25
|
+
|
|
26
|
+
function onDragStart(e: DragEvent, index: number) {
|
|
27
|
+
dragIndex.value = index
|
|
28
|
+
if (e.dataTransfer) {
|
|
29
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
30
|
+
e.dataTransfer.setData('text/plain', String(index))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function onDragOver(e: DragEvent, index: number) {
|
|
35
|
+
e.preventDefault()
|
|
36
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
|
37
|
+
overIndex.value = index
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function onDragLeave() {
|
|
41
|
+
overIndex.value = null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function onDrop(e: DragEvent, toIndex: number) {
|
|
45
|
+
e.preventDefault()
|
|
46
|
+
const fromIndex = dragIndex.value
|
|
47
|
+
if (fromIndex === null || fromIndex === toIndex) {
|
|
48
|
+
reset()
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const items = [...props.modelValue]
|
|
53
|
+
const moved = items.splice(fromIndex, 1)[0]!
|
|
54
|
+
items.splice(toIndex, 0, moved)
|
|
55
|
+
|
|
56
|
+
emit('update:modelValue', items)
|
|
57
|
+
emit('reorder', { from: fromIndex, to: toIndex, items })
|
|
58
|
+
reset()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function onDragEnd() {
|
|
62
|
+
reset()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function reset() {
|
|
66
|
+
dragIndex.value = null
|
|
67
|
+
overIndex.value = null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getItemClass(index: number) {
|
|
71
|
+
if (dragIndex.value === index) return 'opacity-30'
|
|
72
|
+
if (overIndex.value === index && dragIndex.value !== null) return 'ring-2 ring-primary ring-inset'
|
|
73
|
+
return ''
|
|
74
|
+
}
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<template>
|
|
78
|
+
<div class="flex flex-col" role="listbox">
|
|
79
|
+
<div
|
|
80
|
+
v-for="(item, index) in modelValue"
|
|
81
|
+
:key="item.id"
|
|
82
|
+
:draggable="!handle"
|
|
83
|
+
class="group flex items-center gap-2 rounded-lg px-3 py-2 transition-all"
|
|
84
|
+
:class="[
|
|
85
|
+
getItemClass(index),
|
|
86
|
+
!handle && 'cursor-grab active:cursor-grabbing',
|
|
87
|
+
]"
|
|
88
|
+
@dragstart="!handle && onDragStart($event, index)"
|
|
89
|
+
@dragover="onDragOver($event, index)"
|
|
90
|
+
@dragleave="onDragLeave"
|
|
91
|
+
@drop="onDrop($event, index)"
|
|
92
|
+
@dragend="onDragEnd"
|
|
93
|
+
role="option"
|
|
94
|
+
>
|
|
95
|
+
<div
|
|
96
|
+
v-if="handle"
|
|
97
|
+
class="flex shrink-0 cursor-grab items-center justify-center rounded p-0.5 text-on-surface-variant/50 transition-colors hover:text-on-surface-variant active:cursor-grabbing"
|
|
98
|
+
draggable="true"
|
|
99
|
+
@dragstart="onDragStart($event, index)"
|
|
100
|
+
>
|
|
101
|
+
<MIcon name="drag_indicator" :size="20" />
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div class="min-w-0 flex-1">
|
|
105
|
+
<slot :item="item" :index="index">
|
|
106
|
+
<span class="text-body-medium text-on-surface">{{ item.id }}</span>
|
|
107
|
+
</slot>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</template>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import MIcon from './MIcon.vue'
|
|
3
|
+
|
|
4
|
+
withDefaults(defineProps<{
|
|
5
|
+
icon?: string
|
|
6
|
+
title: string
|
|
7
|
+
description?: string
|
|
8
|
+
compact?: boolean
|
|
9
|
+
}>(), { icon: 'inbox' })
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<div
|
|
14
|
+
class="flex flex-col items-center justify-center text-center"
|
|
15
|
+
:class="compact ? 'gap-2 py-6' : 'gap-3 py-14'"
|
|
16
|
+
>
|
|
17
|
+
<div
|
|
18
|
+
class="flex items-center justify-center rounded-full bg-surface-container-high text-on-surface-variant"
|
|
19
|
+
:class="compact ? 'h-12 w-12' : 'h-16 w-16'"
|
|
20
|
+
>
|
|
21
|
+
<MIcon :name="icon" :size="compact ? 24 : 32" />
|
|
22
|
+
</div>
|
|
23
|
+
<h3
|
|
24
|
+
class="font-medium text-on-surface"
|
|
25
|
+
:class="compact ? 'text-title-small' : 'text-title-medium'"
|
|
26
|
+
>
|
|
27
|
+
{{ title }}
|
|
28
|
+
</h3>
|
|
29
|
+
<p
|
|
30
|
+
v-if="description"
|
|
31
|
+
class="max-w-sm text-on-surface-variant"
|
|
32
|
+
:class="compact ? 'text-body-small' : 'text-body-medium'"
|
|
33
|
+
>
|
|
34
|
+
{{ description }}
|
|
35
|
+
</p>
|
|
36
|
+
<div v-if="$slots.actions" :class="compact ? 'mt-1' : 'mt-2'">
|
|
37
|
+
<slot name="actions" />
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</template>
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import MIcon from './MIcon.vue'
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(defineProps<{
|
|
6
|
+
title: string
|
|
7
|
+
subtitle?: string
|
|
8
|
+
icon?: string
|
|
9
|
+
modelValue?: boolean
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
variant?: 'outlined' | 'filled' | 'elevated'
|
|
12
|
+
}>(), { disabled: false, variant: 'outlined' })
|
|
13
|
+
|
|
14
|
+
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
|
|
15
|
+
|
|
16
|
+
const internal = ref(false)
|
|
17
|
+
const isOpen = computed(() =>
|
|
18
|
+
props.modelValue !== undefined ? props.modelValue : internal.value,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
function toggle() {
|
|
22
|
+
if (props.disabled) return
|
|
23
|
+
const next = !isOpen.value
|
|
24
|
+
if (props.modelValue !== undefined) emit('update:modelValue', next)
|
|
25
|
+
else internal.value = next
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const wrapperClass = computed(() => {
|
|
29
|
+
if (props.variant === 'filled') return 'bg-surface-container-low rounded-md'
|
|
30
|
+
if (props.variant === 'elevated') return 'bg-surface-container-low rounded-md shadow-elevation-1'
|
|
31
|
+
return 'rounded-md border border-outline-variant'
|
|
32
|
+
})
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<div :class="wrapperClass" class="overflow-hidden">
|
|
37
|
+
<!-- Header / trigger -->
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
class="flex w-full items-center gap-4 px-5 py-4 text-left transition-colors duration-150 focus-visible:outline-none"
|
|
41
|
+
:class="[
|
|
42
|
+
disabled ? 'cursor-not-allowed opacity-[0.38]' : 'cursor-pointer hover:bg-on-surface/4',
|
|
43
|
+
isOpen ? 'bg-on-surface/4' : '',
|
|
44
|
+
]"
|
|
45
|
+
:aria-expanded="isOpen"
|
|
46
|
+
:disabled="disabled"
|
|
47
|
+
@click="toggle"
|
|
48
|
+
>
|
|
49
|
+
<MIcon v-if="icon" :name="icon" :size="22" class="shrink-0 text-on-surface-variant" />
|
|
50
|
+
<div class="flex-1 min-w-0">
|
|
51
|
+
<p class="text-body-large font-medium text-on-surface">{{ title }}</p>
|
|
52
|
+
<p v-if="subtitle" class="text-body-small text-on-surface-variant">{{ subtitle }}</p>
|
|
53
|
+
</div>
|
|
54
|
+
<MIcon
|
|
55
|
+
name="expand_more"
|
|
56
|
+
:size="22"
|
|
57
|
+
class="shrink-0 text-on-surface-variant transition-transform duration-200"
|
|
58
|
+
:class="isOpen ? 'rotate-180' : ''"
|
|
59
|
+
/>
|
|
60
|
+
</button>
|
|
61
|
+
|
|
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">
|
|
66
|
+
<slot />
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</Transition>
|
|
70
|
+
</div>
|
|
71
|
+
</template>
|
|
72
|
+
|
|
73
|
+
<style scoped>
|
|
74
|
+
/*
|
|
75
|
+
grid-template-rows: 0fr → 1fr expands to the exact content height,
|
|
76
|
+
so the animation is always proportional — no max-height overshoot.
|
|
77
|
+
*/
|
|
78
|
+
.expand-grid {
|
|
79
|
+
display: grid;
|
|
80
|
+
grid-template-rows: 1fr;
|
|
81
|
+
}
|
|
82
|
+
.expand-body {
|
|
83
|
+
min-height: 0; /* required for 0fr to actually collapse */
|
|
84
|
+
overflow: hidden;
|
|
85
|
+
}
|
|
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
|
+
</style>
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
3
|
+
import MIcon from './MIcon.vue'
|
|
4
|
+
|
|
5
|
+
export interface SpeedDialItem {
|
|
6
|
+
icon: string
|
|
7
|
+
label?: string
|
|
8
|
+
onClick?: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(
|
|
12
|
+
defineProps<{
|
|
13
|
+
icon: string
|
|
14
|
+
label?: string
|
|
15
|
+
color?: 'primary' | 'secondary' | 'tertiary' | 'surface'
|
|
16
|
+
size?: 'small' | 'regular' | 'large'
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
/** Speed-dial child items. If provided, clicking the FAB toggles them instead of emitting click. */
|
|
19
|
+
items?: SpeedDialItem[]
|
|
20
|
+
/** Direction the speed-dial items expand toward. */
|
|
21
|
+
direction?: 'up' | 'down' | 'left' | 'right' | 'radial'
|
|
22
|
+
}>(),
|
|
23
|
+
{
|
|
24
|
+
color: 'primary',
|
|
25
|
+
size: 'regular',
|
|
26
|
+
disabled: false,
|
|
27
|
+
direction: 'up',
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
const emit = defineEmits<{ click: [MouseEvent] }>()
|
|
32
|
+
|
|
33
|
+
const open = ref(false)
|
|
34
|
+
const containerEl = ref<HTMLElement>()
|
|
35
|
+
|
|
36
|
+
const hasItems = computed(() => !!props.items?.length)
|
|
37
|
+
|
|
38
|
+
const colorMap: Record<string, string> = {
|
|
39
|
+
primary: 'bg-primary-container text-on-primary-container',
|
|
40
|
+
secondary: 'bg-secondary-container text-on-secondary-container',
|
|
41
|
+
tertiary: 'bg-tertiary-container text-on-tertiary-container',
|
|
42
|
+
surface: 'bg-surface-container-high text-primary',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const fabSizeClasses = computed(() => {
|
|
46
|
+
if (props.label) return 'h-14 rounded-2xl px-4 gap-3'
|
|
47
|
+
switch (props.size) {
|
|
48
|
+
case 'small': return 'h-10 w-10 rounded-lg'
|
|
49
|
+
case 'large': return 'h-24 w-24 rounded-[28px]'
|
|
50
|
+
default: return 'h-14 w-14 rounded-2xl'
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const fabIconSize = computed(() => {
|
|
55
|
+
if (props.label) return 24
|
|
56
|
+
switch (props.size) {
|
|
57
|
+
case 'small': return 20
|
|
58
|
+
case 'large': return 36
|
|
59
|
+
default: return 24
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// FAB height in px — used to position items relative to the container
|
|
64
|
+
const fabPx = computed(() => {
|
|
65
|
+
if (props.label) return 56
|
|
66
|
+
switch (props.size) {
|
|
67
|
+
case 'small': return 40
|
|
68
|
+
case 'large': return 96
|
|
69
|
+
default: return 56
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Item size (always small-FAB-sized): 40px
|
|
74
|
+
const ITEM_PX = 40
|
|
75
|
+
const ITEM_GAP = 8
|
|
76
|
+
|
|
77
|
+
function itemStyle(index: number): Record<string, string> {
|
|
78
|
+
const count = props.items?.length ?? 0
|
|
79
|
+
// Stagger delay: open = forward order, close = reverse order
|
|
80
|
+
const delay = open.value
|
|
81
|
+
? `${index * 35}ms`
|
|
82
|
+
: `${(count - 1 - index) * 35}ms`
|
|
83
|
+
|
|
84
|
+
const transition = `transform 220ms cubic-bezier(0.2,0,0,1) ${delay}, opacity 180ms ease ${delay}`
|
|
85
|
+
|
|
86
|
+
if (props.direction === 'radial') {
|
|
87
|
+
const angle = (2 * Math.PI * index) / count - Math.PI / 2
|
|
88
|
+
const r = 80
|
|
89
|
+
const dx = (Math.cos(angle) * r).toFixed(1)
|
|
90
|
+
const dy = (Math.sin(angle) * r).toFixed(1)
|
|
91
|
+
return {
|
|
92
|
+
position: 'absolute',
|
|
93
|
+
top: '50%',
|
|
94
|
+
left: '50%',
|
|
95
|
+
marginTop: `${-ITEM_PX / 2}px`,
|
|
96
|
+
marginLeft: `${-ITEM_PX / 2}px`,
|
|
97
|
+
transform: open.value ? `translate(${dx}px, ${dy}px) scale(1)` : 'translate(0,0) scale(0)',
|
|
98
|
+
opacity: open.value ? '1' : '0',
|
|
99
|
+
transition,
|
|
100
|
+
pointerEvents: open.value ? 'auto' : 'none',
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Linear directions: offset from the container edge
|
|
105
|
+
const step = ITEM_PX + ITEM_GAP
|
|
106
|
+
const base = fabPx.value + ITEM_GAP + index * step
|
|
107
|
+
|
|
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` },
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const translateFrom: Record<string, string> = {
|
|
116
|
+
up: 'translateY(12px) scale(0.75)',
|
|
117
|
+
down: 'translateY(-12px) scale(0.75)',
|
|
118
|
+
left: 'translateX(12px) scale(0.75)',
|
|
119
|
+
right: 'translateX(-12px) scale(0.75)',
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
position: 'absolute',
|
|
124
|
+
...offsetMap[props.direction] ?? offsetMap.up,
|
|
125
|
+
transform: open.value ? 'translate(0,0) scale(1)' : (translateFrom[props.direction] ?? 'scale(0.75)'),
|
|
126
|
+
opacity: open.value ? '1' : '0',
|
|
127
|
+
transition,
|
|
128
|
+
pointerEvents: open.value ? 'auto' : 'none',
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Label only makes sense for up/down; placed to the left of the item button
|
|
133
|
+
const showLabel = computed(() => props.direction === 'up' || props.direction === 'down')
|
|
134
|
+
|
|
135
|
+
function createRipple(event: PointerEvent | MouseEvent, target?: HTMLElement) {
|
|
136
|
+
const button = (target ?? event.currentTarget) as HTMLElement
|
|
137
|
+
const rect = button.getBoundingClientRect()
|
|
138
|
+
const d = Math.max(rect.width, rect.height) * 2
|
|
139
|
+
const el = document.createElement('span')
|
|
140
|
+
el.className = 'm3-ripple'
|
|
141
|
+
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`
|
|
142
|
+
button.appendChild(el)
|
|
143
|
+
el.addEventListener('animationend', () => el.remove(), { once: true })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function handleFabClick(e: PointerEvent) {
|
|
147
|
+
if (hasItems.value) {
|
|
148
|
+
open.value = !open.value
|
|
149
|
+
} else {
|
|
150
|
+
emit('click', e)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function handleItemClick(e: PointerEvent, item: SpeedDialItem, buttonEl: HTMLElement) {
|
|
155
|
+
createRipple(e, buttonEl)
|
|
156
|
+
open.value = false
|
|
157
|
+
item.onClick?.()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function onDocClick(e: MouseEvent) {
|
|
161
|
+
if (!open.value) return
|
|
162
|
+
if (containerEl.value && !containerEl.value.contains(e.target as Node)) {
|
|
163
|
+
open.value = false
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
onMounted(() => document.addEventListener('click', onDocClick, true))
|
|
168
|
+
onUnmounted(() => document.removeEventListener('click', onDocClick, true))
|
|
169
|
+
</script>
|
|
170
|
+
|
|
171
|
+
<template>
|
|
172
|
+
<div ref="containerEl" class="relative inline-flex items-center justify-center">
|
|
173
|
+
<!-- Speed-dial items (absolutely positioned outside the container) -->
|
|
174
|
+
<template v-if="hasItems">
|
|
175
|
+
<div
|
|
176
|
+
v-for="(item, i) in items"
|
|
177
|
+
:key="i"
|
|
178
|
+
:style="itemStyle(i)"
|
|
179
|
+
class="flex items-center gap-3"
|
|
180
|
+
:class="showLabel ? 'flex-row-reverse' : ''"
|
|
181
|
+
>
|
|
182
|
+
<!-- Label pill (up/down only) -->
|
|
183
|
+
<span
|
|
184
|
+
v-if="item.label && showLabel"
|
|
185
|
+
class="whitespace-nowrap rounded-md bg-surface-container-high px-3 py-1.5 text-label-medium text-on-surface shadow-elevation-1"
|
|
186
|
+
>
|
|
187
|
+
{{ item.label }}
|
|
188
|
+
</span>
|
|
189
|
+
|
|
190
|
+
<!-- Mini FAB button -->
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
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]"
|
|
194
|
+
:class="colorMap[color]"
|
|
195
|
+
:style="{ width: `${ITEM_PX}px`, height: `${ITEM_PX}px` }"
|
|
196
|
+
@pointerdown="(e) => handleItemClick(e, item, e.currentTarget as HTMLElement)"
|
|
197
|
+
>
|
|
198
|
+
<MIcon :name="item.icon" :size="20" />
|
|
199
|
+
</button>
|
|
200
|
+
</div>
|
|
201
|
+
</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>
|
|
220
|
+
</template>
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import MIcon from './MIcon.vue'
|
|
4
|
+
import MIconButton from './MIconButton.vue'
|
|
5
|
+
import MSpinner from './MSpinner.vue'
|
|
6
|
+
|
|
7
|
+
export interface UploadFile {
|
|
8
|
+
file: File
|
|
9
|
+
id: string
|
|
10
|
+
progress: number
|
|
11
|
+
status: 'pending' | 'uploading' | 'done' | 'error'
|
|
12
|
+
preview?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(
|
|
16
|
+
defineProps<{
|
|
17
|
+
accept?: string
|
|
18
|
+
multiple?: boolean
|
|
19
|
+
maxSize?: number
|
|
20
|
+
disabled?: boolean
|
|
21
|
+
}>(),
|
|
22
|
+
{ multiple: false, disabled: false },
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
const emit = defineEmits<{
|
|
26
|
+
select: [UploadFile[]]
|
|
27
|
+
remove: [UploadFile]
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
const files = ref<UploadFile[]>([])
|
|
31
|
+
const dragging = ref(false)
|
|
32
|
+
const inputRef = ref<HTMLInputElement | null>(null)
|
|
33
|
+
|
|
34
|
+
const acceptList = computed(() =>
|
|
35
|
+
props.accept ? props.accept.split(',').map((s) => s.trim()) : null,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
function isAccepted(file: File) {
|
|
39
|
+
if (!acceptList.value) return true
|
|
40
|
+
return acceptList.value.some((a) => {
|
|
41
|
+
if (a.startsWith('.')) return file.name.toLowerCase().endsWith(a.toLowerCase())
|
|
42
|
+
if (a.endsWith('/*')) return file.type.startsWith(a.replace('/*', '/'))
|
|
43
|
+
return file.type === a
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatSize(bytes: number) {
|
|
48
|
+
if (bytes < 1024) return `${bytes} B`
|
|
49
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
50
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function processFiles(fileList: FileList | File[]) {
|
|
54
|
+
const arr = Array.from(fileList)
|
|
55
|
+
const valid = arr.filter((f) => {
|
|
56
|
+
if (!isAccepted(f)) return false
|
|
57
|
+
if (props.maxSize && f.size > props.maxSize) return false
|
|
58
|
+
return true
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const entries: UploadFile[] = valid.map((f) => {
|
|
62
|
+
const entry: UploadFile = {
|
|
63
|
+
file: f,
|
|
64
|
+
id: crypto.randomUUID(),
|
|
65
|
+
progress: 0,
|
|
66
|
+
status: 'pending',
|
|
67
|
+
}
|
|
68
|
+
if (f.type.startsWith('image/')) {
|
|
69
|
+
entry.preview = URL.createObjectURL(f)
|
|
70
|
+
}
|
|
71
|
+
return entry
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
if (props.multiple) {
|
|
75
|
+
files.value.push(...entries)
|
|
76
|
+
} else {
|
|
77
|
+
files.value.forEach((f) => f.preview && URL.revokeObjectURL(f.preview))
|
|
78
|
+
files.value = entries.slice(0, 1)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
emit('select', entries)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function onDrop(e: DragEvent) {
|
|
85
|
+
dragging.value = false
|
|
86
|
+
if (props.disabled || !e.dataTransfer?.files.length) return
|
|
87
|
+
processFiles(e.dataTransfer.files)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function onFileInput(e: Event) {
|
|
91
|
+
const input = e.target as HTMLInputElement
|
|
92
|
+
if (input.files?.length) processFiles(input.files)
|
|
93
|
+
input.value = ''
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function removeFile(entry: UploadFile) {
|
|
97
|
+
if (entry.preview) URL.revokeObjectURL(entry.preview)
|
|
98
|
+
files.value = files.value.filter((f) => f.id !== entry.id)
|
|
99
|
+
emit('remove', entry)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function openPicker() {
|
|
103
|
+
if (!props.disabled) inputRef.value?.click()
|
|
104
|
+
}
|
|
105
|
+
</script>
|
|
106
|
+
|
|
107
|
+
<template>
|
|
108
|
+
<div class="flex flex-col gap-3">
|
|
109
|
+
<!-- Drop zone -->
|
|
110
|
+
<div
|
|
111
|
+
class="relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-6 transition-colors duration-150"
|
|
112
|
+
:class="[
|
|
113
|
+
disabled
|
|
114
|
+
? 'cursor-not-allowed border-outline-variant/50 bg-surface-container/30 opacity-60'
|
|
115
|
+
: dragging
|
|
116
|
+
? 'border-primary bg-primary-container/20'
|
|
117
|
+
: 'border-outline-variant bg-surface-container-lowest hover:border-primary/60 hover:bg-surface-container',
|
|
118
|
+
]"
|
|
119
|
+
@click="openPicker"
|
|
120
|
+
@dragenter.prevent="dragging = true"
|
|
121
|
+
@dragover.prevent="dragging = true"
|
|
122
|
+
@dragleave.prevent="dragging = false"
|
|
123
|
+
@drop.prevent="onDrop"
|
|
124
|
+
>
|
|
125
|
+
<MIcon
|
|
126
|
+
:name="dragging ? 'downloading' : 'cloud_upload'"
|
|
127
|
+
:size="40"
|
|
128
|
+
class="text-on-surface-variant"
|
|
129
|
+
/>
|
|
130
|
+
<div class="text-center">
|
|
131
|
+
<p class="text-body-large text-on-surface">
|
|
132
|
+
Arrastra archivos aquí o <span class="font-medium text-primary">selecciona</span>
|
|
133
|
+
</p>
|
|
134
|
+
<p v-if="accept || maxSize" class="mt-1 text-body-small text-on-surface-variant">
|
|
135
|
+
<span v-if="accept">{{ accept }}</span>
|
|
136
|
+
<span v-if="accept && maxSize"> · </span>
|
|
137
|
+
<span v-if="maxSize">Máx. {{ formatSize(maxSize) }}</span>
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<input
|
|
143
|
+
ref="inputRef"
|
|
144
|
+
type="file"
|
|
145
|
+
class="hidden"
|
|
146
|
+
:accept="accept"
|
|
147
|
+
:multiple="multiple"
|
|
148
|
+
:disabled="disabled"
|
|
149
|
+
@change="onFileInput"
|
|
150
|
+
/>
|
|
151
|
+
|
|
152
|
+
<!-- File list -->
|
|
153
|
+
<TransitionGroup
|
|
154
|
+
name="m3-file"
|
|
155
|
+
tag="div"
|
|
156
|
+
class="flex flex-col gap-2"
|
|
157
|
+
>
|
|
158
|
+
<div
|
|
159
|
+
v-for="entry in files"
|
|
160
|
+
:key="entry.id"
|
|
161
|
+
class="flex items-center gap-3 rounded-lg bg-surface-container p-3"
|
|
162
|
+
>
|
|
163
|
+
<!-- Preview / icon -->
|
|
164
|
+
<div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-md bg-surface-container-high">
|
|
165
|
+
<img v-if="entry.preview" :src="entry.preview" class="h-full w-full object-cover" />
|
|
166
|
+
<MIcon v-else name="description" :size="24" class="text-on-surface-variant" />
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<!-- Info -->
|
|
170
|
+
<div class="min-w-0 flex-1">
|
|
171
|
+
<p class="truncate text-body-medium text-on-surface">{{ entry.file.name }}</p>
|
|
172
|
+
<p class="text-body-small text-on-surface-variant">{{ formatSize(entry.file.size) }}</p>
|
|
173
|
+
<!-- Progress bar -->
|
|
174
|
+
<div
|
|
175
|
+
v-if="entry.status === 'uploading'"
|
|
176
|
+
class="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-surface-container-highest"
|
|
177
|
+
>
|
|
178
|
+
<div
|
|
179
|
+
class="h-full rounded-full bg-primary transition-[width] duration-300"
|
|
180
|
+
:style="{ width: `${entry.progress}%` }"
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<!-- Status -->
|
|
186
|
+
<MSpinner v-if="entry.status === 'uploading'" :size="20" />
|
|
187
|
+
<MIcon v-else-if="entry.status === 'done'" name="check_circle" :size="20" class="text-success" />
|
|
188
|
+
<MIcon v-else-if="entry.status === 'error'" name="error" :size="20" class="text-error" />
|
|
189
|
+
|
|
190
|
+
<MIconButton icon="close" label="Eliminar" :size="32" @click="removeFile(entry)" />
|
|
191
|
+
</div>
|
|
192
|
+
</TransitionGroup>
|
|
193
|
+
</div>
|
|
194
|
+
</template>
|
|
195
|
+
|
|
196
|
+
<style scoped>
|
|
197
|
+
.m3-file-enter-active,
|
|
198
|
+
.m3-file-leave-active {
|
|
199
|
+
transition: all 0.2s ease;
|
|
200
|
+
}
|
|
201
|
+
.m3-file-enter-from,
|
|
202
|
+
.m3-file-leave-to {
|
|
203
|
+
opacity: 0;
|
|
204
|
+
transform: translateY(-8px);
|
|
205
|
+
}
|
|
206
|
+
</style>
|