@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.
- package/README.md +55 -0
- package/dist/index.css +1 -0
- package/dist/index.js +128 -0
- package/dist/index.mjs +4819 -0
- package/package.json +57 -0
- package/src/components/Accordion/index.vue +355 -0
- package/src/components/Button/index.vue +440 -0
- package/src/components/Card/index.vue +386 -0
- package/src/components/Checkboxes/index.vue +416 -0
- package/src/components/CountrySelect/data/countries.json +2084 -0
- package/src/components/CountrySelect/index.vue +319 -0
- package/src/components/Input/index.vue +293 -0
- package/src/components/Modal/index.vue +360 -0
- package/src/components/Select/index.vue +411 -0
- package/src/components/Skeleton/index.vue +110 -0
- package/src/components/ThumbnailContainer/index.vue +451 -0
- package/src/composables/Msg.ts +349 -0
- package/src/index.ts +28 -0
- package/src/styles/theme.css +120 -0
@@ -0,0 +1,411 @@
|
|
1
|
+
<template>
|
2
|
+
<div class="relative" :style="{ width: width }" ref="selectContainer">
|
3
|
+
<!-- 触发器 -->
|
4
|
+
<div
|
5
|
+
@click="toggleDropdown"
|
6
|
+
@keydown.enter="toggleDropdown"
|
7
|
+
@keydown.space="toggleDropdown"
|
8
|
+
@keydown.escape="closeDropdown"
|
9
|
+
:class="[
|
10
|
+
'flex items-center justify-between w-full px-4 py-1.5 text-sm bg-white rounded-md cursor-pointer transition-all duration-200 border ',
|
11
|
+
'focus:outline-none focus:ring-2 focus:ring-[#1a1a1a]/50 focus:ring-offset-1',
|
12
|
+
{
|
13
|
+
'shadow-sm hover:shadow-md border border-gray-200/60 hover:border-gray-300/80': !disabled && !borderless,
|
14
|
+
'shadow-sm hover:shadow-md': !disabled && borderless,
|
15
|
+
'bg-gray-50/50 cursor-not-allowed border border-gray-100': disabled,
|
16
|
+
'ring-1 ring-[#1a1a1a]/50 border-[#1a1a1a]/50 shadow-md': isOpen && !borderless,
|
17
|
+
'ring-1 ring-[#1a1a1a]/50 shadow-md': isOpen && borderless
|
18
|
+
}
|
19
|
+
]"
|
20
|
+
:tabindex="disabled ? -1 : 0"
|
21
|
+
role="combobox"
|
22
|
+
:aria-expanded="isOpen"
|
23
|
+
:aria-haspopup="true"
|
24
|
+
:aria-labelledby="labelId"
|
25
|
+
>
|
26
|
+
<div class="flex items-center gap-2 min-h-0 flex-1">
|
27
|
+
<span v-if="placeholder && !hasSelection" class="text-gray-500">
|
28
|
+
{{ placeholder }}
|
29
|
+
</span>
|
30
|
+
<div v-else class="flex flex-wrap gap-1">
|
31
|
+
<!-- 单选显示 -->
|
32
|
+
<template v-if="!multiple">
|
33
|
+
<span v-if="selectedOption" class="text-gray-900">
|
34
|
+
{{ getOptionLabel(selectedOption) }}
|
35
|
+
</span>
|
36
|
+
</template>
|
37
|
+
<!-- 多选显示 -->
|
38
|
+
<template v-else>
|
39
|
+
<span
|
40
|
+
v-for="option in selectedOptions"
|
41
|
+
:key="getOptionValue(option)"
|
42
|
+
class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs bg-[#FFF] text-[#1a1a1a] rounded-full border border-[#1a1a1a]/50"
|
43
|
+
>
|
44
|
+
{{ getOptionLabel(option) }}
|
45
|
+
<button
|
46
|
+
@click.stop="removeOption(option)"
|
47
|
+
class="rounded-full p-0.5 transition-colors duration-150"
|
48
|
+
type="button"
|
49
|
+
>
|
50
|
+
<i class="bi bi-x text-xs text-[#1a1a1a]"></i>
|
51
|
+
</button>
|
52
|
+
</span>
|
53
|
+
</template>
|
54
|
+
</div>
|
55
|
+
</div>
|
56
|
+
<i
|
57
|
+
:class="[
|
58
|
+
'bi text-gray-400 transition-transform duration-200 text-lg',
|
59
|
+
{
|
60
|
+
'bi-chevron-down': !isOpen,
|
61
|
+
'bi-chevron-up': isOpen
|
62
|
+
}
|
63
|
+
]"
|
64
|
+
></i>
|
65
|
+
</div>
|
66
|
+
|
67
|
+
<!-- 下拉菜单 -->
|
68
|
+
<Transition
|
69
|
+
enter-active-class="transition ease-out duration-200"
|
70
|
+
enter-from-class="opacity-0 scale-95"
|
71
|
+
enter-to-class="opacity-100 scale-100"
|
72
|
+
leave-active-class="transition ease-in duration-150"
|
73
|
+
leave-from-class="opacity-100 scale-100"
|
74
|
+
leave-to-class="opacity-0 scale-95"
|
75
|
+
>
|
76
|
+
<div
|
77
|
+
v-if="isOpen"
|
78
|
+
class="absolute z-[9999] w-full mt-2 bg-white rounded-xl shadow-xl border border-gray-100/50 max-h-60 overflow-auto backdrop-blur-sm"
|
79
|
+
role="listbox"
|
80
|
+
>
|
81
|
+
<!-- 搜索框 -->
|
82
|
+
<div v-if="searchable" class="p-3 border-b border-gray-50/80">
|
83
|
+
<input
|
84
|
+
ref="searchInput"
|
85
|
+
v-model="searchQuery"
|
86
|
+
type="text"
|
87
|
+
class="w-full px-3 py-2 text-sm bg-gray-50/50 border border-gray-200/60 rounded-lg focus:outline-none focus:ring-1 focus:ring-[#1a1a1a]/50 focus:border-[#1a1a1a]/50 transition-all duration-200"
|
88
|
+
placeholder="搜索..."
|
89
|
+
@keydown.escape="closeDropdown"
|
90
|
+
/>
|
91
|
+
</div>
|
92
|
+
|
93
|
+
<!-- 选项列表 -->
|
94
|
+
<div class="py-1">
|
95
|
+
<div
|
96
|
+
v-if="filteredOptions.length === 0"
|
97
|
+
class="px-4 py-3 text-sm text-gray-400 text-center"
|
98
|
+
>
|
99
|
+
{{ noOptionsText }}
|
100
|
+
</div>
|
101
|
+
<div
|
102
|
+
v-for="option in filteredOptions"
|
103
|
+
:key="getOptionValue(option)"
|
104
|
+
@click="selectOption(option)"
|
105
|
+
@keydown.enter="selectOption(option)"
|
106
|
+
@keydown.space="selectOption(option)"
|
107
|
+
:class="[
|
108
|
+
'px-4 py-3 text-sm cursor-pointer transition-all duration-150',
|
109
|
+
{
|
110
|
+
'bg-[#f2f2f2] text-[#1a1a1a] font-medium': isSelected(option),
|
111
|
+
'hover:bg-[#f2f2f2] text-gray-700': !isSelected(option)
|
112
|
+
}
|
113
|
+
]"
|
114
|
+
role="option"
|
115
|
+
:aria-selected="isSelected(option)"
|
116
|
+
:tabindex="0"
|
117
|
+
>
|
118
|
+
<div class="flex items-center gap-3">
|
119
|
+
<!-- 多选复选框 -->
|
120
|
+
<div v-if="multiple" class="flex items-center">
|
121
|
+
<div
|
122
|
+
:class="[
|
123
|
+
'w-4 h-4 rounded border flex items-center justify-center transition-all duration-150',
|
124
|
+
{
|
125
|
+
'border-[#1a1a1a] text-white': isSelected(option),
|
126
|
+
'border-gray-300 bg-white': !isSelected(option)
|
127
|
+
}
|
128
|
+
]"
|
129
|
+
>
|
130
|
+
<i
|
131
|
+
v-if="isSelected(option)"
|
132
|
+
class="bi bi-check text-[#1a1a1a] text-xs"
|
133
|
+
></i>
|
134
|
+
</div>
|
135
|
+
</div>
|
136
|
+
<!-- 单选单选框 -->
|
137
|
+
<div v-else class="flex items-center">
|
138
|
+
<div
|
139
|
+
:class="[
|
140
|
+
'w-4 h-4 rounded-full border-2 flex items-center justify-center transition-all duration-150',
|
141
|
+
{
|
142
|
+
'border-[#1a1a1a] bg-[#1a1a1a]': isSelected(option),
|
143
|
+
'border-gray-300 bg-white': !isSelected(option)
|
144
|
+
}
|
145
|
+
]"
|
146
|
+
>
|
147
|
+
<div
|
148
|
+
v-if="isSelected(option)"
|
149
|
+
class="w-1.5 h-1.5 bg-white rounded-full"
|
150
|
+
></div>
|
151
|
+
</div>
|
152
|
+
</div>
|
153
|
+
<span class="flex-1">{{ getOptionLabel(option) }}</span>
|
154
|
+
</div>
|
155
|
+
</div>
|
156
|
+
</div>
|
157
|
+
</div>
|
158
|
+
</Transition>
|
159
|
+
</div>
|
160
|
+
</template>
|
161
|
+
|
162
|
+
<script setup lang="ts">
|
163
|
+
/**
|
164
|
+
* Select 选择器组件
|
165
|
+
* @displayName XSelect
|
166
|
+
*/
|
167
|
+
|
168
|
+
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
169
|
+
export interface SelectOption {
|
170
|
+
label: string
|
171
|
+
value: any
|
172
|
+
disabled?: boolean
|
173
|
+
[key: string]: any
|
174
|
+
}
|
175
|
+
|
176
|
+
export interface SelectProps {
|
177
|
+
modelValue?: any
|
178
|
+
options: SelectOption[]
|
179
|
+
placeholder?: string
|
180
|
+
multiple?: boolean
|
181
|
+
searchable?: boolean
|
182
|
+
disabled?: boolean
|
183
|
+
labelKey?: string
|
184
|
+
valueKey?: string
|
185
|
+
noOptionsText?: string
|
186
|
+
clearable?: boolean
|
187
|
+
borderless?: boolean
|
188
|
+
width?: string
|
189
|
+
}
|
190
|
+
|
191
|
+
interface Emits {
|
192
|
+
(e: 'update:modelValue', value: any): void
|
193
|
+
(e: 'change', value: any): void
|
194
|
+
(e: 'open'): void
|
195
|
+
(e: 'close'): void
|
196
|
+
}
|
197
|
+
|
198
|
+
const props = withDefaults(defineProps<SelectProps>(), {
|
199
|
+
placeholder: '请选择...',
|
200
|
+
multiple: false,
|
201
|
+
searchable: false,
|
202
|
+
disabled: false,
|
203
|
+
labelKey: 'label',
|
204
|
+
valueKey: 'value',
|
205
|
+
noOptionsText: '暂无选项',
|
206
|
+
clearable: false,
|
207
|
+
borderless: false,
|
208
|
+
width: '200px'
|
209
|
+
})
|
210
|
+
|
211
|
+
const emit = defineEmits<Emits>()
|
212
|
+
|
213
|
+
// 响应式数据
|
214
|
+
const isOpen = ref(false)
|
215
|
+
const searchQuery = ref('')
|
216
|
+
const selectContainer = ref<HTMLElement>()
|
217
|
+
const searchInput = ref<HTMLInputElement>()
|
218
|
+
|
219
|
+
// 计算属性
|
220
|
+
const filteredOptions = computed(() => {
|
221
|
+
if (!props.searchable || !searchQuery.value) {
|
222
|
+
return props.options
|
223
|
+
}
|
224
|
+
return props.options.filter(option =>
|
225
|
+
getOptionLabel(option).toLowerCase().includes(searchQuery.value.toLowerCase())
|
226
|
+
)
|
227
|
+
})
|
228
|
+
|
229
|
+
const selectedOption = computed(() => {
|
230
|
+
if (props.multiple) return null
|
231
|
+
return props.options.find(option => getOptionValue(option) === props.modelValue) || null
|
232
|
+
})
|
233
|
+
|
234
|
+
const selectedOptions = computed(() => {
|
235
|
+
if (!props.multiple) return []
|
236
|
+
if (Array.isArray(props.modelValue)) {
|
237
|
+
return props.options.filter(option =>
|
238
|
+
props.modelValue.includes(getOptionValue(option))
|
239
|
+
)
|
240
|
+
}
|
241
|
+
return []
|
242
|
+
})
|
243
|
+
|
244
|
+
const hasSelection = computed(() => {
|
245
|
+
if (props.multiple) {
|
246
|
+
return selectedOptions.value.length > 0
|
247
|
+
}
|
248
|
+
return selectedOption.value !== null
|
249
|
+
})
|
250
|
+
|
251
|
+
const labelId = computed(() => `select-label-${Math.random().toString(36).substr(2, 9)}`)
|
252
|
+
|
253
|
+
// 方法
|
254
|
+
const getOptionLabel = (option: SelectOption): string => {
|
255
|
+
return typeof option === 'object' ? option[props.labelKey] : option
|
256
|
+
}
|
257
|
+
|
258
|
+
const getOptionValue = (option: SelectOption): any => {
|
259
|
+
return typeof option === 'object' ? option[props.valueKey] : option
|
260
|
+
}
|
261
|
+
|
262
|
+
const isSelected = (option: SelectOption): boolean => {
|
263
|
+
const value = getOptionValue(option)
|
264
|
+
if (props.multiple) {
|
265
|
+
return Array.isArray(props.modelValue) && props.modelValue.includes(value)
|
266
|
+
}
|
267
|
+
return props.modelValue === value
|
268
|
+
}
|
269
|
+
|
270
|
+
const selectOption = (option: SelectOption) => {
|
271
|
+
if (option.disabled) return
|
272
|
+
|
273
|
+
const value = getOptionValue(option)
|
274
|
+
|
275
|
+
if (props.multiple) {
|
276
|
+
const currentValues = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
277
|
+
const index = currentValues.indexOf(value)
|
278
|
+
|
279
|
+
if (index > -1) {
|
280
|
+
currentValues.splice(index, 1)
|
281
|
+
} else {
|
282
|
+
currentValues.push(value)
|
283
|
+
}
|
284
|
+
|
285
|
+
emit('update:modelValue', currentValues)
|
286
|
+
emit('change', currentValues)
|
287
|
+
} else {
|
288
|
+
emit('update:modelValue', value)
|
289
|
+
emit('change', value)
|
290
|
+
closeDropdown()
|
291
|
+
}
|
292
|
+
}
|
293
|
+
|
294
|
+
const removeOption = (option: SelectOption) => {
|
295
|
+
if (!props.multiple) return
|
296
|
+
|
297
|
+
const value = getOptionValue(option)
|
298
|
+
const currentValues = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
299
|
+
const index = currentValues.indexOf(value)
|
300
|
+
|
301
|
+
if (index > -1) {
|
302
|
+
currentValues.splice(index, 1)
|
303
|
+
emit('update:modelValue', currentValues)
|
304
|
+
emit('change', currentValues)
|
305
|
+
}
|
306
|
+
}
|
307
|
+
|
308
|
+
const toggleDropdown = () => {
|
309
|
+
if (props.disabled) return
|
310
|
+
|
311
|
+
if (isOpen.value) {
|
312
|
+
closeDropdown()
|
313
|
+
} else {
|
314
|
+
openDropdown()
|
315
|
+
}
|
316
|
+
}
|
317
|
+
|
318
|
+
const openDropdown = () => {
|
319
|
+
isOpen.value = true
|
320
|
+
emit('open')
|
321
|
+
|
322
|
+
// 聚焦搜索框
|
323
|
+
nextTick(() => {
|
324
|
+
if (props.searchable && searchInput.value) {
|
325
|
+
searchInput.value.focus()
|
326
|
+
}
|
327
|
+
})
|
328
|
+
}
|
329
|
+
|
330
|
+
const closeDropdown = () => {
|
331
|
+
|
332
|
+
isOpen.value = false
|
333
|
+
|
334
|
+
searchQuery.value = ''
|
335
|
+
|
336
|
+
emit('close')
|
337
|
+
|
338
|
+
}
|
339
|
+
|
340
|
+
// 点击外部关闭下拉框
|
341
|
+
const handleClickOutside = (event: Event) => {
|
342
|
+
if (selectContainer.value && !selectContainer.value.contains(event.target as Node)) {
|
343
|
+
closeDropdown()
|
344
|
+
}
|
345
|
+
}
|
346
|
+
|
347
|
+
// 键盘导航
|
348
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
349
|
+
if (!isOpen.value) return
|
350
|
+
|
351
|
+
const options = filteredOptions.value
|
352
|
+
const currentIndex = options.findIndex(option => isSelected(option))
|
353
|
+
|
354
|
+
switch (event.key) {
|
355
|
+
case 'ArrowDown':
|
356
|
+
event.preventDefault()
|
357
|
+
// 可以添加高亮逻辑
|
358
|
+
break
|
359
|
+
case 'ArrowUp':
|
360
|
+
event.preventDefault()
|
361
|
+
// 可以添加高亮逻辑
|
362
|
+
break
|
363
|
+
case 'Enter':
|
364
|
+
case ' ':
|
365
|
+
event.preventDefault()
|
366
|
+
if (currentIndex >= 0 && options[currentIndex]) {
|
367
|
+
selectOption(options[currentIndex])
|
368
|
+
}
|
369
|
+
break
|
370
|
+
}
|
371
|
+
}
|
372
|
+
|
373
|
+
// 生命周期
|
374
|
+
onMounted(() => {
|
375
|
+
document.addEventListener('click', handleClickOutside)
|
376
|
+
document.addEventListener('keydown', handleKeydown)
|
377
|
+
})
|
378
|
+
|
379
|
+
onUnmounted(() => {
|
380
|
+
document.removeEventListener('click', handleClickOutside)
|
381
|
+
document.removeEventListener('keydown', handleKeydown)
|
382
|
+
})
|
383
|
+
|
384
|
+
// 暴露方法
|
385
|
+
defineExpose({
|
386
|
+
open: openDropdown,
|
387
|
+
close: closeDropdown,
|
388
|
+
toggle: toggleDropdown
|
389
|
+
})
|
390
|
+
</script>
|
391
|
+
|
392
|
+
<style scoped>
|
393
|
+
/* 自定义滚动条样式 */
|
394
|
+
.overflow-auto::-webkit-scrollbar {
|
395
|
+
width: 4px;
|
396
|
+
}
|
397
|
+
|
398
|
+
.overflow-auto::-webkit-scrollbar-track {
|
399
|
+
background: transparent;
|
400
|
+
}
|
401
|
+
|
402
|
+
.overflow-auto::-webkit-scrollbar-thumb {
|
403
|
+
background: rgba(156, 163, 175, 0.3);
|
404
|
+
border-radius: 2px;
|
405
|
+
}
|
406
|
+
|
407
|
+
.overflow-auto::-webkit-scrollbar-thumb:hover {
|
408
|
+
background: rgba(156, 163, 175, 0.5);
|
409
|
+
}
|
410
|
+
</style>
|
411
|
+
|
@@ -0,0 +1,110 @@
|
|
1
|
+
<template>
|
2
|
+
<div
|
3
|
+
v-if="loading"
|
4
|
+
class="bg-gray-200"
|
5
|
+
:class="[
|
6
|
+
widthClass,
|
7
|
+
heightClass,
|
8
|
+
shapeClass,
|
9
|
+
customClass,
|
10
|
+
{ 'animate-pulse': animated }
|
11
|
+
]"
|
12
|
+
:style="customStyle"
|
13
|
+
></div>
|
14
|
+
<slot v-else></slot>
|
15
|
+
</template>
|
16
|
+
|
17
|
+
<script setup lang="ts">
|
18
|
+
import { computed, withDefaults } from 'vue'
|
19
|
+
|
20
|
+
/**
|
21
|
+
* Skeleton 骨架屏组件
|
22
|
+
* @displayName XSkeleton
|
23
|
+
*/
|
24
|
+
|
25
|
+
export interface SkeletonProps {
|
26
|
+
width?: string | number
|
27
|
+
height?: string | number
|
28
|
+
shape?: 'rectangle' | 'circle' | 'rounded'
|
29
|
+
animated?: boolean
|
30
|
+
loading?: boolean
|
31
|
+
class?: string
|
32
|
+
style?: Record<string, any>
|
33
|
+
}
|
34
|
+
|
35
|
+
const props = withDefaults(defineProps<SkeletonProps>(), {
|
36
|
+
width: '100%',
|
37
|
+
height: '1rem',
|
38
|
+
shape: 'rectangle',
|
39
|
+
animated: true,
|
40
|
+
loading: true,
|
41
|
+
class: '',
|
42
|
+
style: () => ({})
|
43
|
+
})
|
44
|
+
|
45
|
+
const widthClass = computed(() => {
|
46
|
+
if (typeof props.width === 'string') {
|
47
|
+
if (props.width.includes('%') || props.width.includes('px') || props.width.includes('rem')) {
|
48
|
+
return ''
|
49
|
+
}
|
50
|
+
return `w-${props.width}`
|
51
|
+
}
|
52
|
+
return ''
|
53
|
+
})
|
54
|
+
|
55
|
+
const heightClass = computed(() => {
|
56
|
+
if (typeof props.height === 'string') {
|
57
|
+
if (props.height.includes('%') || props.height.includes('px') || props.height.includes('rem')) {
|
58
|
+
return ''
|
59
|
+
}
|
60
|
+
return `h-${props.height}`
|
61
|
+
}
|
62
|
+
return ''
|
63
|
+
})
|
64
|
+
|
65
|
+
const shapeClass = computed(() => {
|
66
|
+
switch (props.shape) {
|
67
|
+
case 'circle':
|
68
|
+
return 'rounded-full'
|
69
|
+
case 'rounded':
|
70
|
+
return 'rounded-lg'
|
71
|
+
default:
|
72
|
+
return 'rounded'
|
73
|
+
}
|
74
|
+
})
|
75
|
+
|
76
|
+
const customClass = computed(() => props.class)
|
77
|
+
|
78
|
+
const customStyle = computed(() => {
|
79
|
+
const style: Record<string, any> = { ...props.style }
|
80
|
+
|
81
|
+
if (typeof props.width === 'string' && (props.width.includes('%') || props.width.includes('px') || props.width.includes('rem'))) {
|
82
|
+
style.width = props.width
|
83
|
+
} else if (typeof props.width === 'number') {
|
84
|
+
style.width = `${props.width}px`
|
85
|
+
}
|
86
|
+
|
87
|
+
if (typeof props.height === 'string' && (props.height.includes('%') || props.height.includes('px') || props.height.includes('rem'))) {
|
88
|
+
style.height = props.height
|
89
|
+
} else if (typeof props.height === 'number') {
|
90
|
+
style.height = `${props.height}px`
|
91
|
+
}
|
92
|
+
|
93
|
+
return style
|
94
|
+
})
|
95
|
+
</script>
|
96
|
+
|
97
|
+
<style scoped>
|
98
|
+
@keyframes pulse {
|
99
|
+
0%, 100% {
|
100
|
+
opacity: 1;
|
101
|
+
}
|
102
|
+
50% {
|
103
|
+
opacity: 0.5;
|
104
|
+
}
|
105
|
+
}
|
106
|
+
|
107
|
+
.animate-pulse {
|
108
|
+
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
109
|
+
}
|
110
|
+
</style>
|