@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,319 @@
1
+ <template>
2
+ <div ref="containerRef" class="country-select-container relative">
3
+ <div
4
+ @click="toggleDropdown"
5
+ :class="[
6
+ 'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#1a1a1a] focus:border-[#1a1a1a] transition-colors duration-200 cursor-pointer bg-white flex items-center justify-between',
7
+ customClass,
8
+ { 'opacity-60 cursor-not-allowed': disabled }
9
+ ]"
10
+ :tabindex="disabled ? -1 : 0"
11
+ @keydown.enter="toggleDropdown"
12
+ @keydown.space="toggleDropdown"
13
+ @keydown.escape="closeDropdown"
14
+ >
15
+ <div class="flex items-center gap-2">
16
+ <img
17
+ v-if="selectedCountry"
18
+ :src="selectedCountry.flagSvg"
19
+ :alt="selectedCountry.name"
20
+ class="w-5 h-4 object-cover rounded-sm"
21
+ @error="handleFlagError"
22
+ />
23
+ <span :class="{ 'text-gray-500': !selectedCountry && placeholder }">
24
+ {{ selectedCountry ? selectedCountry.name : (placeholder || '选择国家') }}
25
+ </span>
26
+ </div>
27
+ <svg
28
+ class="w-5 h-5 text-gray-400 transition-transform duration-200"
29
+ :class="{ 'rotate-180': isOpen }"
30
+ fill="none"
31
+ stroke="currentColor"
32
+ viewBox="0 0 24 24"
33
+ >
34
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
35
+ </svg>
36
+ </div>
37
+
38
+ <!-- Dropdown -->
39
+ <div
40
+ v-if="isOpen && !disabled"
41
+ class="absolute z-[9999] w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto"
42
+ style="position: absolute;"
43
+ >
44
+ <div
45
+ v-for="country in sortedCountries"
46
+ :key="country.code"
47
+ @click="selectCountry(country)"
48
+ class="px-3 py-2 hover:bg-gray-50 cursor-pointer flex items-center gap-2 transition-colors"
49
+ :class="{ 'bg-gray-100': selectedCountry?.code === country.code }"
50
+ >
51
+ <img
52
+ :src="country.flagSvg"
53
+ :alt="country.name"
54
+ class="w-5 h-4 object-cover rounded-sm flex-shrink-0"
55
+ @error="(e) => handleFlagError(e, country)"
56
+ />
57
+ <span>{{ country.name }}</span>
58
+ </div>
59
+ </div>
60
+
61
+ <!-- Backdrop -->
62
+ <div
63
+ v-if="isOpen && !disabled"
64
+ class="fixed inset-0 z-[9998]"
65
+ @click="closeDropdown"
66
+ ></div>
67
+ </div>
68
+ </template>
69
+
70
+ <script setup lang="ts">
71
+ /**
72
+ * CountrySelect 国家选择器组件
73
+ * @displayName XCountrySelect
74
+ */
75
+ import countriesData from './data/countries.json'
76
+ import { ref, computed, onMounted, onUnmounted, nextTick, readonly } from 'vue'
77
+
78
+ export interface Country {
79
+ name: string
80
+ code: string
81
+ flagEmoji: string
82
+ flagSvg: string
83
+ flagPng: string
84
+ }
85
+
86
+ export interface CountrySelectProps {
87
+ modelValue?: string
88
+ placeholder?: string
89
+ disabled?: boolean
90
+ required?: boolean
91
+ customClass?: string
92
+ autoDetectFromIp?: boolean
93
+ returnType?: 'name' | 'code' // 返回国家名称还是国家代码
94
+ priorityCountries?: string[] // 优先显示的国家代码列表
95
+ ipDetectUrl?: string // 自定义IP检测接口URL
96
+ ipDetectMethod?: 'GET' | 'POST' // 请求方法
97
+ ipDetectHeaders?: Record<string, string> // 自定义请求头
98
+ ipDetectResponseParser?: (data: any) => string // 自定义响应解析函数,返回国家代码
99
+ }
100
+
101
+ const props = withDefaults(defineProps<CountrySelectProps>(), {
102
+ modelValue: '',
103
+ placeholder: '',
104
+ disabled: false,
105
+ required: false,
106
+ customClass: '',
107
+ autoDetectFromIp: true,
108
+ returnType: 'name',
109
+ priorityCountries: () => ['US', 'CA', 'GB', 'DE', 'FR', 'JP', 'CN', 'AU'],
110
+ ipDetectUrl: 'https://ipinfo.io/json',
111
+ ipDetectMethod: 'GET'
112
+ })
113
+
114
+ const emit = defineEmits<{
115
+ 'update:modelValue': [value: string]
116
+ 'change': [value: string, country: Country]
117
+ 'ip-detect-start': [] // IP检测开始
118
+ 'ip-detect-success': [countryCode: string] // IP检测成功
119
+ 'ip-detect-error': [error: Error] // IP检测失败
120
+ }>()
121
+
122
+ // 下拉框状态
123
+ const isOpen = ref(false)
124
+
125
+ // 国家数据处理
126
+ const allCountries = ref<Country[]>(countriesData as Country[])
127
+
128
+ // 当前选中的国家
129
+ const selectedCountry = computed(() => {
130
+ if (!props.modelValue) return null
131
+ return allCountries.value.find(country =>
132
+ props.returnType === 'code' ? country.code === props.modelValue : country.name === props.modelValue
133
+ )
134
+ })
135
+
136
+ // 排序后的国家列表(优先国家在前)
137
+ const sortedCountries = computed(() => {
138
+ const priority = allCountries.value.filter(country =>
139
+ props.priorityCountries.includes(country.code)
140
+ )
141
+ const others = allCountries.value.filter(country =>
142
+ !props.priorityCountries.includes(country.code)
143
+ )
144
+
145
+ // 按优先级排序,然后按字母顺序排序其他国家
146
+ const sortedPriority = priority.sort((a, b) => {
147
+ const aIndex = props.priorityCountries.indexOf(a.code)
148
+ const bIndex = props.priorityCountries.indexOf(b.code)
149
+ return aIndex - bIndex
150
+ })
151
+
152
+ const sortedOthers = others.sort((a, b) => a.name.localeCompare(b.name))
153
+
154
+ return [...sortedPriority, ...sortedOthers]
155
+ })
156
+
157
+ // 下拉框操作
158
+ const toggleDropdown = () => {
159
+ if (!props.disabled) {
160
+ isOpen.value = !isOpen.value
161
+ }
162
+ }
163
+
164
+ const closeDropdown = () => {
165
+ isOpen.value = false
166
+ }
167
+
168
+ const selectCountry = (country: Country | null) => {
169
+ if (country) {
170
+ const value = props.returnType === 'code' ? country.code : country.name
171
+ emit('update:modelValue', value)
172
+ emit('change', value, country)
173
+ } else {
174
+ emit('update:modelValue', '')
175
+ }
176
+ closeDropdown()
177
+ }
178
+
179
+ // 图片错误处理:SVG失败时切换到PNG
180
+ const handleFlagError = (event: Event, country?: Country) => {
181
+ const img = event.target as HTMLImageElement
182
+ if (country && img.src === country.flagSvg) {
183
+ img.src = country.flagPng
184
+ } else if (selectedCountry.value && img.src === selectedCountry.value.flagSvg) {
185
+ img.src = selectedCountry.value.flagPng
186
+ }
187
+ }
188
+
189
+ /**
190
+ * IP自动检测国家
191
+ * 支持自定义后端接口,默认使用 ipinfo.io
192
+ *
193
+ * @example 使用默认接口
194
+ * <XCountrySelect auto-detect-from-ip />
195
+ *
196
+ * @example 使用自定义后端接口
197
+ * <XCountrySelect
198
+ * auto-detect-from-ip
199
+ * ip-detect-url="https://api.example.com/ip/location"
200
+ * :ip-detect-response-parser="(data) => data.countryCode"
201
+ * />
202
+ */
203
+ const detectCountryFromIp = async () => {
204
+ if (!props.autoDetectFromIp || props.modelValue) return
205
+
206
+ emit('ip-detect-start')
207
+
208
+ try {
209
+ // 构建请求配置
210
+ const fetchOptions: RequestInit = {
211
+ method: props.ipDetectMethod,
212
+ headers: {
213
+ 'Content-Type': 'application/json',
214
+ ...props.ipDetectHeaders
215
+ }
216
+ }
217
+
218
+ // 发起请求
219
+ const response = await fetch(props.ipDetectUrl, fetchOptions)
220
+
221
+ if (!response.ok) {
222
+ throw new Error(`HTTP error! status: ${response.status}`)
223
+ }
224
+
225
+ const data = await response.json()
226
+
227
+ // 解析国家代码
228
+ let detectedCountryCode: string
229
+
230
+ if (props.ipDetectResponseParser) {
231
+ // 使用自定义解析函数
232
+ detectedCountryCode = props.ipDetectResponseParser(data)
233
+ } else {
234
+ // 默认解析逻辑(适配 ipinfo.io 和常见后端格式)
235
+ detectedCountryCode = data.country || data.countryCode || data.country_code || ''
236
+ }
237
+
238
+ detectedCountryCode = detectedCountryCode.toUpperCase()
239
+
240
+ if (!detectedCountryCode) {
241
+ throw new Error('Country code not found in response')
242
+ }
243
+
244
+ console.log('Auto-detected country from IP:', detectedCountryCode)
245
+ emit('ip-detect-success', detectedCountryCode)
246
+
247
+ // 查找对应的国家
248
+ const detectedCountry = allCountries.value.find(country =>
249
+ country.code === detectedCountryCode
250
+ )
251
+
252
+ if (detectedCountry) {
253
+ const valueToEmit = props.returnType === 'code' ? detectedCountry.code : detectedCountry.name
254
+ emit('update:modelValue', valueToEmit)
255
+ emit('change', valueToEmit, detectedCountry)
256
+ } else {
257
+ console.warn('Detected country code not found in country list:', detectedCountryCode)
258
+ }
259
+ } catch (error) {
260
+ const err = error instanceof Error ? error : new Error(String(error))
261
+ console.warn('Failed to detect country from IP:', err)
262
+ emit('ip-detect-error', err)
263
+
264
+ // 如果检测失败且没有初始值,可选择默认国家(默认不自动设置)
265
+ // 取消注释以下代码可启用默认国家逻辑
266
+ /*
267
+ const defaultCountry = allCountries.value.find(country => country.code === 'US')
268
+ if (defaultCountry && !props.modelValue) {
269
+ const valueToEmit = props.returnType === 'code' ? defaultCountry.code : defaultCountry.name
270
+ emit('update:modelValue', valueToEmit)
271
+ emit('change', valueToEmit, defaultCountry)
272
+ }
273
+ */
274
+ }
275
+ }
276
+
277
+ // 点击外部关闭下拉框
278
+ const containerRef = ref<HTMLElement>()
279
+
280
+ // 生命周期
281
+ onMounted(async () => {
282
+ await nextTick()
283
+
284
+ // 如果启用IP检测且没有初始值,尝试自动检测
285
+ if (props.autoDetectFromIp && !props.modelValue) {
286
+ await detectCountryFromIp()
287
+ }
288
+
289
+ // 设置点击外部关闭下拉框
290
+ const handleClickOutside = (event: Event) => {
291
+ if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
292
+ closeDropdown()
293
+ }
294
+ }
295
+
296
+ document.addEventListener('click', handleClickOutside)
297
+
298
+ onUnmounted(() => {
299
+ document.removeEventListener('click', handleClickOutside)
300
+ })
301
+ })
302
+
303
+ // 暴露方法给父组件
304
+ defineExpose({
305
+ detectCountryFromIp,
306
+ allCountries: readonly(allCountries),
307
+ sortedCountries: readonly(sortedCountries)
308
+ })
309
+ </script>
310
+
311
+ <style scoped>
312
+ .country-select-container {
313
+ position: relative;
314
+ }
315
+
316
+ .country-select-container img {
317
+ flex-shrink: 0;
318
+ }
319
+ </style>
@@ -0,0 +1,293 @@
1
+ <template>
2
+ <div class="x-input-group" :style="{ width: width }">
3
+ <input
4
+ ref="inputRef"
5
+ :type="type"
6
+ :value="modelValue"
7
+ :placeholder="hasLabel ? '' : placeholder"
8
+ :disabled="disabled"
9
+ :readonly="readonly"
10
+ :required="required"
11
+ :maxlength="maxlength"
12
+ :minlength="minlength"
13
+ :autocomplete="autocomplete"
14
+ :class="[
15
+ 'x-input',
16
+ {
17
+ 'x-input--disabled': disabled,
18
+ 'x-input--error': error,
19
+ 'x-input--clearable': clearable && modelValue,
20
+ 'x-input--with-label': hasLabel
21
+ }
22
+ ]"
23
+ @input="handleInput"
24
+ @blur="handleBlur"
25
+ @focus="handleFocus"
26
+ @keydown="handleKeydown"
27
+ @keyup="handleKeyup"
28
+ @change="handleChange"
29
+ />
30
+ <label
31
+ v-if="label"
32
+ :class="[
33
+ 'x-input-label',
34
+ {
35
+ 'x-input-label--active': isFocused || hasValue,
36
+ 'x-input-label--error': error
37
+ }
38
+ ]"
39
+ >
40
+ {{ label }}
41
+ </label>
42
+
43
+ <!-- 清除按钮 -->
44
+ <button
45
+ v-if="clearable && modelValue && !disabled"
46
+ type="button"
47
+ class="x-input-clear"
48
+ @click="handleClear"
49
+ @mousedown.prevent
50
+ >
51
+ <Icon icon="bi:x-circle"></Icon>
52
+ </button>
53
+
54
+ <!-- 字数统计 -->
55
+ <div v-if="showWordLimit && maxlength" class="x-input-count">
56
+ {{ currentLength }} / {{ maxlength }}
57
+ </div>
58
+ </div>
59
+ </template>
60
+
61
+
62
+ <script setup lang="ts">
63
+ import { ref, computed } from 'vue'
64
+ import { Icon } from '@iconify/vue'
65
+ /**
66
+ * Input 输入框组件 适用于表单输入
67
+ * @displayName XInput
68
+ */
69
+
70
+ export interface InputProps {
71
+ modelValue?: string | number
72
+ type?: string
73
+ placeholder?: string
74
+ label?: string
75
+ disabled?: boolean
76
+ readonly?: boolean
77
+ required?: boolean
78
+ clearable?: boolean
79
+ showWordLimit?: boolean
80
+ maxlength?: number
81
+ minlength?: number
82
+ autocomplete?: string
83
+ error?: boolean
84
+ width?: string
85
+ }
86
+
87
+ const props = withDefaults(defineProps<InputProps>(), {
88
+ type: 'text',
89
+ placeholder: '',
90
+ label: '',
91
+ disabled: false,
92
+ readonly: false,
93
+ required: false,
94
+ clearable: false,
95
+ showWordLimit: false,
96
+ autocomplete: 'off',
97
+ error: false,
98
+ width: '100%'
99
+ })
100
+
101
+ const emit = defineEmits<{
102
+ 'update:modelValue': [value: string | number]
103
+ 'input': [value: string | number, event: Event]
104
+ 'change': [value: string | number, event: Event]
105
+ 'blur': [event: FocusEvent]
106
+ 'focus': [event: FocusEvent]
107
+ 'clear': []
108
+ 'keydown': [event: KeyboardEvent]
109
+ 'keyup': [event: KeyboardEvent]
110
+ }>()
111
+
112
+ const inputRef = ref<HTMLInputElement>()
113
+ const isFocused = ref(false)
114
+
115
+ // 计算属性
116
+ const hasLabel = computed(() => !!props.label)
117
+
118
+ const hasValue = computed(() => {
119
+ const value = props.modelValue
120
+ return value !== undefined && value !== null && value !== ''
121
+ })
122
+
123
+ const currentLength = computed(() => {
124
+ return String(props.modelValue || '').length
125
+ })
126
+
127
+ // 事件处理
128
+ const handleInput = (event: Event) => {
129
+ const target = event.target as HTMLInputElement
130
+ const value = target.value
131
+ emit('update:modelValue', value)
132
+ emit('input', value, event)
133
+ }
134
+
135
+ const handleChange = (event: Event) => {
136
+ const target = event.target as HTMLInputElement
137
+ const value = target.value
138
+ emit('change', value, event)
139
+ }
140
+
141
+ const handleBlur = (event: FocusEvent) => {
142
+ isFocused.value = false
143
+ emit('blur', event)
144
+ }
145
+
146
+ const handleFocus = (event: FocusEvent) => {
147
+ isFocused.value = true
148
+ emit('focus', event)
149
+ }
150
+
151
+ const handleKeydown = (event: KeyboardEvent) => {
152
+ emit('keydown', event)
153
+ }
154
+
155
+ const handleKeyup = (event: KeyboardEvent) => {
156
+ emit('keyup', event)
157
+ }
158
+
159
+ const handleClear = () => {
160
+ emit('update:modelValue', '')
161
+ emit('clear')
162
+ inputRef.value?.focus()
163
+ }
164
+
165
+ // 暴露方法
166
+ const focus = () => {
167
+ inputRef.value?.focus()
168
+ }
169
+
170
+ const blur = () => {
171
+ inputRef.value?.blur()
172
+ }
173
+
174
+ const select = () => {
175
+ inputRef.value?.select()
176
+ }
177
+
178
+ defineExpose({
179
+ focus,
180
+ blur,
181
+ select,
182
+ ref: inputRef
183
+ })
184
+ </script>
185
+ <style scoped>
186
+ .x-input-group {
187
+ font-family: var(--x-font-family, 'Segoe UI', sans-serif);
188
+ position: relative;
189
+ display: inline-block;
190
+ }
191
+
192
+ .x-input {
193
+ width: 100%;
194
+ font-size: var(--x-font-size-sm, 14px);
195
+ padding: 0.8em 1em;
196
+ outline: none;
197
+ border: 2px solid var(--x-color-gray-300, rgb(200, 200, 200));
198
+ background-color: transparent;
199
+ border-radius: var(--x-radius-lg, 12px);
200
+ transition: all var(--x-transition, 0.3s ease);
201
+ color: var(--x-color-gray-900, #1a1a1a);
202
+ }
203
+
204
+ .x-input:hover:not(:disabled) {
205
+ border-color: var(--x-color-gray-400, rgb(170, 170, 170));
206
+ }
207
+
208
+ .x-input:focus {
209
+ border-color: var(--x-color-primary, #1a1a1a);
210
+ box-shadow: 0 0 0 3px rgba(26, 26, 26, 0.1);
211
+ }
212
+
213
+ .x-input--disabled {
214
+ background-color: var(--x-color-gray-100, #f5f5f5);
215
+ cursor: not-allowed;
216
+ opacity: 0.6;
217
+ }
218
+
219
+ .x-input--error {
220
+ border-color: var(--x-color-danger, #ef4444);
221
+ }
222
+
223
+ .x-input--error:focus {
224
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
225
+ }
226
+
227
+ .x-input--clearable {
228
+ padding-right: 2.5em;
229
+ }
230
+
231
+ .x-input-label {
232
+ position: absolute;
233
+ left: 1em;
234
+ top: 50%;
235
+ transform: translateY(-50%);
236
+ padding: 0 0.4em;
237
+ font-size: var(--x-font-size-sm, 14px);
238
+ color: var(--x-color-gray-500, rgb(100, 100, 100));
239
+ background-color: white;
240
+ pointer-events: none;
241
+ transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
242
+ transform-origin: left center;
243
+ white-space: nowrap;
244
+ z-index: 1;
245
+ }
246
+
247
+ /* Label 浮动状态 */
248
+ .x-input-label--active {
249
+ top: 0;
250
+ transform: translateY(-50%) scale(0.85);
251
+ font-size: var(--x-font-size-xs, 12px);
252
+ color: var(--x-color-primary, #1a1a1a);
253
+ font-weight: 500;
254
+ }
255
+
256
+ /* Label 错误状态 */
257
+ .x-input-label--error.x-input-label--active {
258
+ color: var(--x-color-danger, #ef4444);
259
+ }
260
+
261
+ /* 禁用状态的 label */
262
+ .x-input--disabled ~ .x-input-label {
263
+ color: var(--x-color-gray-400, #aaa);
264
+ opacity: 0.6;
265
+ }
266
+
267
+ .x-input-clear {
268
+ position: absolute;
269
+ right: 0.8em;
270
+ top: 50%;
271
+ transform: translateY(-50%);
272
+ background: none;
273
+ border: none;
274
+ cursor: pointer;
275
+ padding: 0.2em;
276
+ color: var(--x-color-gray-400, #aaa);
277
+ transition: color 0.2s;
278
+ font-size: 1.2em;
279
+ line-height: 1;
280
+ }
281
+
282
+ .x-input-clear:hover {
283
+ color: var(--x-color-gray-600, #666);
284
+ }
285
+
286
+ .x-input-count {
287
+ position: absolute;
288
+ right: 0.8em;
289
+ bottom: -1.5em;
290
+ font-size: var(--x-font-size-xs, 12px);
291
+ color: var(--x-color-gray-500, #888);
292
+ }
293
+ </style>