@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,306 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, useId, onMounted, onUnmounted, nextTick } from 'vue'
|
|
3
|
+
import MIcon from './MIcon.vue'
|
|
4
|
+
import MCheckbox from './MCheckbox.vue'
|
|
5
|
+
import { useFieldBg } from '../composables/useFieldBg'
|
|
6
|
+
|
|
7
|
+
export interface MultiSelectOption {
|
|
8
|
+
label: string
|
|
9
|
+
value: string | number
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const props = withDefaults(
|
|
14
|
+
defineProps<{
|
|
15
|
+
modelValue: (string | number)[]
|
|
16
|
+
options: MultiSelectOption[]
|
|
17
|
+
label?: string
|
|
18
|
+
placeholder?: string
|
|
19
|
+
variant?: 'filled' | 'outlined'
|
|
20
|
+
disabled?: boolean
|
|
21
|
+
error?: string
|
|
22
|
+
hint?: string
|
|
23
|
+
required?: boolean
|
|
24
|
+
leadingIcon?: string
|
|
25
|
+
fieldBg?: string
|
|
26
|
+
searchable?: boolean
|
|
27
|
+
maxChips?: number
|
|
28
|
+
}>(),
|
|
29
|
+
{
|
|
30
|
+
modelValue: () => [],
|
|
31
|
+
variant: 'filled',
|
|
32
|
+
disabled: false,
|
|
33
|
+
required: false,
|
|
34
|
+
searchable: true,
|
|
35
|
+
maxChips: 3,
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const emit = defineEmits<{ 'update:modelValue': [(string | number)[]] }>()
|
|
40
|
+
|
|
41
|
+
const id = useId()
|
|
42
|
+
const open = ref(false)
|
|
43
|
+
const search = ref('')
|
|
44
|
+
const fieldEl = ref<HTMLElement | null>(null)
|
|
45
|
+
const { resolvedFieldBg } = useFieldBg(fieldEl, () => props.fieldBg)
|
|
46
|
+
const dropdownEl = ref<HTMLElement | null>(null)
|
|
47
|
+
const searchInput = ref<HTMLInputElement | null>(null)
|
|
48
|
+
const dropPos = ref({ top: '0px', left: '0px', width: '0px' })
|
|
49
|
+
|
|
50
|
+
const hasValue = computed(() => props.modelValue.length > 0)
|
|
51
|
+
|
|
52
|
+
const filteredOptions = computed(() => {
|
|
53
|
+
if (!search.value) return props.options
|
|
54
|
+
const q = search.value.toLowerCase()
|
|
55
|
+
return props.options.filter((o) => o.label.toLowerCase().includes(q))
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const visibleChips = computed(() =>
|
|
59
|
+
props.modelValue.slice(0, props.maxChips).map((v) => ({
|
|
60
|
+
value: v,
|
|
61
|
+
label: props.options.find((o) => o.value === v)?.label ?? String(v),
|
|
62
|
+
})),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const overflowCount = computed(() => Math.max(0, props.modelValue.length - props.maxChips))
|
|
66
|
+
|
|
67
|
+
function toggle(value: string | number) {
|
|
68
|
+
const current = props.modelValue
|
|
69
|
+
if (current.includes(value)) {
|
|
70
|
+
emit('update:modelValue', current.filter((v) => v !== value))
|
|
71
|
+
} else {
|
|
72
|
+
emit('update:modelValue', [...current, value])
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function removeChip(value: string | number, e: Event) {
|
|
77
|
+
e.stopPropagation()
|
|
78
|
+
emit('update:modelValue', props.modelValue.filter((v) => v !== value))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function computeDropPos() {
|
|
82
|
+
if (!fieldEl.value) return
|
|
83
|
+
const rect = fieldEl.value.getBoundingClientRect()
|
|
84
|
+
dropPos.value = {
|
|
85
|
+
top: `${rect.bottom + 4}px`,
|
|
86
|
+
left: `${rect.left}px`,
|
|
87
|
+
width: `${rect.width}px`,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function openDropdown() {
|
|
92
|
+
if (props.disabled) return
|
|
93
|
+
computeDropPos()
|
|
94
|
+
open.value = true
|
|
95
|
+
search.value = ''
|
|
96
|
+
await nextTick()
|
|
97
|
+
searchInput.value?.focus()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function close() {
|
|
101
|
+
open.value = false
|
|
102
|
+
search.value = ''
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function onOutsideClick(e: MouseEvent) {
|
|
106
|
+
const t = e.target as Node
|
|
107
|
+
if (!fieldEl.value?.contains(t) && !dropdownEl.value?.contains(t)) close()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function onScroll(e: Event) {
|
|
111
|
+
if (!open.value) return
|
|
112
|
+
if (dropdownEl.value?.contains(e.target as Node)) return
|
|
113
|
+
if (!fieldEl.value) return
|
|
114
|
+
const rect = fieldEl.value.getBoundingClientRect()
|
|
115
|
+
if (rect.bottom < 0 || rect.top > window.innerHeight) {
|
|
116
|
+
close()
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
computeDropPos()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
onMounted(() => {
|
|
123
|
+
document.addEventListener('mousedown', onOutsideClick)
|
|
124
|
+
window.addEventListener('scroll', onScroll, true)
|
|
125
|
+
})
|
|
126
|
+
onUnmounted(() => {
|
|
127
|
+
document.removeEventListener('mousedown', onOutsideClick)
|
|
128
|
+
window.removeEventListener('scroll', onScroll, true)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const triggerClasses = computed(() => {
|
|
132
|
+
const base = [
|
|
133
|
+
'flex min-h-[56px] w-full cursor-pointer items-center gap-1.5 flex-wrap',
|
|
134
|
+
'transition-[border-color,border-width] duration-150',
|
|
135
|
+
props.leadingIcon ? 'pl-12 pr-10' : 'pl-4 pr-10',
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
if (props.variant === 'outlined') {
|
|
139
|
+
return [
|
|
140
|
+
...base,
|
|
141
|
+
'rounded-sm border bg-transparent py-2',
|
|
142
|
+
open.value
|
|
143
|
+
? (props.error ? 'border-2 border-error' : 'border-2 border-primary')
|
|
144
|
+
: (props.error ? 'border-error' : 'border-outline hover:border-on-surface'),
|
|
145
|
+
].join(' ')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return [
|
|
149
|
+
...base,
|
|
150
|
+
'rounded-t-sm bg-surface-container-highest border-b pt-7 pb-2',
|
|
151
|
+
open.value
|
|
152
|
+
? (props.error ? 'border-b-2 border-error' : 'border-b-2 border-primary')
|
|
153
|
+
: (props.error ? 'border-error' : 'border-on-surface-variant hover:border-on-surface'),
|
|
154
|
+
].join(' ')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const labelClasses = computed(() => {
|
|
158
|
+
const left = props.leadingIcon
|
|
159
|
+
? (props.variant === 'outlined' ? 'left-11' : 'left-12')
|
|
160
|
+
: (props.variant === 'outlined' ? 'left-3' : 'left-4')
|
|
161
|
+
|
|
162
|
+
const floated = props.variant === 'outlined'
|
|
163
|
+
? '-top-2.5 translate-y-0 text-label-small bg-[var(--field-bg)] px-1 right-auto max-w-[calc(100%-1.5rem)]'
|
|
164
|
+
: 'top-3.5 translate-y-0 text-label-small'
|
|
165
|
+
|
|
166
|
+
const unFloated = 'top-1/2 -translate-y-1/2 text-body-large'
|
|
167
|
+
const active = open.value || hasValue.value
|
|
168
|
+
|
|
169
|
+
return [
|
|
170
|
+
'pointer-events-none absolute right-10 truncate transition-all duration-200',
|
|
171
|
+
left,
|
|
172
|
+
active ? floated : unFloated,
|
|
173
|
+
open.value
|
|
174
|
+
? (props.error ? 'text-error' : 'text-primary')
|
|
175
|
+
: (props.error ? 'text-error' : 'text-on-surface-variant'),
|
|
176
|
+
].join(' ')
|
|
177
|
+
})
|
|
178
|
+
</script>
|
|
179
|
+
|
|
180
|
+
<template>
|
|
181
|
+
<div class="flex flex-col gap-1">
|
|
182
|
+
<div
|
|
183
|
+
ref="fieldEl"
|
|
184
|
+
class="relative"
|
|
185
|
+
:class="variant === 'outlined' ? 'mt-2' : ''"
|
|
186
|
+
:style="variant === 'outlined' ? { '--field-bg': resolvedFieldBg } : undefined"
|
|
187
|
+
>
|
|
188
|
+
<div
|
|
189
|
+
v-if="leadingIcon"
|
|
190
|
+
class="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-on-surface-variant"
|
|
191
|
+
>
|
|
192
|
+
<MIcon :name="leadingIcon" :size="20" />
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<!-- Trigger field -->
|
|
196
|
+
<div
|
|
197
|
+
:id="id"
|
|
198
|
+
:class="triggerClasses"
|
|
199
|
+
role="button"
|
|
200
|
+
:tabindex="disabled ? -1 : 0"
|
|
201
|
+
:aria-expanded="open"
|
|
202
|
+
:aria-haspopup="true"
|
|
203
|
+
@click="open ? close() : openDropdown()"
|
|
204
|
+
@keydown.enter.prevent="open ? close() : openDropdown()"
|
|
205
|
+
@keydown.space.prevent="open ? close() : openDropdown()"
|
|
206
|
+
@keydown.escape="close()"
|
|
207
|
+
>
|
|
208
|
+
<template v-if="hasValue">
|
|
209
|
+
<span
|
|
210
|
+
v-for="chip in visibleChips"
|
|
211
|
+
:key="chip.value"
|
|
212
|
+
class="inline-flex items-center gap-1 rounded-full bg-secondary-container px-2 py-0.5 text-label-small text-on-secondary-container"
|
|
213
|
+
>
|
|
214
|
+
{{ chip.label }}
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
class="flex h-4 w-4 items-center justify-center rounded-full hover:bg-on-secondary-container/20"
|
|
218
|
+
@click="removeChip(chip.value, $event)"
|
|
219
|
+
>
|
|
220
|
+
<MIcon name="close" :size="12" />
|
|
221
|
+
</button>
|
|
222
|
+
</span>
|
|
223
|
+
<span
|
|
224
|
+
v-if="overflowCount > 0"
|
|
225
|
+
class="rounded-full bg-surface-container-high px-2 py-0.5 text-label-small text-on-surface-variant"
|
|
226
|
+
>
|
|
227
|
+
+{{ overflowCount }}
|
|
228
|
+
</span>
|
|
229
|
+
</template>
|
|
230
|
+
<span v-else-if="!open" class="text-body-large text-on-surface-variant opacity-0">
|
|
231
|
+
{{ placeholder }}
|
|
232
|
+
</span>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<label :class="labelClasses">
|
|
236
|
+
{{ label }}<span v-if="required" class="text-error"> *</span>
|
|
237
|
+
</label>
|
|
238
|
+
|
|
239
|
+
<div class="pointer-events-none absolute right-2 top-7 -translate-y-1/2">
|
|
240
|
+
<MIcon
|
|
241
|
+
:name="open ? 'arrow_drop_up' : 'arrow_drop_down'"
|
|
242
|
+
:size="24"
|
|
243
|
+
class="text-on-surface-variant"
|
|
244
|
+
/>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<p v-if="error" class="px-4 text-body-small text-error">{{ error }}</p>
|
|
249
|
+
<p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
<!-- Dropdown teleported to body to escape overflow clipping -->
|
|
253
|
+
<Teleport to="body">
|
|
254
|
+
<Transition
|
|
255
|
+
enter-active-class="transition-[opacity,transform] duration-150"
|
|
256
|
+
enter-from-class="opacity-0 -translate-y-1 scale-[0.98]"
|
|
257
|
+
enter-to-class="opacity-100 translate-y-0 scale-100"
|
|
258
|
+
leave-active-class="transition-[opacity,transform] duration-100"
|
|
259
|
+
leave-from-class="opacity-100 translate-y-0 scale-100"
|
|
260
|
+
leave-to-class="opacity-0 -translate-y-1 scale-[0.98]"
|
|
261
|
+
>
|
|
262
|
+
<div
|
|
263
|
+
v-if="open"
|
|
264
|
+
ref="dropdownEl"
|
|
265
|
+
class="fixed z-[500] max-h-60 overflow-auto rounded-sm bg-surface-container shadow-elevation-2"
|
|
266
|
+
:style="dropPos"
|
|
267
|
+
>
|
|
268
|
+
<!-- Search -->
|
|
269
|
+
<div v-if="searchable" class="sticky top-0 bg-surface-container px-3 py-2">
|
|
270
|
+
<div class="flex items-center gap-2 rounded-full bg-surface-container-high px-3 py-1.5">
|
|
271
|
+
<MIcon name="search" :size="16" class="shrink-0 text-on-surface-variant" />
|
|
272
|
+
<input
|
|
273
|
+
ref="searchInput"
|
|
274
|
+
v-model="search"
|
|
275
|
+
type="text"
|
|
276
|
+
placeholder="Buscar..."
|
|
277
|
+
class="w-full bg-transparent text-body-medium text-on-surface outline-none placeholder:text-on-surface-variant"
|
|
278
|
+
/>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
<div class="flex flex-col py-1">
|
|
283
|
+
<label
|
|
284
|
+
v-for="opt in filteredOptions"
|
|
285
|
+
:key="opt.value"
|
|
286
|
+
class="flex cursor-pointer items-center gap-3 px-4 py-2 hover:bg-on-surface/8"
|
|
287
|
+
:class="opt.disabled ? 'cursor-not-allowed opacity-38' : ''"
|
|
288
|
+
>
|
|
289
|
+
<MCheckbox
|
|
290
|
+
:model-value="modelValue.includes(opt.value)"
|
|
291
|
+
:disabled="opt.disabled"
|
|
292
|
+
@update:model-value="!opt.disabled && toggle(opt.value)"
|
|
293
|
+
/>
|
|
294
|
+
<span class="text-body-large text-on-surface">{{ opt.label }}</span>
|
|
295
|
+
</label>
|
|
296
|
+
<p
|
|
297
|
+
v-if="filteredOptions.length === 0"
|
|
298
|
+
class="px-4 py-3 text-center text-body-small text-on-surface-variant"
|
|
299
|
+
>
|
|
300
|
+
Sin resultados
|
|
301
|
+
</p>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</Transition>
|
|
305
|
+
</Teleport>
|
|
306
|
+
</template>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import MIcon from './MIcon.vue'
|
|
3
|
+
import MBadge from './MBadge.vue'
|
|
4
|
+
|
|
5
|
+
export interface NavBarItem {
|
|
6
|
+
value: string | number
|
|
7
|
+
label: string
|
|
8
|
+
icon: string
|
|
9
|
+
badge?: number
|
|
10
|
+
badgeDot?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
withDefaults(defineProps<{
|
|
14
|
+
modelValue: string | number
|
|
15
|
+
items: NavBarItem[]
|
|
16
|
+
}>(), {})
|
|
17
|
+
|
|
18
|
+
defineEmits<{ 'update:modelValue': [string | number] }>()
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<nav class="flex h-20 w-full items-center justify-around border-t border-outline-variant bg-surface-container">
|
|
23
|
+
<button
|
|
24
|
+
v-for="item in items"
|
|
25
|
+
:key="item.value"
|
|
26
|
+
type="button"
|
|
27
|
+
class="group flex flex-1 cursor-pointer flex-col items-center justify-center gap-1 self-stretch transition-colors focus-visible:outline-none"
|
|
28
|
+
:class="
|
|
29
|
+
item.value === modelValue
|
|
30
|
+
? 'text-on-secondary-container'
|
|
31
|
+
: 'text-on-surface-variant'
|
|
32
|
+
"
|
|
33
|
+
@click="$emit('update:modelValue', item.value)"
|
|
34
|
+
>
|
|
35
|
+
<!-- Pill indicator with icon -->
|
|
36
|
+
<span
|
|
37
|
+
class="inline-flex h-8 items-center justify-center rounded-2xl transition-all duration-200"
|
|
38
|
+
:class="
|
|
39
|
+
item.value === modelValue
|
|
40
|
+
? 'w-16 bg-secondary-container'
|
|
41
|
+
: 'w-0 bg-secondary-container/0 group-hover:w-16 group-hover:bg-on-surface/8'
|
|
42
|
+
"
|
|
43
|
+
>
|
|
44
|
+
<MBadge v-if="item.badge != null" :count="item.badge">
|
|
45
|
+
<MIcon :name="item.icon" :size="24" />
|
|
46
|
+
</MBadge>
|
|
47
|
+
<MBadge v-else-if="item.badgeDot" dot>
|
|
48
|
+
<MIcon :name="item.icon" :size="24" />
|
|
49
|
+
</MBadge>
|
|
50
|
+
<MIcon v-else :name="item.icon" :size="24" />
|
|
51
|
+
</span>
|
|
52
|
+
|
|
53
|
+
<!-- Label -->
|
|
54
|
+
<span
|
|
55
|
+
class="text-label-medium transition-[font-weight] duration-150"
|
|
56
|
+
:class="item.value === modelValue ? 'font-bold' : 'font-medium'"
|
|
57
|
+
>
|
|
58
|
+
{{ item.label }}
|
|
59
|
+
</span>
|
|
60
|
+
</button>
|
|
61
|
+
</nav>
|
|
62
|
+
</template>
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { watch } from 'vue'
|
|
3
|
+
import MIcon from './MIcon.vue'
|
|
4
|
+
|
|
5
|
+
export interface DrawerItem {
|
|
6
|
+
value: string | number
|
|
7
|
+
label: string
|
|
8
|
+
icon?: string
|
|
9
|
+
badge?: string | number
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface DrawerSection {
|
|
14
|
+
title?: string
|
|
15
|
+
items: DrawerItem[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const props = withDefaults(defineProps<{
|
|
19
|
+
modelValue: boolean
|
|
20
|
+
selected?: string | number
|
|
21
|
+
sections: DrawerSection[]
|
|
22
|
+
title?: string
|
|
23
|
+
modal?: boolean
|
|
24
|
+
}>(), { modal: true })
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits<{
|
|
27
|
+
'update:modelValue': [boolean]
|
|
28
|
+
select: [string | number]
|
|
29
|
+
}>()
|
|
30
|
+
|
|
31
|
+
function close() { emit('update:modelValue', false) }
|
|
32
|
+
function select(item: DrawerItem) {
|
|
33
|
+
if (item.disabled) return
|
|
34
|
+
emit('select', item.value)
|
|
35
|
+
if (props.modal) close()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
watch(() => props.modelValue, (open) => {
|
|
39
|
+
if (open) document.body.style.overflow = 'hidden'
|
|
40
|
+
else document.body.style.overflow = ''
|
|
41
|
+
})
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<!-- Modal variant -->
|
|
46
|
+
<Teleport v-if="modal" to="body">
|
|
47
|
+
<Transition name="nd" :duration="{ enter: 300, leave: 280 }">
|
|
48
|
+
<div v-if="modelValue" class="fixed inset-0 z-[100] flex">
|
|
49
|
+
<!-- Scrim -->
|
|
50
|
+
<div class="nd-scrim absolute inset-0 bg-black/40" @click="close" />
|
|
51
|
+
|
|
52
|
+
<!-- Panel -->
|
|
53
|
+
<nav class="nd-panel relative flex h-full w-72 max-w-[85vw] flex-col bg-surface-container shadow-elevation-3">
|
|
54
|
+
<!-- Header -->
|
|
55
|
+
<div v-if="title || $slots.header" class="shrink-0 px-5 pt-6 pb-2">
|
|
56
|
+
<slot name="header">
|
|
57
|
+
<h2 class="text-title-small font-medium text-on-surface-variant">{{ title }}</h2>
|
|
58
|
+
</slot>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<!-- Sections -->
|
|
62
|
+
<div class="flex-1 overflow-y-auto px-3 py-2">
|
|
63
|
+
<template v-for="(section, si) in sections" :key="si">
|
|
64
|
+
<div v-if="si > 0" class="my-2 border-t border-outline-variant" />
|
|
65
|
+
<p v-if="section.title" class="px-4 pt-4 pb-2 text-title-small font-medium text-on-surface-variant">
|
|
66
|
+
{{ section.title }}
|
|
67
|
+
</p>
|
|
68
|
+
<button
|
|
69
|
+
v-for="item in section.items"
|
|
70
|
+
:key="item.value"
|
|
71
|
+
type="button"
|
|
72
|
+
class="flex w-full items-center gap-3 rounded-full px-4 py-3 text-left transition-colors focus-visible:outline-none"
|
|
73
|
+
:class="[
|
|
74
|
+
item.disabled
|
|
75
|
+
? 'cursor-not-allowed opacity-[0.38]'
|
|
76
|
+
: item.value === selected
|
|
77
|
+
? 'bg-secondary-container text-on-secondary-container'
|
|
78
|
+
: 'cursor-pointer text-on-surface-variant hover:bg-on-surface/8',
|
|
79
|
+
]"
|
|
80
|
+
:disabled="item.disabled"
|
|
81
|
+
@click="select(item)"
|
|
82
|
+
>
|
|
83
|
+
<MIcon v-if="item.icon" :name="item.icon" :size="24" />
|
|
84
|
+
<span class="flex-1 text-label-large font-medium">{{ item.label }}</span>
|
|
85
|
+
<span
|
|
86
|
+
v-if="item.badge != null"
|
|
87
|
+
class="text-label-medium text-on-surface-variant"
|
|
88
|
+
>
|
|
89
|
+
{{ item.badge }}
|
|
90
|
+
</span>
|
|
91
|
+
</button>
|
|
92
|
+
</template>
|
|
93
|
+
</div>
|
|
94
|
+
</nav>
|
|
95
|
+
</div>
|
|
96
|
+
</Transition>
|
|
97
|
+
</Teleport>
|
|
98
|
+
|
|
99
|
+
<!-- Standard (inline) variant -->
|
|
100
|
+
<nav
|
|
101
|
+
v-else
|
|
102
|
+
class="flex h-full w-72 flex-col border-r border-outline-variant bg-surface"
|
|
103
|
+
>
|
|
104
|
+
<div v-if="title || $slots.header" class="shrink-0 px-5 pt-6 pb-2">
|
|
105
|
+
<slot name="header">
|
|
106
|
+
<h2 class="text-title-small font-medium text-on-surface-variant">{{ title }}</h2>
|
|
107
|
+
</slot>
|
|
108
|
+
</div>
|
|
109
|
+
<div class="flex-1 overflow-y-auto px-3 py-2">
|
|
110
|
+
<template v-for="(section, si) in sections" :key="si">
|
|
111
|
+
<div v-if="si > 0" class="my-2 border-t border-outline-variant" />
|
|
112
|
+
<p v-if="section.title" class="px-4 pt-4 pb-2 text-title-small font-medium text-on-surface-variant">
|
|
113
|
+
{{ section.title }}
|
|
114
|
+
</p>
|
|
115
|
+
<button
|
|
116
|
+
v-for="item in section.items"
|
|
117
|
+
:key="item.value"
|
|
118
|
+
type="button"
|
|
119
|
+
class="flex w-full items-center gap-3 rounded-full px-4 py-3 text-left transition-colors focus-visible:outline-none"
|
|
120
|
+
:class="[
|
|
121
|
+
item.disabled
|
|
122
|
+
? 'cursor-not-allowed opacity-[0.38]'
|
|
123
|
+
: item.value === selected
|
|
124
|
+
? 'bg-secondary-container text-on-secondary-container'
|
|
125
|
+
: 'cursor-pointer text-on-surface-variant hover:bg-on-surface/8',
|
|
126
|
+
]"
|
|
127
|
+
:disabled="item.disabled"
|
|
128
|
+
@click="select(item)"
|
|
129
|
+
>
|
|
130
|
+
<MIcon v-if="item.icon" :name="item.icon" :size="24" />
|
|
131
|
+
<span class="flex-1 text-label-large font-medium">{{ item.label }}</span>
|
|
132
|
+
<span v-if="item.badge != null" class="text-label-medium text-on-surface-variant">
|
|
133
|
+
{{ item.badge }}
|
|
134
|
+
</span>
|
|
135
|
+
</button>
|
|
136
|
+
</template>
|
|
137
|
+
</div>
|
|
138
|
+
</nav>
|
|
139
|
+
</template>
|
|
140
|
+
|
|
141
|
+
<style scoped>
|
|
142
|
+
.nd-scrim {
|
|
143
|
+
transition: opacity 280ms ease;
|
|
144
|
+
}
|
|
145
|
+
.nd-enter-from .nd-scrim,
|
|
146
|
+
.nd-leave-to .nd-scrim {
|
|
147
|
+
opacity: 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.nd-panel {
|
|
151
|
+
transition: transform 300ms cubic-bezier(0.2, 0, 0, 1);
|
|
152
|
+
}
|
|
153
|
+
.nd-enter-from .nd-panel,
|
|
154
|
+
.nd-leave-to .nd-panel {
|
|
155
|
+
transform: translateX(-100%);
|
|
156
|
+
}
|
|
157
|
+
</style>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import MIcon from './MIcon.vue'
|
|
3
|
+
import MBadge from './MBadge.vue'
|
|
4
|
+
|
|
5
|
+
export interface NavRailItem {
|
|
6
|
+
value: string | number
|
|
7
|
+
label: string
|
|
8
|
+
icon: string
|
|
9
|
+
badge?: number
|
|
10
|
+
badgeDot?: boolean
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
withDefaults(defineProps<{
|
|
15
|
+
modelValue: string | number
|
|
16
|
+
items: NavRailItem[]
|
|
17
|
+
alignment?: 'top' | 'center' | 'bottom'
|
|
18
|
+
}>(), { alignment: 'top' })
|
|
19
|
+
|
|
20
|
+
defineEmits<{ 'update:modelValue': [string | number] }>()
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<nav class="flex h-full w-20 flex-col items-center border-r border-outline-variant bg-surface">
|
|
25
|
+
<!-- FAB slot -->
|
|
26
|
+
<div v-if="$slots.fab" class="flex shrink-0 items-center justify-center pt-3 pb-2">
|
|
27
|
+
<slot name="fab" />
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<!-- Items -->
|
|
31
|
+
<div
|
|
32
|
+
class="flex flex-1 flex-col items-center gap-1 py-3"
|
|
33
|
+
:class="{
|
|
34
|
+
'justify-start': alignment === 'top',
|
|
35
|
+
'justify-center': alignment === 'center',
|
|
36
|
+
'justify-end': alignment === 'bottom',
|
|
37
|
+
}"
|
|
38
|
+
>
|
|
39
|
+
<button
|
|
40
|
+
v-for="item in items"
|
|
41
|
+
:key="item.value"
|
|
42
|
+
type="button"
|
|
43
|
+
class="group flex w-full cursor-pointer flex-col items-center justify-center gap-1 px-3 py-2 focus-visible:outline-none"
|
|
44
|
+
:class="item.disabled ? 'cursor-not-allowed opacity-[0.38]' : ''"
|
|
45
|
+
:disabled="item.disabled"
|
|
46
|
+
@click="!item.disabled && $emit('update:modelValue', item.value)"
|
|
47
|
+
>
|
|
48
|
+
<!-- Pill indicator -->
|
|
49
|
+
<span
|
|
50
|
+
class="inline-flex h-8 items-center justify-center rounded-2xl transition-all duration-200"
|
|
51
|
+
:class="
|
|
52
|
+
item.value === modelValue
|
|
53
|
+
? 'w-14 bg-secondary-container text-on-secondary-container'
|
|
54
|
+
: 'w-14 bg-transparent text-on-surface-variant group-hover:bg-on-surface/8'
|
|
55
|
+
"
|
|
56
|
+
>
|
|
57
|
+
<MBadge v-if="item.badge != null" :count="item.badge">
|
|
58
|
+
<MIcon :name="item.icon" :size="24" />
|
|
59
|
+
</MBadge>
|
|
60
|
+
<MBadge v-else-if="item.badgeDot" dot>
|
|
61
|
+
<MIcon :name="item.icon" :size="24" />
|
|
62
|
+
</MBadge>
|
|
63
|
+
<MIcon v-else :name="item.icon" :size="24" />
|
|
64
|
+
</span>
|
|
65
|
+
|
|
66
|
+
<!-- Label -->
|
|
67
|
+
<span
|
|
68
|
+
class="max-w-[56px] truncate text-center text-label-medium"
|
|
69
|
+
:class="
|
|
70
|
+
item.value === modelValue
|
|
71
|
+
? 'font-bold text-on-surface'
|
|
72
|
+
: 'font-medium text-on-surface-variant'
|
|
73
|
+
"
|
|
74
|
+
>
|
|
75
|
+
{{ item.label }}
|
|
76
|
+
</span>
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
</nav>
|
|
80
|
+
</template>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import MIconButton from './MIconButton.vue'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{ page: number; perPage: number; total: number }>()
|
|
6
|
+
const emit = defineEmits<{ 'update:page': [number] }>()
|
|
7
|
+
|
|
8
|
+
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.perPage)))
|
|
9
|
+
|
|
10
|
+
const rangeLabel = computed(() => {
|
|
11
|
+
if (props.total === 0) return '0 resultados'
|
|
12
|
+
const from = (props.page - 1) * props.perPage + 1
|
|
13
|
+
const to = Math.min(props.page * props.perPage, props.total)
|
|
14
|
+
return `${from}-${to} de ${props.total}`
|
|
15
|
+
})
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<div class="flex flex-wrap items-center justify-between gap-4 text-body-medium text-on-surface-variant">
|
|
20
|
+
<span>{{ rangeLabel }}</span>
|
|
21
|
+
<div class="flex items-center gap-2">
|
|
22
|
+
<span>Página {{ page }} de {{ totalPages }}</span>
|
|
23
|
+
<MIconButton
|
|
24
|
+
icon="chevron_left"
|
|
25
|
+
label="Página anterior"
|
|
26
|
+
:disabled="page <= 1"
|
|
27
|
+
@click="emit('update:page', page - 1)"
|
|
28
|
+
/>
|
|
29
|
+
<MIconButton
|
|
30
|
+
icon="chevron_right"
|
|
31
|
+
label="Página siguiente"
|
|
32
|
+
:disabled="page >= totalPages"
|
|
33
|
+
@click="emit('update:page', page + 1)"
|
|
34
|
+
/>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|