@xlui/xux-ui 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.
@@ -0,0 +1,451 @@
1
+ <template>
2
+ <div class="thumbnail-container-wrapper relative">
3
+ <!-- 左侧滚动按钮 -->
4
+ <button
5
+ v-if="showLeftArrow"
6
+ @click="scrollLeft"
7
+ class="scroll-arrow scroll-arrow-left"
8
+ aria-label="向左滚动"
9
+ >
10
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
11
+ <path d="M15 18l-6-6 6-6" />
12
+ </svg>
13
+ </button>
14
+
15
+ <!-- 缩略图容器 -->
16
+ <div
17
+ ref="thumbnailContainer"
18
+ @scroll="handleThumbnailScroll"
19
+ class="thumbnail-scroll-container"
20
+ >
21
+ <div
22
+ v-for="(img, index) in images"
23
+ :key="index"
24
+ :ref="el => setThumbnailRef(el, index)"
25
+ @mouseenter="handleImageHover(index)"
26
+ @mouseleave="handleImageLeave()"
27
+ @click="handleThumbnailClick(index)"
28
+ class="thumbnail-item"
29
+ :class="{
30
+ 'thumbnail-item--active': currentIndex === index,
31
+ 'thumbnail-item--inactive': currentIndex !== index
32
+ }"
33
+ >
34
+ <img
35
+ :src="img"
36
+ :alt="`缩略图 ${index + 1}`"
37
+ class="thumbnail-image"
38
+ :class="{
39
+ 'thumbnail-image--active': currentIndex === index,
40
+ 'thumbnail-image--inactive': currentIndex !== index
41
+ }"
42
+ >
43
+
44
+ <!-- 图片序号 -->
45
+ <div class="thumbnail-index">
46
+ {{ index + 1 }}
47
+ </div>
48
+ </div>
49
+ </div>
50
+
51
+ <!-- 右侧滚动按钮 -->
52
+ <button
53
+ v-if="showRightArrow"
54
+ @click="scrollRight"
55
+ class="scroll-arrow scroll-arrow-right"
56
+ aria-label="向右滚动"
57
+ >
58
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
59
+ <path d="M9 18l6-6-6-6" />
60
+ </svg>
61
+ </button>
62
+ </div>
63
+ </template>
64
+
65
+ <script setup lang="ts">
66
+ import { ref, onMounted, nextTick, watch } from 'vue'
67
+ import type { ComponentPublicInstance } from 'vue'
68
+
69
+ /**
70
+ * ThumbnailContainer 缩略图容器组件
71
+ * @displayName XThumbnailContainer
72
+ */
73
+
74
+ export interface ThumbnailContainerProps {
75
+ images: string[]
76
+ modelValue?: number
77
+ autoScroll?: boolean
78
+ showIndex?: boolean
79
+ }
80
+
81
+ const props = withDefaults(defineProps<ThumbnailContainerProps>(), {
82
+ images: () => [],
83
+ modelValue: 0,
84
+ autoScroll: true,
85
+ showIndex: true
86
+ })
87
+
88
+ const emit = defineEmits<{
89
+ 'update:modelValue': [index: number]
90
+ 'change': [index: number]
91
+ 'hover': [index: number]
92
+ }>()
93
+
94
+ // 响应式状态
95
+ const currentIndex = ref(props.modelValue)
96
+ const thumbnailContainer = ref<HTMLDivElement | null>(null)
97
+ const thumbnailRefs = ref<HTMLElement[]>([])
98
+ const canScrollLeft = ref(false)
99
+ const canScrollRight = ref(false)
100
+ const scrollProgress = ref(0)
101
+ const showLeftArrow = ref(false)
102
+ const showRightArrow = ref(false)
103
+ // 处理滚动事件
104
+ const handleThumbnailScroll = () => {
105
+ updateScrollState()
106
+ }
107
+
108
+ // 设置缩略图 ref
109
+ const setThumbnailRef = (el: Element | ComponentPublicInstance | null, index: number) => {
110
+ if (el && el instanceof HTMLElement) {
111
+ thumbnailRefs.value[index] = el
112
+ }
113
+ }
114
+
115
+ // 处理鼠标悬停
116
+ const handleImageHover = (index: number) => {
117
+ emit('hover', index)
118
+ }
119
+
120
+ // 处理鼠标离开
121
+ const handleImageLeave = () => {
122
+ // 可以在这里添加悬停结束的逻辑
123
+ }
124
+
125
+ // 处理缩略图点击
126
+ const handleThumbnailClick = (index: number) => {
127
+ currentIndex.value = index
128
+ emit('update:modelValue', index)
129
+ emit('change', index)
130
+
131
+ // 自动滚动到合适位置
132
+ if (props.autoScroll) {
133
+ nextTick(() => {
134
+ scrollThumbnailToThirdPosition(index)
135
+ setTimeout(() => {
136
+ updateScrollState()
137
+ }, 300)
138
+ })
139
+ }
140
+ }
141
+
142
+ // 左滚动
143
+ const scrollLeft = () => {
144
+ if (!thumbnailContainer.value) return
145
+ const container = thumbnailContainer.value
146
+ const scrollAmount = container.clientWidth * 0.8
147
+ container.scrollBy({
148
+ left: -scrollAmount,
149
+ behavior: 'smooth'
150
+ })
151
+ }
152
+
153
+ // 右滚动
154
+ const scrollRight = () => {
155
+ if (!thumbnailContainer.value) return
156
+ const container = thumbnailContainer.value
157
+ const scrollAmount = container.clientWidth * 0.8
158
+ container.scrollBy({
159
+ left: scrollAmount,
160
+ behavior: 'smooth'
161
+ })
162
+ }
163
+
164
+
165
+ // 滚动缩略图到第3个位置
166
+ const scrollThumbnailToThirdPosition = (clickedIndex: number) => {
167
+ if (!thumbnailContainer.value || thumbnailRefs.value.length === 0) return
168
+
169
+ const container = thumbnailContainer.value
170
+ const clickedItem = thumbnailRefs.value[clickedIndex]
171
+
172
+ if (!clickedItem) return
173
+
174
+ // 计算目标滚动位置:点击项移动到第3个位置(索引2)
175
+ const targetPosition = 2 // 第3个位置的索引
176
+
177
+ // 获取缩略图的宽度和间距
178
+ const itemWidth = clickedItem.offsetWidth
179
+ const gap = 8 // 默认间距为8px
180
+
181
+ // 计算每个项目的总宽度(包括间距)
182
+ const itemTotalWidth = itemWidth + gap
183
+
184
+ // 计算目标滚动距离
185
+ // 如果点击的是前3个项目,滚动到开始位置
186
+ // 否则,将点击项滚动到第3个位置
187
+ let targetScrollLeft = 0
188
+
189
+ if (clickedIndex >= targetPosition) {
190
+ // 将点击项滚动到第3个位置
191
+ targetScrollLeft = (clickedIndex - targetPosition) * itemTotalWidth
192
+ }
193
+
194
+ // 边界处理:确保不会滚动超出范围
195
+ const maxScrollLeft = container.scrollWidth - container.clientWidth
196
+ targetScrollLeft = Math.max(0, Math.min(targetScrollLeft, maxScrollLeft))
197
+ // 平滑滚动
198
+ container.scrollTo({
199
+ left: targetScrollLeft,
200
+ behavior: 'smooth'
201
+ })
202
+ }
203
+ // 更新滚动状态
204
+ const updateScrollState = () => {
205
+ if (!thumbnailContainer.value) return
206
+
207
+ const container = thumbnailContainer.value
208
+ const scrollLeft = container.scrollLeft
209
+ const maxScrollLeft = container.scrollWidth - container.clientWidth
210
+
211
+ canScrollLeft.value = scrollLeft > 0
212
+ canScrollRight.value = scrollLeft < maxScrollLeft
213
+
214
+ // 计算滚动进度
215
+ if (maxScrollLeft > 0) {
216
+ scrollProgress.value = (scrollLeft / maxScrollLeft) * 100
217
+ } else {
218
+ scrollProgress.value = 0
219
+ }
220
+
221
+ // 判断是否显示箭头
222
+ const visibleCount = getVisibleThumbnailCount()
223
+ showLeftArrow.value = props.images.length > visibleCount && canScrollLeft.value
224
+ showRightArrow.value = props.images.length > visibleCount && canScrollRight.value
225
+ }
226
+
227
+ // 获取可见缩略图数量
228
+ const getVisibleThumbnailCount = () => {
229
+ if (!thumbnailContainer.value || thumbnailRefs.value.length === 0) return 4
230
+
231
+ const container = thumbnailContainer.value
232
+ const containerWidth = container.clientWidth - 32
233
+ const itemWidth = thumbnailRefs.value[0]?.offsetWidth || 80
234
+ const gap = 12 // gap-3 对应12px
235
+ const itemTotalWidth = itemWidth + gap
236
+
237
+ return Math.floor(containerWidth / itemTotalWidth)
238
+ }
239
+
240
+ // 切换到指定索引
241
+ const setCurrentIndex = (index: number) => {
242
+ if (index >= 0 && index < props.images.length) {
243
+ handleThumbnailClick(index)
244
+ }
245
+ }
246
+
247
+ // 监听 modelValue 变化
248
+ watch(() => props.modelValue, (newValue) => {
249
+ if (newValue !== undefined && newValue !== currentIndex.value) {
250
+ currentIndex.value = newValue
251
+ if (props.autoScroll) {
252
+ nextTick(() => {
253
+ scrollThumbnailToThirdPosition(newValue)
254
+ })
255
+ }
256
+ }
257
+ })
258
+
259
+ // 监听 images 变化
260
+ watch(() => props.images, () => {
261
+ nextTick(() => {
262
+ updateScrollState()
263
+ })
264
+ }, { deep: true })
265
+
266
+ onMounted(() => {
267
+ nextTick(() => {
268
+ updateScrollState()
269
+ })
270
+ })
271
+
272
+ // 暴露方法
273
+ defineExpose({
274
+ setCurrentIndex,
275
+ scrollLeft,
276
+ scrollRight,
277
+ updateScrollState
278
+ })
279
+ </script>
280
+
281
+ <style scoped>
282
+ .thumbnail-container-wrapper {
283
+ position: relative;
284
+ width: 100%;
285
+ }
286
+
287
+ /* 滚动容器 */
288
+ .thumbnail-scroll-container {
289
+ display: flex;
290
+ overflow-x: auto;
291
+ gap: 0.75rem; /* 12px */
292
+ padding: 0.5rem 1rem;
293
+ scroll-behavior: smooth;
294
+ scrollbar-width: none;
295
+ -ms-overflow-style: none;
296
+ }
297
+
298
+ .thumbnail-scroll-container::-webkit-scrollbar {
299
+ display: none;
300
+ }
301
+
302
+ /* 缩略图项 */
303
+ .thumbnail-item {
304
+ position: relative;
305
+ width: 80px;
306
+ height: 80px;
307
+ border: 2px solid var(--x-color-gray-300, #d1d5db);
308
+ border-radius: var(--x-radius-lg, 12px);
309
+ cursor: pointer;
310
+ flex-shrink: 0;
311
+ transition: all var(--x-transition, 0.2s ease);
312
+ overflow: hidden;
313
+ }
314
+
315
+ /* 移除向下动画,只保留边框和阴影变化 */
316
+ .thumbnail-item:hover {
317
+ border-color: var(--x-color-gray-400, #9ca3af);
318
+ box-shadow: var(--x-shadow-md, 0 4px 6px rgba(0, 0, 0, 0.1));
319
+ }
320
+
321
+ .thumbnail-item--active {
322
+ border-color: var(--x-color-primary, #1a1a1a);
323
+ box-shadow: 0 0 0 1px var(--x-color-primary, #1a1a1a),
324
+ var(--x-shadow-lg, 0 10px 15px rgba(0, 0, 0, 0.1));
325
+ }
326
+
327
+ .thumbnail-item--inactive {
328
+ border-color: var(--x-color-gray-300, #d1d5db);
329
+ }
330
+
331
+ /* 缩略图图片 */
332
+ .thumbnail-image {
333
+ width: 100%;
334
+ height: 100%;
335
+ object-fit: cover;
336
+ pointer-events: none;
337
+ transition: opacity var(--x-transition, 0.2s ease);
338
+ }
339
+
340
+ .thumbnail-image--active {
341
+ opacity: 1;
342
+ }
343
+
344
+ .thumbnail-image--inactive {
345
+ opacity: 0.75;
346
+ }
347
+
348
+ /* 图片序号 */
349
+ .thumbnail-index {
350
+ position: absolute;
351
+ bottom: 0.25rem;
352
+ left: 0.25rem;
353
+ background-color: rgba(0, 0, 0, 0.6);
354
+ color: white;
355
+ font-size: var(--x-font-size-xs, 12px);
356
+ padding: 0.125rem 0.375rem;
357
+ border-radius: var(--x-radius-sm, 4px);
358
+ line-height: 1;
359
+ }
360
+
361
+ /* 滚动按钮 - 去掉 active 向下动画 */
362
+ .scroll-arrow {
363
+ position: absolute;
364
+ top: 50%;
365
+ transform: translateY(-50%); /* 只保持垂直居中,不再有 active 时的 translateY 变化 */
366
+ z-index: 10;
367
+ display: flex;
368
+ align-items: center;
369
+ justify-content: center;
370
+ width: 40px;
371
+ height: 40px;
372
+ background: rgba(255, 255, 255, 0.95);
373
+ border: 1px solid var(--x-color-gray-200, #e5e7eb);
374
+ border-radius: 50%;
375
+ cursor: pointer;
376
+ transition: all var(--x-transition-fast, 0.15s ease);
377
+ box-shadow: var(--x-shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.05));
378
+ }
379
+
380
+ .scroll-arrow:hover {
381
+ background: white;
382
+ border-color: var(--x-color-primary, #1a1a1a);
383
+ box-shadow: var(--x-shadow-md, 0 4px 6px rgba(0, 0, 0, 0.1));
384
+ }
385
+
386
+ /* 移除 active 状态的向下动画 */
387
+ .scroll-arrow:active {
388
+ /* 不再改变 transform,只改变背景色 */
389
+ background: var(--x-color-gray-100, #f3f4f6);
390
+ }
391
+
392
+ .scroll-arrow:focus-visible {
393
+ outline: 2px solid var(--x-color-primary, #1a1a1a);
394
+ outline-offset: 2px;
395
+ }
396
+
397
+ .scroll-arrow-left {
398
+ left: 0.5rem;
399
+ }
400
+
401
+ .scroll-arrow-right {
402
+ right: 0.5rem;
403
+ }
404
+
405
+ .scroll-arrow svg {
406
+ width: 24px;
407
+ height: 24px;
408
+ color: var(--x-color-gray-700, #374151);
409
+ transition: color var(--x-transition-fast, 0.15s ease);
410
+ }
411
+
412
+ .scroll-arrow:hover svg {
413
+ color: var(--x-color-primary, #1a1a1a);
414
+ }
415
+
416
+ /* 响应式设计 */
417
+ @media (min-width: 640px) {
418
+ .thumbnail-item {
419
+ width: 96px;
420
+ height: 96px;
421
+ }
422
+ }
423
+
424
+ @media (min-width: 1024px) {
425
+ .thumbnail-item {
426
+ width: 123px;
427
+ height: 122px;
428
+ }
429
+ }
430
+
431
+ @media (max-width: 640px) {
432
+ .scroll-arrow {
433
+ width: 36px;
434
+ height: 36px;
435
+ }
436
+
437
+ .scroll-arrow svg {
438
+ width: 20px;
439
+ height: 20px;
440
+ }
441
+ }
442
+
443
+ /* 优化动画性能 */
444
+ @media (prefers-reduced-motion: reduce) {
445
+ .thumbnail-item,
446
+ .thumbnail-image,
447
+ .scroll-arrow {
448
+ transition: none;
449
+ }
450
+ }
451
+ </style>