@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.
Files changed (83) hide show
  1. package/README.md +219 -59
  2. package/dist/{MMenuItem-C6RwHl-l.js → MMenuItem-FyttjOj_.js} +399 -207
  3. package/dist/MMenuItem-FyttjOj_.js.map +1 -0
  4. package/dist/components/MAutocomplete.vue.d.ts +2 -0
  5. package/dist/components/MBox.vue.d.ts +2 -2
  6. package/dist/components/MButton.vue.d.ts +4 -0
  7. package/dist/components/MButtonGroup.vue.d.ts +28 -0
  8. package/dist/components/MCarousel.vue.d.ts +21 -12
  9. package/dist/components/MColorPickerModal.vue.d.ts +18 -0
  10. package/dist/components/MDataTable.vue.d.ts +1 -1
  11. package/dist/components/MDatePickerModal.vue.d.ts +21 -0
  12. package/dist/components/MDateRangePickerModal.vue.d.ts +24 -0
  13. package/dist/components/MDialog.vue.d.ts +9 -1
  14. package/dist/components/MExpansionPanel.vue.d.ts +3 -2
  15. package/dist/components/MFab.vue.d.ts +14 -2
  16. package/dist/components/MFlex.vue.d.ts +2 -2
  17. package/dist/components/MIconButton.vue.d.ts +4 -2
  18. package/dist/components/MInfiniteScroll.vue.d.ts +1 -1
  19. package/dist/components/MList.vue.d.ts +2 -0
  20. package/dist/components/MLoadingOverlay.vue.d.ts +1 -1
  21. package/dist/components/MMenu.vue.d.ts +3 -1
  22. package/dist/components/MMenuItem.vue.d.ts +14 -3
  23. package/dist/components/MMultiAutocomplete.vue.d.ts +2 -0
  24. package/dist/components/MMultiSelect.vue.d.ts +2 -0
  25. package/dist/components/MNavigationDrawer.vue.d.ts +1 -1
  26. package/dist/components/MNotificationHost.vue.d.ts +3 -0
  27. package/dist/components/MSelect.vue.d.ts +2 -0
  28. package/dist/components/MSlider.vue.d.ts +15 -4
  29. package/dist/components/MSplitButton.vue.d.ts +39 -0
  30. package/dist/components/MSplitter.vue.d.ts +1 -1
  31. package/dist/components/MStack.vue.d.ts +2 -2
  32. package/dist/components/MTable.vue.d.ts +1 -1
  33. package/dist/components/MTimePickerModal.vue.d.ts +21 -0
  34. package/dist/components/MToolbar.vue.d.ts +30 -0
  35. package/dist/components/MTooltip.vue.d.ts +8 -2
  36. package/dist/components/MWindow.vue.d.ts +2 -2
  37. package/dist/composables/useDevice.d.ts +3 -0
  38. package/dist/composables/useNotification.d.ts +64 -0
  39. package/dist/composables/useToast.d.ts +8 -0
  40. package/dist/index.d.ts +12 -0
  41. package/dist/m3ui-vue.css +1 -1
  42. package/dist/m3ui.js +5222 -2994
  43. package/dist/m3ui.js.map +1 -1
  44. package/dist/rich-text-editor.js +10 -10
  45. package/dist/styles.css +1 -1
  46. package/package.json +1 -1
  47. package/src/components/MAutocomplete.vue +109 -13
  48. package/src/components/MButton.vue +38 -16
  49. package/src/components/MButtonGroup.vue +158 -0
  50. package/src/components/MCarousel.vue +223 -101
  51. package/src/components/MColorPicker.vue +1 -4
  52. package/src/components/MColorPickerModal.vue +276 -0
  53. package/src/components/MDatePicker.vue +1 -4
  54. package/src/components/MDatePickerModal.vue +265 -0
  55. package/src/components/MDateRangePicker.vue +1 -4
  56. package/src/components/MDateRangePickerModal.vue +264 -0
  57. package/src/components/MDialog.vue +38 -1
  58. package/src/components/MExpansionPanel.vue +10 -32
  59. package/src/components/MFab.vue +92 -11
  60. package/src/components/MIconButton.vue +36 -6
  61. package/src/components/MList.vue +6 -3
  62. package/src/components/MListItem.vue +9 -3
  63. package/src/components/MMenu.vue +10 -5
  64. package/src/components/MMenuItem.vue +93 -10
  65. package/src/components/MMultiAutocomplete.vue +105 -10
  66. package/src/components/MMultiSelect.vue +106 -13
  67. package/src/components/MNotificationHost.vue +163 -0
  68. package/src/components/MSelect.vue +99 -16
  69. package/src/components/MSlider.vue +367 -134
  70. package/src/components/MSnackbar.vue +3 -0
  71. package/src/components/MSplitButton.vue +240 -0
  72. package/src/components/MTabs.vue +82 -39
  73. package/src/components/MTagInput.vue +11 -1
  74. package/src/components/MTimePicker.vue +1 -4
  75. package/src/components/MTimePickerModal.vue +458 -0
  76. package/src/components/MToolbar.vue +75 -0
  77. package/src/components/MTooltip.vue +60 -10
  78. package/src/components/_MContextMenuPanel.vue +2 -2
  79. package/src/composables/useDevice.ts +29 -0
  80. package/src/composables/useNotification.ts +93 -0
  81. package/src/composables/useToast.ts +42 -5
  82. package/src/index.ts +12 -0
  83. 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, watch, onMounted, onBeforeUnmount } from 'vue'
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
- title?: string
9
- subtitle?: string
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
- autoplay?: boolean
16
- interval?: number
16
+ layout?: 'hero' | 'multi-browse' | 'uncontained'
17
+ height?: string
17
18
  showArrows?: boolean
