@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,158 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import MIcon from './MIcon.vue'
|
|
4
|
+
|
|
5
|
+
export interface ButtonGroupOption {
|
|
6
|
+
value: unknown
|
|
7
|
+
label?: string
|
|
8
|
+
icon?: string
|
|
9
|
+
disabled?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(defineProps<{
|
|
13
|
+
modelValue: unknown
|
|
14
|
+
options: ButtonGroupOption[]
|
|
15
|
+
variant?: 'standard' | 'connected'
|
|
16
|
+
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
|
17
|
+
multiSelect?: boolean
|
|
18
|
+
selectionRequired?: boolean
|
|
19
|
+
color?: 'primary' | 'secondary' | 'tertiary'
|
|
20
|
+
}>(), {
|
|
21
|
+
variant: 'standard',
|
|
22
|
+
size: 'sm',
|
|
23
|
+
multiSelect: false,
|
|
24
|
+
selectionRequired: false,
|
|
25
|
+
color: 'primary',
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const emit = defineEmits<{ 'update:modelValue': [unknown] }>()
|
|
29
|
+
|
|
30
|
+
function eq(a: unknown, b: unknown): boolean {
|
|
31
|
+
if (a === b) return true
|
|
32
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a == null || b == null) return false
|
|
33
|
+
return JSON.stringify(a) === JSON.stringify(b)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isSelected(value: unknown) {
|
|
37
|
+
if (props.modelValue == null) return false
|
|
38
|
+
if (Array.isArray(props.modelValue)) return props.modelValue.some(v => eq(v, value))
|
|
39
|
+
return eq(props.modelValue, value)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toggle(opt: ButtonGroupOption) {
|
|
43
|
+
if (opt.disabled) return
|
|
44
|
+
if (props.multiSelect) {
|
|
45
|
+
const arr = Array.isArray(props.modelValue) ? [...props.modelValue] : props.modelValue != null ? [props.modelValue] : []
|
|
46
|
+
const idx = arr.findIndex(v => eq(v, opt.value))
|
|
47
|
+
if (idx >= 0) {
|
|
48
|
+
if (props.selectionRequired && arr.length <= 1) return
|
|
49
|
+
arr.splice(idx, 1)
|
|
50
|
+
} else {
|
|
51
|
+
arr.push(opt.value)
|
|
52
|
+
}
|
|
53
|
+
emit('update:modelValue', arr)
|
|
54
|
+
} else {
|
|
55
|
+
if (isSelected(opt.value) && !props.selectionRequired) {
|
|
56
|
+
emit('update:modelValue', null)
|
|
57
|
+
} else {
|
|
58
|
+
emit('update:modelValue', opt.value)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sizeMap = {
|
|
64
|
+
xs: { h: 'h-8', px: 'px-3', text: 'text-label-medium', icon: 16, check: 14 },
|
|
65
|
+
sm: { h: 'h-10', px: 'px-4', text: 'text-label-large', icon: 18, check: 16 },
|
|
66
|
+
md: { h: 'h-14', px: 'px-6', text: 'text-title-medium', icon: 20, check: 18 },
|
|
67
|
+
lg: { h: 'h-16', px: 'px-7', text: 'text-title-large', icon: 22, check: 20 },
|
|
68
|
+
xl: { h: 'h-20', px: 'px-8', text: 'text-headline-small', icon: 24, check: 22 },
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const s = computed(() => sizeMap[props.size] ?? sizeMap.sm)
|
|
72
|
+
|
|
73
|
+
const fullRadius = computed(() => {
|
|
74
|
+
const hMap = { xs: '16px', sm: '20px', md: '28px', lg: '32px', xl: '40px' }
|
|
75
|
+
return hMap[props.size] ?? '20px'
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const transitionMs = computed(() => {
|
|
79
|
+
const msMap = { xs: 350, sm: 300, md: 250, lg: 200, xl: 180 }
|
|
80
|
+
return msMap[props.size] ?? 250
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const selectedColors = computed(() => {
|
|
84
|
+
const map = {
|
|
85
|
+
primary: 'bg-primary text-on-primary',
|
|
86
|
+
secondary: 'bg-secondary text-on-secondary',
|
|
87
|
+
tertiary: 'bg-tertiary text-on-tertiary',
|
|
88
|
+
}
|
|
89
|
+
return map[props.color] ?? map.primary
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const unselectedColors = 'bg-surface-container-highest text-on-surface'
|
|
93
|
+
|
|
94
|
+
function createRipple(event: PointerEvent) {
|
|
95
|
+
const button = event.currentTarget as HTMLElement
|
|
96
|
+
const rect = button.getBoundingClientRect()
|
|
97
|
+
const d = Math.max(rect.width, rect.height) * 2
|
|
98
|
+
const el = document.createElement('span')
|
|
99
|
+
el.className = 'm3-ripple'
|
|
100
|
+
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`
|
|
101
|
+
button.appendChild(el)
|
|
102
|
+
el.addEventListener('animationend', () => el.remove(), { once: true })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function btnStyle(i: number, selected: boolean) {
|
|
106
|
+
const count = props.options.length
|
|
107
|
+
const full = fullRadius.value
|
|
108
|
+
const inner = '6px'
|
|
109
|
+
let tl: string, tr: string, br: string, bl: string
|
|
110
|
+
if (props.variant === 'standard' || selected) {
|
|
111
|
+
tl = tr = br = bl = full
|
|
112
|
+
} else {
|
|
113
|
+
tl = bl = i === 0 ? full : inner
|
|
114
|
+
tr = br = i === count - 1 ? full : inner
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
borderTopLeftRadius: tl,
|
|
118
|
+
borderTopRightRadius: tr,
|
|
119
|
+
borderBottomRightRadius: br,
|
|
120
|
+
borderBottomLeftRadius: bl,
|
|
121
|
+
transition: `border-top-left-radius ${transitionMs.value}ms ease, border-top-right-radius ${transitionMs.value}ms ease, border-bottom-right-radius ${transitionMs.value}ms ease, border-bottom-left-radius ${transitionMs.value}ms ease, background-color ${transitionMs.value}ms ease, color ${transitionMs.value}ms ease`,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
</script>
|
|
125
|
+
|
|
126
|
+
<template>
|
|
127
|
+
<div
|
|
128
|
+
class="inline-flex"
|
|
129
|
+
:class="variant === 'standard' ? 'gap-1.5' : 'gap-0.5'"
|
|
130
|
+
role="group"
|
|
131
|
+
>
|
|
132
|
+
<button
|
|
133
|
+
v-for="(opt, i) in options"
|
|
134
|
+
:key="String(opt.value)"
|
|
135
|
+
type="button"
|
|
136
|
+
class="btn-group-item relative inline-flex items-center justify-center gap-2 overflow-hidden font-medium"
|
|
137
|
+
:class="[
|
|
138
|
+
s.h, s.px, s.text,
|
|
139
|
+
opt.disabled ? 'cursor-not-allowed opacity-[0.38]' : 'cursor-pointer',
|
|
140
|
+
isSelected(opt.value) ? selectedColors : unselectedColors,
|
|
141
|
+
]"
|
|
142
|
+
:style="btnStyle(i, isSelected(opt.value))"
|
|
143
|
+
:disabled="opt.disabled"
|
|
144
|
+
:aria-pressed="isSelected(opt.value)"
|
|
145
|
+
@pointerdown="createRipple"
|
|
146
|
+
@click="toggle(opt)"
|
|
147
|
+
>
|
|
148
|
+
<MIcon
|
|
149
|
+
v-if="isSelected(opt.value)"
|
|
150
|
+
name="check"
|
|
151
|
+
:size="s.check"
|
|
152
|
+
/>
|
|
153
|
+
<MIcon v-else-if="opt.icon" :name="opt.icon" :size="s.icon" />
|
|
154
|
+
<span v-if="opt.label">{{ opt.label }}</span>
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
</template>
|
|
158
|
+
|
|
@@ -1,87 +1,213 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref, computed,
|
|
2
|
+
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
|
3
3
|
import MIcon from './MIcon.vue'
|
|
4
4
|
|
|
5
5
|
export interface CarouselItem {
|
|
6
6
|
src: string
|
|
7
7
|
alt?: string
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
label?: string
|
|
9
|
+
supportingText?: string
|
|
10
|
+
ratio?: number
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
const props = withDefaults(
|
|
13
14
|
defineProps<{
|
|
14
15
|
items: CarouselItem[]
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
layout?: 'hero' | 'multi-browse' | 'uncontained'
|
|
17
|
+
height?: string
|
|
17
18
|
showArrows?: boolean
|
|
18
19
|
showIndicators?: boolean
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
autoplay?: boolean
|
|
21
|
+
interval?: number
|
|
22
|
+
gap?: number
|
|
23
|
+
visibleItems?: number
|
|
24
|
+
animated?: boolean
|
|
21
25
|
}>(),
|
|
22
26
|
{
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
layout: 'hero',
|
|
28
|
+
height: '320px',
|
|
25
29
|
showArrows: true,
|
|
26
30
|
showIndicators: true,
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
autoplay: false,
|
|
32
|
+
interval: 5000,
|
|
33
|
+
gap: 8,
|
|
34
|
+
visibleItems: 3,
|
|
35
|
+
animated: false,
|
|
29
36
|
},
|
|
30
37
|
)
|
|
31
38
|
|
|
32
39
|
const emit = defineEmits<{ change: [index: number] }>()
|
|
33
40
|
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
const trackEl = ref<HTMLElement>()
|
|
42
|
+
const itemEls = ref<HTMLElement[]>([])
|
|
43
|
+
const activeIndex = ref(0)
|
|
44
|
+
const scrollPos = ref(0)
|
|
45
|
+
const isPageBased = computed(() => props.layout === 'multi-browse' || props.layout === 'uncontained')
|
|
46
|
+
|
|
47
|
+
const totalPages = computed(() => {
|
|
48
|
+
if (!isPageBased.value) return props.items.length
|
|
49
|
+
if (!trackEl.value) return 1
|
|
50
|
+
const track = trackEl.value
|
|
51
|
+
return Math.max(1, Math.ceil(track.scrollWidth / track.clientWidth))
|
|
52
|
+
})
|
|
39
53
|
|
|
40
|
-
const
|
|
41
|
-
|
|
54
|
+
const activePage = computed(() => {
|
|
55
|
+
if (!isPageBased.value) return activeIndex.value
|
|
56
|
+
if (!trackEl.value) return 0
|
|
57
|
+
const track = trackEl.value
|
|
58
|
+
if (track.scrollWidth <= track.clientWidth) return 0
|
|
59
|
+
const _ = scrollPos.value
|
|
60
|
+
return Math.round(track.scrollLeft / track.clientWidth)
|
|
61
|
+
})
|
|
62
|
+
const itemTransforms = ref<string[]>([])
|
|
63
|
+
const imageTransforms = ref<string[]>([])
|
|
64
|
+
let autoTimer: ReturnType<typeof setInterval> | null = null
|
|
65
|
+
let rafId = 0
|
|
66
|
+
let scrollTimeout: ReturnType<typeof setTimeout> | null = null
|
|
42
67
|
|
|
43
|
-
function
|
|
44
|
-
if (
|
|
45
|
-
direction.value = index > current.value ? 'right' : 'left'
|
|
46
|
-
transitioning.value = true
|
|
47
|
-
current.value = ((index % total.value) + total.value) % total.value
|
|
48
|
-
emit('change', current.value)
|
|
68
|
+
function setItemRef(el: any, i: number) {
|
|
69
|
+
if (el) itemEls.value[i] = el as HTMLElement
|
|
49
70
|
}
|
|
50
71
|
|
|
51
|
-
|
|
52
|
-
|
|
72
|
+
// ── Scroll handling (only transforms, never layout) ────────────
|
|
73
|
+
function onScroll() {
|
|
74
|
+
if (rafId) return
|
|
75
|
+
rafId = requestAnimationFrame(() => {
|
|
76
|
+
rafId = 0
|
|
77
|
+
updateTransforms()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
if (scrollTimeout) clearTimeout(scrollTimeout)
|
|
53
81
|
}
|
|
54
82
|
|
|
55
|
-
function
|
|
56
|
-
|
|
83
|
+
function updateTransforms() {
|
|
84
|
+
if (!trackEl.value) return
|
|
85
|
+
const track = trackEl.value
|
|
86
|
+
scrollPos.value = track.scrollLeft
|
|
87
|
+
const trackRect = track.getBoundingClientRect()
|
|
88
|
+
const trackW = trackRect.width
|
|
89
|
+
const centerX = trackRect.left + trackW / 2
|
|
90
|
+
const transforms: string[] = []
|
|
91
|
+
const imgTransforms: string[] = []
|
|
92
|
+
let closest = 0
|
|
93
|
+
let closestDist = Infinity
|
|
94
|
+
|
|
95
|
+
const refX = props.layout === 'multi-browse' ? trackRect.left : centerX
|
|
96
|
+
|
|
97
|
+
for (let i = 0; i < itemEls.value.length; i++) {
|
|
98
|
+
const el = itemEls.value[i]
|
|
99
|
+
if (!el) { transforms.push(''); imgTransforms.push(''); continue }
|
|
100
|
+
const rect = el.getBoundingClientRect()
|
|
101
|
+
const itemCenterX = rect.left + rect.width / 2
|
|
102
|
+
const dist = props.layout === 'multi-browse'
|
|
103
|
+
? Math.abs(rect.left - refX)
|
|
104
|
+
: Math.abs(itemCenterX - centerX)
|
|
105
|
+
const norm = Math.min(dist / (trackW * 0.5), 1)
|
|
106
|
+
|
|
107
|
+
if (props.layout === 'hero') {
|
|
108
|
+
imgTransforms.push(`translateX(${(itemCenterX - centerX) * -0.08}px) scale(1.1)`)
|
|
109
|
+
transforms.push('')
|
|
110
|
+
} else if (props.layout === 'uncontained' && props.animated) {
|
|
111
|
+
const s = 1 - norm * 0.12
|
|
112
|
+
transforms.push(`scale(${s})`)
|
|
113
|
+
imgTransforms.push('')
|
|
114
|
+
} else {
|
|
115
|
+
transforms.push('')
|
|
116
|
+
imgTransforms.push('')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (dist < closestDist) {
|
|
120
|
+
closestDist = dist
|
|
121
|
+
closest = i
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
itemTransforms.value = transforms
|
|
126
|
+
imageTransforms.value = imgTransforms
|
|
127
|
+
|
|
128
|
+
if (closest !== activeIndex.value) {
|
|
129
|
+
activeIndex.value = closest
|
|
130
|
+
emit('change', closest)
|
|
131
|
+
}
|
|
57
132
|
}
|
|
58
133
|
|
|
59
|
-
|
|
60
|
-
|
|
134
|
+
// ── Fixed item widths (never change during scroll) ─────────────
|
|
135
|
+
function itemWidth(i: number) {
|
|
136
|
+
switch (props.layout) {
|
|
137
|
+
case 'hero':
|
|
138
|
+
return `calc(100% - 48px)`
|
|
139
|
+
case 'multi-browse':
|
|
140
|
+
return `calc(${100 / props.visibleItems}% - ${props.gap * (props.visibleItems - 1) / props.visibleItems}px)`
|
|
141
|
+
case 'uncontained':
|
|
142
|
+
return `${(props.items[i]?.ratio ?? 1) * 260}px`
|
|
143
|
+
default:
|
|
144
|
+
return '80%'
|
|
145
|
+
}
|
|
61
146
|
}
|
|
62
147
|
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
148
|
+
function itemStyle(i: number) {
|
|
149
|
+
const style: Record<string, string> = {
|
|
150
|
+
flexShrink: '0',
|
|
151
|
+
width: itemWidth(i),
|
|
152
|
+
}
|
|
153
|
+
const t = itemTransforms.value[i]
|
|
154
|
+
if (t) {
|
|
155
|
+
style.transform = t
|
|
156
|
+
style.transition = 'transform 120ms ease-out'
|
|
67
157
|
}
|
|
158
|
+
return style
|
|
68
159
|
}
|
|
69
160
|
|
|
70
|
-
function
|
|
71
|
-
|
|
161
|
+
function imgStyle(i: number) {
|
|
162
|
+
const t = imageTransforms.value[i]
|
|
163
|
+
if (!t) return {}
|
|
164
|
+
return { transform: t }
|
|
72
165
|
}
|
|
73
166
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
167
|
+
// ── Navigation ─────────────────────────────────────────────────
|
|
168
|
+
function scrollToItem(index: number) {
|
|
169
|
+
const el = itemEls.value[index]
|
|
170
|
+
if (!el || !trackEl.value) return
|
|
171
|
+
const track = trackEl.value
|
|
172
|
+
const scrollLeft = el.offsetLeft - (track.clientWidth - el.offsetWidth) / 2
|
|
173
|
+
track.scrollTo({ left: scrollLeft, behavior: 'smooth' })
|
|
77
174
|
}
|
|
78
175
|
|
|
79
|
-
function
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
176
|
+
function scrollToPage(page: number) {
|
|
177
|
+
if (!trackEl.value) return
|
|
178
|
+
const track = trackEl.value
|
|
179
|
+
track.scrollTo({ left: page * track.clientWidth, behavior: 'smooth' })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function next() {
|
|
183
|
+
if (isPageBased.value) {
|
|
184
|
+
if (!trackEl.value) return
|
|
185
|
+
trackEl.value.scrollBy({ left: trackEl.value.clientWidth, behavior: 'smooth' })
|
|
186
|
+
} else {
|
|
187
|
+
const i = activeIndex.value < props.items.length - 1 ? activeIndex.value + 1 : 0
|
|
188
|
+
scrollToItem(i)
|
|
83
189
|
}
|
|
84
|
-
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function prev() {
|
|
193
|
+
if (isPageBased.value) {
|
|
194
|
+
if (!trackEl.value) return
|
|
195
|
+
trackEl.value.scrollBy({ left: -trackEl.value.clientWidth, behavior: 'smooth' })
|
|
196
|
+
} else {
|
|
197
|
+
const i = activeIndex.value > 0 ? activeIndex.value - 1 : props.items.length - 1
|
|
198
|
+
scrollToItem(i)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function startAutoplay() {
|
|
203
|
+
stopAutoplay()
|
|
204
|
+
if (props.autoplay && props.items.length > 1) {
|
|
205
|
+
autoTimer = setInterval(next, props.interval)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function stopAutoplay() {
|
|
210
|
+
if (autoTimer) { clearInterval(autoTimer); autoTimer = null }
|
|
85
211
|
}
|
|
86
212
|
|
|
87
213
|
function onKeydown(e: KeyboardEvent) {
|
|
@@ -89,58 +215,65 @@ function onKeydown(e: KeyboardEvent) {
|
|
|
89
215
|
else if (e.key === 'ArrowLeft') prev()
|
|
90
216
|
}
|
|
91
217
|
|
|
218
|
+
const trackPadding = computed(() => {
|
|
219
|
+
if (props.layout === 'hero') return '0 24px'
|
|
220
|
+
if (props.layout === 'uncontained') return '0 24px'
|
|
221
|
+
return '0'
|
|
222
|
+
})
|
|
223
|
+
|
|
92
224
|
watch(() => props.autoplay, startAutoplay)
|
|
93
|
-
onMounted(
|
|
94
|
-
|
|
225
|
+
onMounted(() => {
|
|
226
|
+
startAutoplay()
|
|
227
|
+
nextTick(() => updateTransforms())
|
|
228
|
+
})
|
|
229
|
+
onBeforeUnmount(() => {
|
|
230
|
+
stopAutoplay()
|
|
231
|
+
if (rafId) cancelAnimationFrame(rafId)
|
|
232
|
+
})
|
|
95
233
|
|
|
96
|
-
defineExpose({ next, prev,
|
|
234
|
+
defineExpose({ next, prev, scrollToItem, scrollToPage })
|
|
97
235
|
</script>
|
|
98
236
|
|
|
99
237
|
<template>
|
|
100
238
|
<div
|
|
101
|
-
class="group relative overflow-hidden
|
|
102
|
-
:class="rounded ? 'rounded-2xl' : ''"
|
|
103
|
-
:style="{ aspectRatio }"
|
|
239
|
+
class="group relative overflow-hidden"
|
|
104
240
|
tabindex="0"
|
|
105
241
|
@keydown="onKeydown"
|
|
106
|
-
@touchstart.passive="onTouchStart"
|
|
107
|
-
@touchend.passive="onTouchEnd"
|
|
108
242
|
@mouseenter="stopAutoplay"
|
|
109
243
|
@mouseleave="startAutoplay"
|
|
110
244
|
>
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
@
|
|
117
|
-
@after-leave="onTransitionEnd"
|
|
245
|
+
<div
|
|
246
|
+
ref="trackEl"
|
|
247
|
+
class="carousel-track flex overflow-x-auto"
|
|
248
|
+
:class="layout === 'multi-browse' ? 'snap-start' : 'snap-center'"
|
|
249
|
+
:style="{ height, gap: `${gap}px`, padding: trackPadding }"
|
|
250
|
+
@scroll.passive="onScroll"
|
|
118
251
|
>
|
|
119
252
|
<div
|
|
120
253
|
v-for="(item, i) in items"
|
|
121
|
-
v-show="i === current"
|
|
122
254
|
:key="i"
|
|
123
|
-
|
|
255
|
+
:ref="(el) => setItemRef(el, i)"
|
|
256
|
+
class="relative overflow-hidden rounded-2xl"
|
|
257
|
+
:style="itemStyle(i)"
|
|
124
258
|
>
|
|
125
259
|
<img
|
|
126
260
|
:src="item.src"
|
|
127
|
-
:alt="item.alt ?? item.
|
|
128
|
-
class="h-full w-full object-cover"
|
|
261
|
+
:alt="item.alt ?? item.label ?? ''"
|
|
262
|
+
class="pointer-events-none h-full w-full object-cover"
|
|
263
|
+
:style="imgStyle(i)"
|
|
129
264
|
/>
|
|
130
265
|
|
|
131
|
-
<!-- Overlay with text -->
|
|
132
266
|
<div
|
|
133
|
-
v-if="item.
|
|
134
|
-
class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent px-
|
|
267
|
+
v-if="item.label || item.supportingText"
|
|
268
|
+
class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent px-5 pb-4 pt-12"
|
|
135
269
|
>
|
|
136
|
-
<
|
|
137
|
-
<p v-if="item.
|
|
270
|
+
<p v-if="item.label" class="text-title-medium font-medium text-white">{{ item.label }}</p>
|
|
271
|
+
<p v-if="item.supportingText" class="mt-0.5 text-body-small text-white/80">{{ item.supportingText }}</p>
|
|
138
272
|
</div>
|
|
139
273
|
</div>
|
|
140
|
-
</
|
|
274
|
+
</div>
|
|
141
275
|
|
|
142
|
-
|
|
143
|
-
<template v-if="showArrows && total > 1">
|
|
276
|
+
<template v-if="showArrows && items.length > 1">
|
|
144
277
|
<button
|
|
145
278
|
type="button"
|
|
146
279
|
class="absolute left-3 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full bg-surface/80 text-on-surface shadow-elevation-1 opacity-0 backdrop-blur-sm transition-all duration-200 hover:bg-surface hover:shadow-elevation-2 group-hover:opacity-100"
|
|
@@ -157,47 +290,36 @@ defineExpose({ next, prev, goTo })
|
|
|
157
290
|
</button>
|
|
158
291
|
</template>
|
|
159
292
|
|
|
160
|
-
|
|
293
|
+
</div>
|
|
294
|
+
|
|
161
295
|
<div
|
|
162
|
-
v-if="showIndicators &&
|
|
163
|
-
class="
|
|
164
|
-
:class="currentItem?.title || currentItem?.subtitle ? 'pb-20' : 'pb-3'"
|
|
296
|
+
v-if="showIndicators && totalPages > 1"
|
|
297
|
+
class="flex justify-center gap-1.5 pt-3"
|
|
165
298
|
>
|
|
166
299
|
<button
|
|
167
|
-
v-for="
|
|
168
|
-
:key="
|
|
300
|
+
v-for="p in totalPages"
|
|
301
|
+
:key="p"
|
|
169
302
|
type="button"
|
|
170
303
|
class="h-2 cursor-pointer rounded-full transition-all duration-300"
|
|
171
|
-
:class="
|
|
172
|
-
@click="
|
|
304
|
+
:class="(p - 1) === activePage ? 'w-5 bg-on-surface' : 'w-2 bg-on-surface/30 hover:bg-on-surface/50'"
|
|
305
|
+
@click="isPageBased ? scrollToPage(p - 1) : scrollToItem(p - 1)"
|
|
173
306
|
/>
|
|
174
307
|
</div>
|
|
175
|
-
</div>
|
|
176
308
|
</template>
|
|
177
309
|
|
|
178
|
-
<style>
|
|
179
|
-
.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
transition: transform 500ms cubic-bezier(0.2, 0, 0, 1), opacity 500ms cubic-bezier(0.2, 0, 0, 1);
|
|
310
|
+
<style scoped>
|
|
311
|
+
.carousel-track {
|
|
312
|
+
scroll-snap-type: x mandatory;
|
|
313
|
+
-webkit-overflow-scrolling: touch;
|
|
314
|
+
scrollbar-width: none;
|
|
184
315
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
transform: translateX(100%);
|
|
188
|
-
opacity: 0;
|
|
189
|
-
}
|
|
190
|
-
.m3-carousel-right-leave-to {
|
|
191
|
-
transform: translateX(-100%);
|
|
192
|
-
opacity: 0;
|
|
316
|
+
.carousel-track::-webkit-scrollbar {
|
|
317
|
+
display: none;
|
|
193
318
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
transform: translateX(-100%);
|
|
197
|
-
opacity: 0;
|
|
319
|
+
.snap-center > * {
|
|
320
|
+
scroll-snap-align: center;
|
|
198
321
|
}
|
|
199
|
-
.
|
|
200
|
-
|
|
201
|
-
opacity: 0;
|
|
322
|
+
.snap-start > * {
|
|
323
|
+
scroll-snap-align: start;
|
|
202
324
|
}
|
|
203
325
|
</style>
|
|
@@ -146,10 +146,7 @@ function onOut(e: MouseEvent) {
|
|
|
146
146
|
function onScroll(e: Event) {
|
|
147
147
|
if (!open.value) return
|
|
148
148
|
if (panelEl.value?.contains(e.target as Node)) return
|
|
149
|
-
|
|
150
|
-
const rect = triggerEl.value.getBoundingClientRect()
|
|
151
|
-
if (rect.bottom < 0 || rect.top > window.innerHeight) { open.value = false; return }
|
|
152
|
-
computeDropPos()
|
|
149
|
+
open.value = false
|
|
153
150
|
}
|
|
154
151
|
|
|
155
152
|
watch(open, (v) => {
|