@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,240 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|
3
|
+
import MIcon from './MIcon.vue'
|
|
4
|
+
|
|
5
|
+
export interface SplitButtonItem {
|
|
6
|
+
label: string
|
|
7
|
+
icon?: string
|
|
8
|
+
disabled?: boolean
|
|
9
|
+
onClick?: () => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(defineProps<{
|
|
13
|
+
label: string
|
|
14
|
+
icon?: string
|
|
15
|
+
variant?: 'filled' | 'tonal' | 'outlined' | 'elevated'
|
|
16
|
+
color?: 'primary' | 'secondary' | 'tertiary'
|
|
17
|
+
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
|
18
|
+
disabled?: boolean
|
|
19
|
+
items?: SplitButtonItem[]
|
|
20
|
+
}>(), {
|
|
21
|
+
variant: 'tonal',
|
|
22
|
+
color: 'primary',
|
|
23
|
+
size: 'sm',
|
|
24
|
+
disabled: false,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const emit = defineEmits<{ click: [MouseEvent] }>()
|
|
28
|
+
|
|
29
|
+
const open = ref(false)
|
|
30
|
+
const containerEl = ref<HTMLElement>()
|
|
31
|
+
const menuEl = ref<HTMLElement>()
|
|
32
|
+
const menuStyle = ref<Record<string, string>>({})
|
|
33
|
+
|
|
34
|
+
const colorStyles = computed(() => {
|
|
35
|
+
const map: Record<string, Record<string, { bg: string; text: string; hover: string; divider: string }>> = {
|
|
36
|
+
filled: {
|
|
37
|
+
primary: { bg: 'bg-primary', text: 'text-on-primary', hover: 'hover:brightness-110', divider: 'bg-on-primary/20' },
|
|
38
|
+
secondary: { bg: 'bg-secondary', text: 'text-on-secondary', hover: 'hover:brightness-110', divider: 'bg-on-secondary/20' },
|
|
39
|
+
tertiary: { bg: 'bg-tertiary', text: 'text-on-tertiary', hover: 'hover:brightness-110', divider: 'bg-on-tertiary/20' },
|
|
40
|
+
},
|
|
41
|
+
tonal: {
|
|
42
|
+
primary: { bg: 'bg-primary-container', text: 'text-on-primary-container', hover: 'hover:bg-primary-container/80', divider: 'bg-on-primary-container/20' },
|
|
43
|
+
secondary: { bg: 'bg-secondary-container', text: 'text-on-secondary-container', hover: 'hover:bg-secondary-container/80', divider: 'bg-on-secondary-container/20' },
|
|
44
|
+
tertiary: { bg: 'bg-tertiary-container', text: 'text-on-tertiary-container', hover: 'hover:bg-tertiary-container/80', divider: 'bg-on-tertiary-container/20' },
|
|
45
|
+
},
|
|
46
|
+
outlined: {
|
|
47
|
+
primary: { bg: 'bg-transparent', text: 'text-primary', hover: 'hover:bg-primary/8', divider: 'bg-outline' },
|
|
48
|
+
secondary: { bg: 'bg-transparent', text: 'text-secondary', hover: 'hover:bg-secondary/8', divider: 'bg-outline' },
|
|
49
|
+
tertiary: { bg: 'bg-transparent', text: 'text-tertiary', hover: 'hover:bg-tertiary/8', divider: 'bg-outline' },
|
|
50
|
+
},
|
|
51
|
+
elevated: {
|
|
52
|
+
primary: { bg: 'bg-surface-container-low', text: 'text-primary', hover: 'hover:bg-surface-container', divider: 'bg-outline-variant' },
|
|
53
|
+
secondary: { bg: 'bg-surface-container-low', text: 'text-secondary', hover: 'hover:bg-surface-container', divider: 'bg-outline-variant' },
|
|
54
|
+
tertiary: { bg: 'bg-surface-container-low', text: 'text-tertiary', hover: 'hover:bg-surface-container', divider: 'bg-outline-variant' },
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
return map[props.variant]?.[props.color] ?? map.tonal!.primary!
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const containerClasses = computed(() => {
|
|
61
|
+
return 'inline-flex items-center gap-0.5'
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const sizeConfig = computed(() => {
|
|
65
|
+
const map = {
|
|
66
|
+
xs: { h: 'h-8', w: 'w-8', px: 'px-3', text: 'text-label-medium', icon: 16, arrow: 16, r: 16, ri: 4 },
|
|
67
|
+
sm: { h: 'h-10', w: 'w-10', px: 'px-4', text: 'text-label-large', icon: 18, arrow: 18, r: 20, ri: 6 },
|
|
68
|
+
md: { h: 'h-14', w: 'w-14', px: 'px-5', text: 'text-title-medium', icon: 20, arrow: 20, r: 28, ri: 6 },
|
|
69
|
+
lg: { h: 'h-16', w: 'w-16', px: 'px-6', text: 'text-title-large', icon: 22, arrow: 22, r: 32, ri: 7 },
|
|
70
|
+
xl: { h: 'h-20', w: 'w-20', px: 'px-7', text: 'text-headline-small', icon: 24, arrow: 24, r: 40, ri: 8 },
|
|
71
|
+
}
|
|
72
|
+
return map[props.size] ?? map.sm
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
function computeMenuPos() {
|
|
76
|
+
if (!containerEl.value) return
|
|
77
|
+
const rect = containerEl.value.getBoundingClientRect()
|
|
78
|
+
const menuH = (props.items?.length ?? 0) * 44 + 16
|
|
79
|
+
const spaceBelow = window.innerHeight - rect.bottom - 8
|
|
80
|
+
const above = spaceBelow < menuH && rect.top > menuH
|
|
81
|
+
menuStyle.value = {
|
|
82
|
+
position: 'fixed',
|
|
83
|
+
zIndex: '500',
|
|
84
|
+
left: `${rect.left}px`,
|
|
85
|
+
minWidth: `${rect.width}px`,
|
|
86
|
+
...(above
|
|
87
|
+
? { bottom: `${window.innerHeight - rect.top + 4}px` }
|
|
88
|
+
: { top: `${rect.bottom + 4}px` }),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toggleMenu() {
|
|
93
|
+
if (props.disabled) return
|
|
94
|
+
if (!open.value) computeMenuPos()
|
|
95
|
+
open.value = !open.value
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function createRipple(event: PointerEvent) {
|
|
99
|
+
const button = event.currentTarget as HTMLElement
|
|
100
|
+
const rect = button.getBoundingClientRect()
|
|
101
|
+
const d = Math.max(rect.width, rect.height) * 2
|
|
102
|
+
const el = document.createElement('span')
|
|
103
|
+
el.className = 'm3-ripple'
|
|
104
|
+
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`
|
|
105
|
+
button.appendChild(el)
|
|
106
|
+
el.addEventListener('animationend', () => el.remove(), { once: true })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function selectItem(item: SplitButtonItem) {
|
|
110
|
+
if (item.disabled) return
|
|
111
|
+
item.onClick?.()
|
|
112
|
+
open.value = false
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function onOutside(e: MouseEvent) {
|
|
116
|
+
const t = e.target as Node
|
|
117
|
+
if (containerEl.value?.contains(t)) return
|
|
118
|
+
if (menuEl.value?.contains(t)) return
|
|
119
|
+
open.value = false
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function onScroll(e: Event) {
|
|
123
|
+
if (!open.value) return
|
|
124
|
+
if (menuEl.value?.contains(e.target as Node)) return
|
|
125
|
+
open.value = false
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
onMounted(() => {
|
|
129
|
+
document.addEventListener('mousedown', onOutside)
|
|
130
|
+
window.addEventListener('scroll', onScroll, true)
|
|
131
|
+
})
|
|
132
|
+
onUnmounted(() => {
|
|
133
|
+
document.removeEventListener('mousedown', onOutside)
|
|
134
|
+
window.removeEventListener('scroll', onScroll, true)
|
|
135
|
+
})
|
|
136
|
+
</script>
|
|
137
|
+
|
|
138
|
+
<template>
|
|
139
|
+
<div ref="containerEl" :class="containerClasses">
|
|
140
|
+
<!-- Main action -->
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
class="relative flex cursor-pointer items-center gap-2 overflow-hidden font-medium transition-colors"
|
|
144
|
+
:class="[
|
|
145
|
+
sizeConfig.h, sizeConfig.px, sizeConfig.text,
|
|
146
|
+
colorStyles.bg,
|
|
147
|
+
colorStyles.text,
|
|
148
|
+
colorStyles.hover,
|
|
149
|
+
variant === 'outlined' ? 'border border-outline' : '',
|
|
150
|
+
variant === 'elevated' ? 'shadow-elevation-1' : '',
|
|
151
|
+
disabled ? 'pointer-events-none opacity-[0.38]' : '',
|
|
152
|
+
]"
|
|
153
|
+
:style="{ borderRadius: `${sizeConfig.r}px ${sizeConfig.ri}px ${sizeConfig.ri}px ${sizeConfig.r}px` }"
|
|
154
|
+
:disabled="disabled"
|
|
155
|
+
@pointerdown="createRipple"
|
|
156
|
+
@click="emit('click', $event)"
|
|
157
|
+
>
|
|
158
|
+
<MIcon v-if="icon" :name="icon" :size="sizeConfig.icon" />
|
|
159
|
+
{{ label }}
|
|
160
|
+
</button>
|
|
161
|
+
|
|
162
|
+
<!-- Toggle -->
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
class="toggle-btn relative flex cursor-pointer items-center justify-center overflow-hidden"
|
|
166
|
+
:class="[
|
|
167
|
+
sizeConfig.h, sizeConfig.w,
|
|
168
|
+
colorStyles.bg,
|
|
169
|
+
colorStyles.text,
|
|
170
|
+
colorStyles.hover,
|
|
171
|
+
variant === 'outlined' ? 'border border-outline' : '',
|
|
172
|
+
variant === 'elevated' ? 'shadow-elevation-1' : '',
|
|
173
|
+
disabled ? 'pointer-events-none opacity-[0.38]' : '',
|
|
174
|
+
]"
|
|
175
|
+
:style="{ borderRadius: open ? `${sizeConfig.r}px` : `${sizeConfig.ri}px ${sizeConfig.r}px ${sizeConfig.r}px ${sizeConfig.ri}px` }"
|
|
176
|
+
:disabled="disabled"
|
|
177
|
+
@pointerdown="createRipple"
|
|
178
|
+
@click="toggleMenu"
|
|
179
|
+
>
|
|
180
|
+
<MIcon
|
|
181
|
+
name="arrow_drop_down"
|
|
182
|
+
:size="sizeConfig.arrow"
|
|
183
|
+
class="transition-transform duration-200"
|
|
184
|
+
:class="open ? 'rotate-180' : ''"
|
|
185
|
+
/>
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<!-- Dropdown menu -->
|
|
190
|
+
<Teleport to="body">
|
|
191
|
+
<Transition
|
|
192
|
+
enter-active-class="transition-[opacity,transform] duration-150"
|
|
193
|
+
enter-from-class="opacity-0 -translate-y-1 scale-[0.98]"
|
|
194
|
+
leave-active-class="transition-[opacity,transform] duration-100"
|
|
195
|
+
leave-to-class="opacity-0 -translate-y-1 scale-[0.98]"
|
|
196
|
+
>
|
|
197
|
+
<div
|
|
198
|
+
v-if="open && items?.length"
|
|
199
|
+
ref="menuEl"
|
|
200
|
+
class="overflow-hidden rounded-xl bg-surface-container py-2 shadow-elevation-2"
|
|
201
|
+
:style="menuStyle"
|
|
202
|
+
>
|
|
203
|
+
<button
|
|
204
|
+
v-for="(item, i) in items"
|
|
205
|
+
:key="i"
|
|
206
|
+
type="button"
|
|
207
|
+
class="flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left text-body-medium transition-colors"
|
|
208
|
+
:class="item.disabled ? 'cursor-not-allowed opacity-[0.38] text-on-surface' : 'text-on-surface hover:bg-on-surface/8'"
|
|
209
|
+
:disabled="item.disabled"
|
|
210
|
+
@click="selectItem(item)"
|
|
211
|
+
>
|
|
212
|
+
<MIcon v-if="item.icon" :name="item.icon" :size="18" class="shrink-0 text-on-surface-variant" />
|
|
213
|
+
{{ item.label }}
|
|
214
|
+
</button>
|
|
215
|
+
</div>
|
|
216
|
+
</Transition>
|
|
217
|
+
|
|
218
|
+
<!-- Custom content -->
|
|
219
|
+
<Transition
|
|
220
|
+
enter-active-class="transition-[opacity,transform] duration-150"
|
|
221
|
+
enter-from-class="opacity-0 -translate-y-1 scale-[0.98]"
|
|
222
|
+
leave-active-class="transition-[opacity,transform] duration-100"
|
|
223
|
+
leave-to-class="opacity-0 -translate-y-1 scale-[0.98]"
|
|
224
|
+
>
|
|
225
|
+
<div
|
|
226
|
+
v-if="open && !items?.length && $slots.menu"
|
|
227
|
+
ref="menuEl"
|
|
228
|
+
:style="menuStyle"
|
|
229
|
+
>
|
|
230
|
+
<slot name="menu" :close="() => open = false" />
|
|
231
|
+
</div>
|
|
232
|
+
</Transition>
|
|
233
|
+
</Teleport>
|
|
234
|
+
</template>
|
|
235
|
+
|
|
236
|
+
<style scoped>
|
|
237
|
+
.toggle-btn {
|
|
238
|
+
transition: border-radius 250ms ease, background-color 150ms ease;
|
|
239
|
+
}
|
|
240
|
+
</style>
|
package/src/components/MTabs.vue
CHANGED
|
@@ -17,24 +17,55 @@ const props = withDefaults(defineProps<{
|
|
|
17
17
|
|
|
18
18
|
const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
|
|
19
19
|
|
|
20
|
-
// Primary tab: refs for indicator position
|
|
21
20
|
const tabEls = ref<HTMLElement[]>([])
|
|
22
21
|
const indicatorLeft = ref(0)
|
|
23
22
|
const indicatorWidth = ref(0)
|
|
23
|
+
const stretching = ref(false)
|
|
24
|
+
let stretchTimer: ReturnType<typeof setTimeout> | null = null
|
|
24
25
|
|
|
25
|
-
function
|
|
26
|
+
function getTabRect(index: number) {
|
|
27
|
+
const el = tabEls.value[index]
|
|
28
|
+
if (!el) return null
|
|
29
|
+
return { left: el.offsetLeft, width: el.offsetWidth }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function updateIndicator(animate = true) {
|
|
26
33
|
nextTick(() => {
|
|
27
34
|
const idx = props.tabs.findIndex((t) => t.value === props.modelValue)
|
|
28
|
-
const
|
|
29
|
-
if (!
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
const rect = getTabRect(idx)
|
|
36
|
+
if (!rect) return
|
|
37
|
+
|
|
38
|
+
if (props.variant === 'primary') {
|
|
39
|
+
const inset = Math.min(rect.width * 0.2, 16)
|
|
40
|
+
if (animate && indicatorWidth.value > 0) {
|
|
41
|
+
const oldLeft = indicatorLeft.value
|
|
42
|
+
const oldRight = oldLeft + indicatorWidth.value
|
|
43
|
+
const newLeft = rect.left + inset
|
|
44
|
+
const newRight = rect.left + rect.width - inset
|
|
45
|
+
stretching.value = true
|
|
46
|
+
indicatorLeft.value = Math.min(oldLeft, newLeft)
|
|
47
|
+
indicatorWidth.value = Math.max(oldRight, newRight) - indicatorLeft.value
|
|
48
|
+
if (stretchTimer) clearTimeout(stretchTimer)
|
|
49
|
+
stretchTimer = setTimeout(() => {
|
|
50
|
+
stretching.value = false
|
|
51
|
+
indicatorLeft.value = newLeft
|
|
52
|
+
indicatorWidth.value = newRight - newLeft
|
|
53
|
+
}, 150)
|
|
54
|
+
} else {
|
|
55
|
+
const insetLeft = rect.left + inset
|
|
56
|
+
indicatorLeft.value = insetLeft
|
|
57
|
+
indicatorWidth.value = rect.width - inset * 2
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
indicatorLeft.value = rect.left
|
|
61
|
+
indicatorWidth.value = rect.width
|
|
62
|
+
}
|
|
32
63
|
})
|
|
33
64
|
}
|
|
34
65
|
|
|
35
|
-
onMounted(updateIndicator)
|
|
36
|
-
watch(() => props.modelValue, updateIndicator)
|
|
37
|
-
watch(() => props.tabs, updateIndicator, { deep: true })
|
|
66
|
+
onMounted(() => updateIndicator(false))
|
|
67
|
+
watch(() => props.modelValue, () => updateIndicator(true))
|
|
68
|
+
watch(() => props.tabs, () => updateIndicator(false), { deep: true })
|
|
38
69
|
|
|
39
70
|
function select(tab: Tab) {
|
|
40
71
|
if (!tab.disabled) emit('update:modelValue', tab.value)
|
|
@@ -42,22 +73,22 @@ function select(tab: Tab) {
|
|
|
42
73
|
</script>
|
|
43
74
|
|
|
44
75
|
<template>
|
|
45
|
-
<!-- ── Primary:
|
|
46
|
-
<div v-if="variant === 'primary'" class="
|
|
47
|
-
<div class="flex overflow-x-auto" style="scrollbar-width: none">
|
|
76
|
+
<!-- ── Primary: sliding short indicator with stretch ──────────────── -->
|
|
77
|
+
<div v-if="variant === 'primary'" class="border-b border-outline-variant">
|
|
78
|
+
<div class="relative flex overflow-x-auto" style="scrollbar-width: none">
|
|
48
79
|
<button
|
|
49
80
|
v-for="tab in tabs"
|
|
50
81
|
:key="tab.value"
|
|
51
82
|
:ref="(el) => { if (el) tabEls[tabs.indexOf(tab)] = el as HTMLElement }"
|
|
52
83
|
type="button"
|
|
53
|
-
class="relative flex
|
|
84
|
+
class="relative flex shrink-0 flex-col items-center justify-center gap-1 px-6 text-label-large transition-colors duration-150 focus-visible:outline-none"
|
|
54
85
|
:class="[
|
|
86
|
+
tab.icon ? 'h-14 pb-1.5' : 'h-12 pb-1.5',
|
|
55
87
|
tab.value === modelValue
|
|
56
88
|
? 'text-primary'
|
|
57
89
|
: tab.disabled
|
|
58
90
|
? 'cursor-not-allowed text-on-surface/38'
|
|
59
91
|
: 'cursor-pointer text-on-surface-variant hover:text-on-surface',
|
|
60
|
-
tab.icon ? 'pt-2 pb-1' : '',
|
|
61
92
|
]"
|
|
62
93
|
:disabled="tab.disabled"
|
|
63
94
|
@click="select(tab)"
|
|
@@ -65,33 +96,45 @@ function select(tab: Tab) {
|
|
|
65
96
|
<MIcon v-if="tab.icon" :name="tab.icon" :size="20" />
|
|
66
97
|
<span>{{ tab.label }}</span>
|
|
67
98
|
</button>
|
|
99
|
+
<div
|
|
100
|
+
class="absolute bottom-0 h-[3px] rounded-t-full bg-primary"
|
|
101
|
+
:style="{
|
|
102
|
+
left: `${indicatorLeft}px`,
|
|
103
|
+
width: `${indicatorWidth}px`,
|
|
104
|
+
transition: stretching
|
|
105
|
+
? 'left 150ms cubic-bezier(0.4, 0, 0.2, 1), width 150ms cubic-bezier(0.4, 0, 0.2, 1)'
|
|
106
|
+
: 'left 250ms cubic-bezier(0.34, 1.56, 0.64, 1), width 250ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
|
107
|
+
}"
|
|
108
|
+
/>
|
|
68
109
|
</div>
|
|
69
|
-
<!-- Sliding indicator -->
|
|
70
|
-
<div
|
|
71
|
-
class="absolute bottom-0 h-[3px] rounded-t-sm bg-primary transition-[left,width] duration-200 ease-[cubic-bezier(0.2,0,0,1)]"
|
|
72
|
-
:style="{ left: `${indicatorLeft}px`, width: `${indicatorWidth}px` }"
|
|
73
|
-
/>
|
|
74
110
|
</div>
|
|
75
111
|
|
|
76
|
-
<!-- ── Secondary:
|
|
77
|
-
<div v-else class="
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
? '
|
|
88
|
-
:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
112
|
+
<!-- ── Secondary: full-width underline indicator ─────────────────── -->
|
|
113
|
+
<div v-else class="border-b border-outline-variant">
|
|
114
|
+
<div class="relative flex overflow-x-auto" style="scrollbar-width: none">
|
|
115
|
+
<button
|
|
116
|
+
v-for="tab in tabs"
|
|
117
|
+
:key="tab.value"
|
|
118
|
+
:ref="(el) => { if (el) tabEls[tabs.indexOf(tab)] = el as HTMLElement }"
|
|
119
|
+
type="button"
|
|
120
|
+
class="relative flex h-12 shrink-0 items-center justify-center gap-2 px-6 text-label-large transition-colors duration-150 focus-visible:outline-none"
|
|
121
|
+
:class="
|
|
122
|
+
tab.value === modelValue
|
|
123
|
+
? 'text-on-surface'
|
|
124
|
+
: tab.disabled
|
|
125
|
+
? 'cursor-not-allowed text-on-surface/38'
|
|
126
|
+
: 'cursor-pointer text-on-surface-variant hover:text-on-surface'
|
|
127
|
+
"
|
|
128
|
+
:disabled="tab.disabled"
|
|
129
|
+
@click="select(tab)"
|
|
130
|
+
>
|
|
131
|
+
<MIcon v-if="tab.icon" :name="tab.icon" :size="18" />
|
|
132
|
+
{{ tab.label }}
|
|
133
|
+
</button>
|
|
134
|
+
<div
|
|
135
|
+
class="absolute bottom-0 h-[2px] bg-primary transition-[left,width] duration-200 ease-[cubic-bezier(0.2,0,0,1)]"
|
|
136
|
+
:style="{ left: `${indicatorLeft}px`, width: `${indicatorWidth}px` }"
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
96
139
|
</div>
|
|
97
140
|
</template>
|
|
@@ -91,6 +91,14 @@ function onPaste(e: ClipboardEvent) {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
function onBlur() {
|
|
95
|
+
if (inputValue.value.trim()) {
|
|
96
|
+
addTag(inputValue.value)
|
|
97
|
+
inputValue.value = ''
|
|
98
|
+
}
|
|
99
|
+
focused.value = false
|
|
100
|
+
}
|
|
101
|
+
|
|
94
102
|
function focusInput() {
|
|
95
103
|
if (!props.disabled) {
|
|
96
104
|
inputEl.value?.focus()
|
|
@@ -191,6 +199,8 @@ const labelClasses = computed(() => {
|
|
|
191
199
|
ref="inputEl"
|
|
192
200
|
:value="inputValue"
|
|
193
201
|
type="text"
|
|
202
|
+
enterkeyhint="done"
|
|
203
|
+
autocomplete="off"
|
|
194
204
|
class="min-w-[60px] flex-1 bg-transparent text-body-large text-on-surface outline-none placeholder:text-on-surface-variant"
|
|
195
205
|
:placeholder="!hasValue && (!label || focused) ? placeholder : ''"
|
|
196
206
|
:disabled="disabled"
|
|
@@ -199,7 +209,7 @@ const labelClasses = computed(() => {
|
|
|
199
209
|
@keydown="onKeydown"
|
|
200
210
|
@paste="onPaste"
|
|
201
211
|
@focus="focused = true"
|
|
202
|
-
@blur="
|
|
212
|
+
@blur="onBlur"
|
|
203
213
|
/>
|
|
204
214
|
</div>
|
|
205
215
|
|
|
@@ -92,10 +92,7 @@ function onOut(e: MouseEvent) {
|
|
|
92
92
|
function onScroll(e: Event) {
|
|
93
93
|
if (!open.value) return
|
|
94
94
|
if (panelEl.value?.contains(e.target as Node)) return
|
|
95
|
-
|
|
96
|
-
const rect = triggerEl.value.getBoundingClientRect()
|
|
97
|
-
if (rect.bottom < 0 || rect.top > window.innerHeight) { open.value = false; return }
|
|
98
|
-
computeDropPos()
|
|
95
|
+
open.value = false
|
|
99
96
|
}
|
|
100
97
|
|
|
101
98
|
watch(open, (v) => {
|