@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,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>