18
19
  showIndicators?: boolean
19
- aspectRatio?: string
20
- rounded?: boolean
20
+ autoplay?: boolean
21
+ interval?: number
22
+ gap?: number
23
+ visibleItems?: number
24
+ animated?: boolean
21
25
  }>(),
22
26
  {
23
- autoplay: false,
24
- interval: 5000,
27
+ layout: 'hero',
28
+ height: '320px',
25
29
  showArrows: true,
26
30
  showIndicators: true,
27
- aspectRatio: '16/9',
28
- rounded: true,
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 current = ref(0)
35
- const direction = ref<'left' | 'right'>('right')
36
- const transitioning = ref(false)
37
- let timer: ReturnType<typeof setInterval> | null = null
38
- let touchStartX = 0
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 total = computed(() => props.items.length)
41
- const currentItem = computed(() => props.items[current.value])
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 goTo(index: number) {
44
- if (transitioning.value || index === current.value) return
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
- function next() {
52
- goTo(current.value + 1)
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 prev() {
56
- goTo(current.value - 1)
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
- function onTransitionEnd() {
60
- transitioning.value = false
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 startAutoplay() {
64
- stopAutoplay()
65
- if (props.autoplay && total.value > 1) {
66
- timer = setInterval(next, props.interval)
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 stopAutoplay() {
71
- if (timer) { clearInterval(timer); timer = null }
161
+ function imgStyle(i: number) {
162
+ const t = imageTransforms.value[i]
163
+ if (!t) return {}
164
+ return { transform: t }
72
165
  }
73
166
 
74
- function onTouchStart(e: TouchEvent) {
75
- touchStartX = e.touches[0]!.clientX
76
- stopAutoplay()
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 onTouchEnd(e: TouchEvent) {
80
- const dx = e.changedTouches[0]!.clientX - touchStartX
81
- if (Math.abs(dx) > 50) {
82
- dx < 0 ? next() : prev()
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
- startAutoplay()
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(startAutoplay)
94
- onBeforeUnmount(stopAutoplay)
225
+ onMounted(() => {
226
+ startAutoplay()
227
+ nextTick(() => updateTransforms())
228
+ })
229
+ onBeforeUnmount(() => {
230
+ stopAutoplay()
231
+ if (rafId) cancelAnimationFrame(rafId)
232
+ })
95
233
 
96
- defineExpose({ next, prev, goTo })
234
+ defineExpose({ next, prev, scrollToItem, scrollToPage })
97
235
  </script>
98
236
 
99
237
  <template>
100
238
  <div
101
- class="group relative overflow-hidden bg-surface-container-highest"
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
- <!-- Slides -->
112
- <TransitionGroup
113
- :name="direction === 'right' ? 'm3-carousel-right' : 'm3-carousel-left'"
114
- tag="div"
115
- class="relative h-full w-full"
116
- @after-enter="onTransitionEnd"
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
- class="absolute inset-0"
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.title ?? ''"
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.title || item.subtitle"
134
- class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent px-6 pb-6 pt-16"
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
- <h3 v-if="item.title" class="text-title-large font-medium text-white">{{ item.title }}</h3>
137
- <p v-if="item.subtitle" class="mt-1 text-body-medium text-white/80">{{ item.subtitle }}</p>
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
- </TransitionGroup>
274
+ </div>
141
275
 
142
- <!-- Arrows -->
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
- <!-- Indicators -->
293
+ </div>
294
+
161
295
  <div
162
- v-if="showIndicators && total > 1"
163
- class="absolute inset-x-0 bottom-0 z-10 flex justify-center gap-2 pb-3"
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="(_, i) in items"
168
- :key="i"
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="i === current ? 'w-6 bg-white' : 'w-2 bg-white/50 hover:bg-white/70'"
172
- @click="goTo(i)"
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
- .m3-carousel-right-enter-active,
180
- .m3-carousel-right-leave-active,
181
- .m3-carousel-left-enter-active,
182
- .m3-carousel-left-leave-active {
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
- .m3-carousel-right-enter-from {
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
- .m3-carousel-left-enter-from {
196
- transform: translateX(-100%);
197
- opacity: 0;
319
+ .snap-center > * {
320
+ scroll-snap-align: center;
198
321
  }
199
- .m3-carousel-left-leave-to {
200
- transform: translateX(100%);
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
- if (!triggerEl.value) return
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) => {