niuma-ui 1.0.2 → 1.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.
Files changed (85) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/RsDatePicker.spec.ts +21 -0
  4. package/src/__tests__/RsDialog.spec.ts +61 -0
  5. package/src/__tests__/RsForm.spec.ts +5 -5
  6. package/src/__tests__/RsFormItem.spec.ts +478 -0
  7. package/src/__tests__/RsFormList.spec.ts +109 -0
  8. package/src/__tests__/RsInput.spec.ts +93 -0
  9. package/src/__tests__/RsInputNumber.spec.ts +33 -0
  10. package/src/__tests__/RsSelect.spec.ts +423 -1
  11. package/src/__tests__/RsSwitch.spec.ts +30 -0
  12. package/src/__tests__/RsTimePicker.spec.ts +9 -0
  13. package/src/__tests__/RsTooltip.spec.ts +43 -0
  14. package/src/__tests__/css-token.spec.ts +51 -0
  15. package/src/__tests__/date-picker-utils.spec.ts +49 -0
  16. package/src/__tests__/dialog-utils.spec.ts +43 -0
  17. package/src/__tests__/form-path.spec.ts +46 -0
  18. package/src/__tests__/form-rules.spec.ts +63 -0
  19. package/src/__tests__/theme.spec.ts +2 -0
  20. package/src/components/RsAlert.vue +1 -1
  21. package/src/components/RsAvatar.vue +1 -1
  22. package/src/components/RsBadge.vue +1 -1
  23. package/src/components/RsBreadcrumb.vue +1 -1
  24. package/src/components/RsButton.vue +4 -4
  25. package/src/components/RsCalendarGrid.vue +3 -3
  26. package/src/components/RsCard.vue +4 -4
  27. package/src/components/RsCodeBlock.vue +4 -4
  28. package/src/components/RsCodeEditor.vue +8 -8
  29. package/src/components/RsConfirmDialog.vue +2 -2
  30. package/src/components/RsContextMenu.vue +6 -4
  31. package/src/components/RsDatePicker.vue +75 -31
  32. package/src/components/RsDateTimePicker.vue +9 -1
  33. package/src/components/RsDescriptions.vue +2 -2
  34. package/src/components/RsDialog.vue +26 -16
  35. package/src/components/RsDrawer.vue +1 -1
  36. package/src/components/RsDropdown.vue +1 -1
  37. package/src/components/RsDynamicTags.vue +18 -4
  38. package/src/components/RsEmpty.vue +1 -1
  39. package/src/components/RsForm.vue +68 -1
  40. package/src/components/RsFormItem.vue +423 -0
  41. package/src/components/RsFormItemControl.vue +21 -0
  42. package/src/components/RsFormList.vue +117 -0
  43. package/src/components/RsInput.vue +411 -18
  44. package/src/components/RsInputNumber.vue +13 -4
  45. package/src/components/RsLink.vue +1 -1
  46. package/src/components/RsMarkdown.vue +5 -5
  47. package/src/components/RsMenu.vue +2 -2
  48. package/src/components/RsMonacoEditor.vue +9 -3
  49. package/src/components/RsPagination.vue +1 -1
  50. package/src/components/RsProseEditor.vue +3 -3
  51. package/src/components/RsSelect.vue +332 -611
  52. package/src/components/RsSidebarGroup.vue +1 -1
  53. package/src/components/RsSplitPane.vue +22 -1
  54. package/src/components/RsStatCard.vue +2 -2
  55. package/src/components/RsSteps.vue +2 -2
  56. package/src/components/RsSwitch.vue +20 -7
  57. package/src/components/RsTabs.vue +9 -9
  58. package/src/components/RsTag.vue +1 -1
  59. package/src/components/RsTerminal.vue +58 -19
  60. package/src/components/RsTimePicker.vue +28 -4
  61. package/src/components/RsTimePickerColumns.vue +4 -4
  62. package/src/components/RsToaster.vue +7 -7
  63. package/src/components/RsTooltip.vue +10 -1
  64. package/src/components/RsTooltipProvider.vue +11 -1
  65. package/src/components/RsTree.vue +6 -6
  66. package/src/components/RsUpload.vue +2 -2
  67. package/src/components/date-picker-utils.ts +113 -2
  68. package/src/components/dialog-utils.ts +37 -2
  69. package/src/components/form-item-control.ts +55 -0
  70. package/src/components/form-path.ts +92 -0
  71. package/src/components/form-rules.ts +64 -10
  72. package/src/components/form-utils.ts +94 -2
  73. package/src/components/select-utils.ts +272 -12
  74. package/src/components/table/RsTableHeaderFilter.vue +1 -1
  75. package/src/components/table/rs-table.css +1 -1
  76. package/src/components/use-rs-select.ts +436 -0
  77. package/src/composables/useRsConfig.ts +10 -3
  78. package/src/composables/useRsI18n.ts +16 -5
  79. package/src/index.ts +69 -1
  80. package/src/locale/interpolate.ts +32 -0
  81. package/src/locale/messages.ts +10 -0
  82. package/src/styles.css +68 -12
  83. package/src/theme/brand.example.css +4 -1
  84. package/src/theme/css-token.ts +107 -0
  85. package/src/theme/types.ts +1 -0
@@ -1,13 +1,17 @@
1
1
  <script setup lang="ts">
2
-
3
- import { computed, ref, watch } from 'vue'
2
+ import { computed, ref, useAttrs } from 'vue'
4
3
 
5
4
  import { useRsI18n } from '../composables/useRsI18n'
6
5
  import type { RsComponentSize, RsRadius } from '../theme/types'
7
6
  import { RS_COMPONENT_SIZE_ICON_PX } from '../theme/types'
8
7
 
9
8
  import RsIcon from './RsIcon.vue'
10
- import { useRsFormContext, useRsFormField } from './form-utils'
9
+ import {
10
+ isRsFormItemBoundControl,
11
+ useRsFormContext,
12
+ useRsFormField,
13
+ useRsFormItemContext,
14
+ } from './form-utils'
11
15
  import {
12
16
  buildLocalInputRules,
13
17
  runFormFieldRules,
@@ -15,371 +19,206 @@ import {
15
19
  } from './form-rules'
16
20
  import { useResolvedRsComponentSize } from './resolve-size'
17
21
  import { rsRadiusCss, useResolvedRsRadius } from './resolve-radius'
18
-
19
22
  import {
20
- buildOptionDisabledMap,
21
- buildOptionLabelMap,
22
- filterSelectOptions,
23
- flattenSelectOptions,
24
- flattenSelectValues,
25
- fromComboboxValue,
26
23
  isSelectOptionGroup,
27
24
  toComboboxValue,
28
- type RsSelectOptions,
25
+ type RsSelectFieldNames,
26
+ type RsSelectFilterOption,
27
+ type RsSelectFilterSort,
28
+ type RsSelectGetPopupContainer,
29
+ type RsSelectModelValue,
30
+ type RsSelectOption,
31
+ type RsSelectOptionFilterProp,
32
+ type RsSelectOptionsInput,
33
+ type RsSelectPlacement,
34
+ type RsSelectStatus,
35
+ type RsSelectValue,
29
36
  } from './select-utils'
30
-
37
+ import { useRsSelect } from './use-rs-select'
31
38
  import {
32
-
33
39
  ComboboxAnchor,
34
-
35
40
  ComboboxContent,
36
-
37
41
  ComboboxEmpty,
38
-
39
42
  ComboboxGroup,
40
-
41
43
  ComboboxInput,
42
-
43
44
  ComboboxItem,
44
-
45
45
  ComboboxItemIndicator,
46
-
47
46
  ComboboxLabel,
48
-
49
47
  ComboboxPortal,
50
-
51
48
  ComboboxRoot,
52
-
53
49
  ComboboxTrigger,
54
-
55
50
  ComboboxViewport,
56
-
57
51
  ComboboxVirtualizer,
58
-
59
- useFilter,
60
-
61
52
  } from './reka'
62
53
 
54
+ defineOptions({ inheritAttrs: false })
63
55
 
64
-
65
- const model = defineModel<string | string[]>({ default: '' })
66
-
67
-
56
+ const model = defineModel<RsSelectModelValue>({ default: '' })
57
+ const open = defineModel<boolean>('open', { default: false })
58
+ const searchQuery = defineModel<string>('searchValue', { default: '' })
68
59
 
69
60
  const props = withDefaults(
70
-
71
61
  defineProps<{
72
-
73
- options: RsSelectOptions
74
-
62
+ options: RsSelectOptionsInput
75
63
  placeholder?: string
76
-
77
64
  disabled?: boolean
78
-
79
65
  id?: string
80
-
81
- /** 下拉内搜索过滤 */
82
-
83
66
  searchable?: boolean
84
-
85
67
  /**
86
68
  * 允许把搜索框内容作为自定义值提交(Enter 或点「使用 xxx」)。
87
- * 专业工具常见:类型名 / 枚举等不在预设列表时仍可手输。
88
69
  * 开启后若未显式关 searchable,将自动启用搜索框。
89
70
  */
90
71
  creatable?: boolean
91
-
92
- /** 多选,v-model 为 string[] */
93
-
94
72
  multiple?: boolean
95
73
  required?: boolean
96
- /** 字段名:匹配 RsForm.rules[name] */
97
74
  name?: string
98
-
99
- /** 显示清空按钮 */
100
-
101
75
  clearable?: boolean
102
-
103
- /** 启用虚拟滚动(长列表) */
104
-
105
76
  virtual?: boolean
106
-
107
- /** 超过该数量自动启用虚拟滚动 */
108
-
109
77
  virtualThreshold?: number
110
-
111
- /** 远程搜索:关闭内置过滤,通过 @search 拉取 options */
112
-
113
78
  remote?: boolean
114
-
115
- /** 远程加载中 */
116
-
117
79
  loading?: boolean
118
-
119
80
  searchPlaceholder?: string
120
-
121
81
  emptyText?: string
122
-
123
82
  loadingText?: string
124
-
125
83
  size?: RsComponentSize
126
-
127
- /** 圆角档位;默认 sm。直角 UI 传 `none`。 */
128
-
129
84
  radius?: RsRadius
130
-
131
85
  /**
132
86
  * 下拉面板是否与触发器等宽。
133
- *
134
- * - 默认 `false`:面板 `min-width` 对齐触发器,可随选项文案变宽(受 max-width 限制),避免长文案大量省略。
135
- * - `true`:面板强制与触发器同宽,适合表单栅格、筛选条等需要对齐的场景;长文案仍会 ellipsis。
136
- *
137
- * 注意:开启虚拟滚动时,列表项多为绝对定位,无法可靠把面板撑到「全局最长文案」宽度,
138
- * 宽度可能随可见项变化或退回到触发器宽度;长列表若强依赖内容撑宽,请关闭 virtual,
139
- * 或改用 `matchTriggerWidth` + 截断项 Tooltip。
87
+ * 默认 false:面板可随文案变宽;true:与触发器同宽。
140
88
  */
141
89
  matchTriggerWidth?: boolean
142
-
143
- /** 根节点占满父级宽度(与按钮并排时用)。 */
144
90
  block?: boolean
145
-
91
+ invalid?: boolean
92
+ showValidateMessage?: boolean
93
+ filterOption?: RsSelectFilterOption | boolean
94
+ optionFilterProp?: RsSelectOptionFilterProp
95
+ maxTagCount?: number
96
+ maxTagPlaceholder?: string | ((omitted: number) => string)
97
+ multipleLimit?: number
98
+ labelInValue?: boolean
99
+ filterSort?: RsSelectFilterSort
100
+ maxTagTextLength?: number
101
+ maxTagTooltip?: boolean
102
+ tokenSeparators?: string[]
103
+ autoClearSearchValue?: boolean
104
+ showArrow?: boolean
105
+ listHeight?: number
106
+ placement?: RsSelectPlacement
107
+ popupClassName?: string
108
+ status?: RsSelectStatus
109
+ /** 把业务字段映射为 label / value / options,对齐 Ant fieldNames */
110
+ fieldNames?: RsSelectFieldNames
111
+ /** 触发器展示用的选项字段,默认 label。对齐 Ant optionLabelProp */
112
+ optionLabelProp?: string
113
+ /** 下拉挂载容器,对齐 Ant getPopupContainer */
114
+ getPopupContainer?: RsSelectGetPopupContainer
146
115
  }>(),
147
-
148
116
  {
149
-
150
117
  disabled: false,
151
-
152
118
  searchable: false,
153
-
154
119
  creatable: false,
155
-
156
120
  multiple: false,
157
121
  required: false,
158
-
159
122
  clearable: false,
160
-
161
123
  virtual: false,
162
-
163
124
  virtualThreshold: 50,
164
-
165
125
  remote: false,
166
-
167
126
  loading: false,
168
-
169
127
  matchTriggerWidth: false,
170
-
171
128
  block: false,
172
-
129
+ optionFilterProp: 'label',
130
+ optionLabelProp: 'label',
131
+ labelInValue: false,
132
+ maxTagTooltip: true,
133
+ autoClearSearchValue: true,
134
+ showArrow: true,
135
+ listHeight: 256,
136
+ placement: 'bottom',
137
+ status: '',
173
138
  },
174
-
175
139
  )
176
140
 
177
-
178
-
179
141
  const emit = defineEmits<{
180
-
181
142
  search: [query: string]
182
-
143
+ select: [value: RsSelectValue, option?: RsSelectOption]
144
+ deselect: [value: RsSelectValue, option?: RsSelectOption]
145
+ clear: []
183
146
  }>()
184
147
 
185
-
186
-
187
148
  const { t } = useRsI18n()
149
+ const attrs = useAttrs()
188
150
  const formContext = useRsFormContext()
189
-
190
- const { contains } = useFilter({ sensitivity: 'base' })
191
-
192
-
193
-
194
- const searchQuery = ref('')
195
-
196
- /** 下拉显隐;支持 v-model:open(如表格多选关闭时提交) */
197
- const open = defineModel<boolean>('open', { default: false })
198
-
199
-
200
-
201
- const resolvedPlaceholder = computed(() => props.placeholder ?? t('select.placeholder'))
202
-
203
- const resolvedSearchPlaceholder = computed(
204
-
205
- () => props.searchPlaceholder ?? t('select.searchPlaceholder'),
206
-
207
- )
208
-
209
- const resolvedEmptyText = computed(() => props.emptyText ?? t('select.empty'))
210
-
211
- const resolvedLoadingText = computed(() => props.loadingText ?? t('select.loading'))
212
-
213
- /** creatable 默认带搜索框,便于手输 */
214
- const isSearchable = computed(() => props.searchable || props.creatable)
215
-
216
-
217
-
218
- const labelMap = computed(() => buildOptionLabelMap(props.options))
219
-
220
- const disabledMap = computed(() => buildOptionDisabledMap(props.options))
221
-
222
- const flatOptions = computed(() => flattenSelectOptions(props.options))
223
-
224
- const flatCount = computed(() => flatOptions.value.length)
225
-
226
- const useVirtual = computed(
227
- () => props.virtual || flatCount.value > props.virtualThreshold,
151
+ const formItem = useRsFormItemContext()
152
+ const boundToItem = computed(() =>
153
+ isRsFormItemBoundControl(formItem, { id: props.id, name: props.name }),
228
154
  )
155
+ const anchorRef = ref<{ $el?: HTMLElement } | HTMLElement | null>(null)
156
+
157
+ const {
158
+ resolvedPlaceholder,
159
+ resolvedSearchPlaceholder,
160
+ resolvedEmptyText,
161
+ resolvedLoadingText,
162
+ isSearchable,
163
+ labelMap,
164
+ useVirtual,
165
+ useManualFilter,
166
+ displayOptions,
167
+ createValue,
168
+ canCreate,
169
+ createOptionLabel,
170
+ virtualValues,
171
+ selectedValues,
172
+ hasValue,
173
+ visibleTagTokens,
174
+ omittedTagCount,
175
+ omittedTagLabel,
176
+ omittedTagTitle,
177
+ singleDisplayLabel,
178
+ comboboxModel,
179
+ tokenLabel,
180
+ truncateTagLabel,
181
+ optionFromToken,
182
+ optionOrCreate,
183
+ isOptionLimited,
184
+ isTokenDisabled,
185
+ restoreTokenValue,
186
+ onSearchKeydown,
187
+ onClear,
188
+ removeTag,
189
+ setValue,
190
+ resetSearch,
191
+ } = useRsSelect(props, model, open, searchQuery, emit, t)
229
192
 
230
- const useManualFilter = computed(() => props.remote || useVirtual.value)
231
-
232
- const displayOptions = computed(() => {
233
- if (props.remote) return props.options
234
- if (!useManualFilter.value) return props.options
235
- return filterSelectOptions(props.options, searchQuery.value, contains)
236
- })
237
-
238
- const createValue = computed(() => searchQuery.value.trim())
239
-
240
- const canCreate = computed(() => {
241
- if (!props.creatable) return false
242
- const q = createValue.value
243
- if (!q) return false
244
- const lower = q.toLowerCase()
245
- return !flatOptions.value.some(
246
- (opt) => opt.value.toLowerCase() === lower || String(opt.label).toLowerCase() === lower,
247
- )
248
- })
249
-
250
- const createOptionLabel = computed(() =>
251
- t('select.createOption', '使用 “{query}”').replace('{query}', createValue.value),
252
- )
253
-
254
- const virtualValues = computed(() => {
255
- const values = flattenSelectValues(displayOptions.value)
256
- if (!canCreate.value) return values
257
- return [createValue.value, ...values.filter((v) => String(v) !== createValue.value)]
258
- })
259
-
260
- const selectedValues = computed<string[]>(() => {
261
- if (props.multiple) {
262
- const value = model.value
263
- if (Array.isArray(value)) return value.map(String)
264
- return value ? [String(value)] : []
265
- }
266
-
267
- const value = model.value
268
- // '' 表示未选中
269
- return value !== '' && value !== undefined && value !== null ? [String(value)] : []
270
- })
271
-
272
- const hasValue = computed(() => selectedValues.value.length > 0)
273
193
  const resolvedDisabled = computed(() => props.disabled || formContext?.disabled.value || false)
274
194
  const resolvedSize = useResolvedRsComponentSize(() => props.size)
275
195
  const resolvedRadius = useResolvedRsRadius(() => props.radius, 'sm')
276
196
  const triggerIconSize = computed(() => RS_COMPONENT_SIZE_ICON_PX[resolvedSize.value])
277
197
  const rootStyle = computed(() => ({
278
198
  '--rs-select-radius': rsRadiusCss(resolvedRadius.value),
199
+ '--rs-select-list-height': `${props.listHeight}px`,
279
200
  }))
280
201
 
281
- const singleDisplayLabel = computed(() => {
282
- if (props.multiple || !hasValue.value) return ''
283
- return labelMap.value.get(selectedValues.value[0]!) ?? selectedValues.value[0]!
284
- })
285
-
286
- const comboboxModel = computed<string | string[] | undefined>({
287
- get() {
288
- if (props.multiple) return selectedValues.value.map(toComboboxValue)
289
- return hasValue.value ? toComboboxValue(selectedValues.value[0]!) : undefined
290
- },
291
- set(value) {
292
- if (props.multiple) {
293
- model.value = Array.isArray(value)
294
- ? value.map((item) => fromComboboxValue(String(item)))
295
- : []
296
- return
297
- }
298
- // Reka 清空 / 空串哨兵 → 对外 ''
299
- if (value == null || value === '') {
300
- model.value = ''
301
- return
302
- }
303
- model.value = fromComboboxValue(String(value))
304
- },
305
- })
306
-
307
-
308
-
309
- watch(searchQuery, (query) => {
310
-
311
- if (props.remote) emit('search', query)
312
-
313
- })
314
-
315
-
316
-
317
- watch(open, (isOpen) => {
318
- if (isOpen) searchQuery.value = ''
202
+ const portalTo = computed(() => {
203
+ if (!props.getPopupContainer) return undefined
204
+ const raw = anchorRef.value
205
+ const el = raw instanceof HTMLElement ? raw : raw?.$el
206
+ return props.getPopupContainer(el instanceof HTMLElement ? el : undefined)
319
207
  })
320
208
 
321
- function commitCreatedValue(value: string): void {
322
- const next = value.trim()
323
- if (!next) return
324
- if (props.multiple) {
325
- const current = selectedValues.value
326
- model.value = current.includes(next) ? current : [...current, next]
327
- } else {
328
- model.value = next
329
- open.value = false
330
- }
331
- searchQuery.value = ''
332
- }
333
-
334
- function onSearchKeydown(event: KeyboardEvent): void {
335
- if (event.key !== 'Enter' || event.isComposing) return
336
- if (!canCreate.value) return
337
- // 无精确匹配时 Enter 提交手输值;有匹配时交给 Combobox 默认选中行为
338
- event.preventDefault()
339
- event.stopPropagation()
340
- commitCreatedValue(createValue.value)
341
- }
342
-
343
-
344
-
345
- function onClear(event: MouseEvent) {
346
- event.preventDefault()
347
- event.stopPropagation()
348
- model.value = props.multiple ? [] : ''
349
- }
350
-
351
-
352
-
353
- function removeTag(value: string, event: MouseEvent) {
354
-
355
- event.preventDefault()
356
-
357
- event.stopPropagation()
358
-
359
- if (!props.multiple) return
360
-
361
- model.value = selectedValues.value.filter((item) => item !== value)
362
-
363
- }
364
-
365
- function setValue(value: unknown): void {
366
- if (props.multiple) {
367
- if (Array.isArray(value)) {
368
- model.value = value.map(String)
369
- } else if (value) {
370
- model.value = [String(value)]
371
- } else {
372
- model.value = []
373
- }
374
- return
375
- }
376
- model.value = value == null ? '' : String(value)
377
- }
378
-
379
209
  const autoMessage = ref('')
210
+ const isInvalid = computed(() =>
211
+ Boolean(
212
+ props.invalid ||
213
+ props.status === 'error' ||
214
+ (boundToItem.value && formItem?.invalid.value) ||
215
+ autoMessage.value,
216
+ ),
217
+ )
218
+ const isWarning = computed(() => props.status === 'warning' && !isInvalid.value)
380
219
 
381
220
  function clearValidation(): void {
382
- searchQuery.value = ''
221
+ resetSearch()
383
222
  autoMessage.value = ''
384
223
  }
385
224
 
@@ -414,358 +253,298 @@ defineExpose({
414
253
  clearValidation,
415
254
  validate: runValidate,
416
255
  })
417
-
418
256
  </script>
419
257
 
420
-
421
-
422
258
  <template>
423
-
424
259
  <ComboboxRoot
425
-
426
260
  v-model="comboboxModel"
427
-
428
261
  v-model:open="open"
429
-
430
262
  class="rs-select"
431
-
432
263
  :class="{
433
-
434
264
  'rs-select--multiple': multiple,
435
-
436
265
  'rs-select--searchable': isSearchable,
437
266
  'rs-select--creatable': creatable,
438
-
439
267
  'rs-select--block': block,
440
-
441
268
  [`rs-select--${resolvedSize}`]: true,
442
-
443
269
  }"
444
-
445
270
  :style="rootStyle"
446
-
447
271
  :multiple="multiple"
448
-
449
272
  :disabled="resolvedDisabled"
450
-
451
273
  :ignore-filter="useManualFilter"
452
-
453
- :reset-search-term-on-select="!multiple"
454
-
274
+ :reset-search-term-on-select="autoClearSearchValue"
455
275
  open-on-click
456
-
457
276
  >
458
-
459
- <ComboboxAnchor as-child>
460
-
461
- <ComboboxTrigger :id="id" class="rs-select__trigger">
277
+ <ComboboxAnchor ref="anchorRef" as-child>
278
+ <ComboboxTrigger
279
+ v-bind="attrs"
280
+ :id="id"
281
+ class="rs-select__trigger"
282
+ :class="{
283
+ 'rs-select__trigger--invalid': isInvalid,
284
+ 'rs-select__trigger--warning': isWarning,
285
+ }"
286
+ :aria-invalid="isInvalid || undefined"
287
+ >
288
+ <span v-if="$slots.prefix" class="rs-select__prefix">
289
+ <slot name="prefix" />
290
+ </span>
462
291
 
463
292
  <span v-if="multiple" class="rs-select__value rs-select__value--multiple">
464
-
465
293
  <template v-if="hasValue">
466
-
467
294
  <span
468
-
469
- v-for="value in selectedValues"
470
-
295
+ v-for="value in visibleTagTokens"
471
296
  :key="value"
472
-
473
297
  class="rs-select__tag"
474
-
475
298
  >
476
-
477
- <span class="rs-select__tag-label">{{ labelMap.get(value) ?? value }}</span>
478
-
479
- <button
480
-
481
- type="button"
482
-
483
- class="rs-select__tag-remove"
484
-
485
- :aria-label="t('select.clear')"
486
-
487
- @pointerdown.stop
488
-
489
- @click="removeTag(value, $event)"
490
-
299
+ <slot
300
+ name="tag"
301
+ :value="restoreTokenValue(value)"
302
+ :label="tokenLabel(value)"
303
+ :closable="true"
491
304
  >
492
-
493
- <RsIcon name="x" :size="12" />
494
-
495
- </button>
496
-
305
+ <span class="rs-select__tag-label">{{ truncateTagLabel(tokenLabel(value)) }}</span>
306
+ <button
307
+ type="button"
308
+ class="rs-select__tag-remove"
309
+ :aria-label="t('select.clear')"
310
+ @pointerdown.stop
311
+ @click="removeTag(value, $event)"
312
+ >
313
+ <RsIcon name="x" :size="12" />
314
+ </button>
315
+ </slot>
316
+ </span>
317
+ <span
318
+ v-if="omittedTagCount > 0"
319
+ class="rs-select__tag rs-select__tag--rest"
320
+ :title="omittedTagTitle"
321
+ >
322
+ <slot name="maxTagPlaceholder" :omitted="omittedTagCount">
323
+ {{ omittedTagLabel }}
324
+ </slot>
497
325
  </span>
498
-
499
326
  </template>
500
-
501
327
  <span v-else class="rs-select__placeholder">{{ resolvedPlaceholder }}</span>
502
-
503
328
  </span>
504
329
 
505
330
  <span v-else class="rs-select__value">
506
-
507
331
  <span v-if="hasValue" class="rs-select__single-label">{{ singleDisplayLabel }}</span>
508
-
509
332
  <span v-else class="rs-select__placeholder">{{ resolvedPlaceholder }}</span>
510
-
511
333
  </span>
512
334
 
513
-
514
-
515
335
  <span class="rs-select__actions">
516
-
517
336
  <button
518
-
519
337
  v-if="clearable && hasValue && !resolvedDisabled"
520
-
521
338
  type="button"
522
-
523
339
  class="rs-select__clear"
524
-
525
340
  :aria-label="t('select.clear')"
526
-
527
341
  @pointerdown.stop
528
-
529
342
  @click="onClear"
530
-
531
343
  >
532
-
533
- <RsIcon name="x" :size="14" />
534
-
344
+ <slot name="clearIcon">
345
+ <RsIcon name="x" :size="14" />
346
+ </slot>
535
347
  </button>
536
-
537
- <RsIcon name="chevron-down" :size="triggerIconSize" class="rs-select__icon" />
538
-
348
+ <span v-if="showArrow" class="rs-select__suffix">
349
+ <slot name="suffixIcon">
350
+ <RsIcon name="chevron-down" :size="triggerIconSize" class="rs-select__icon" />
351
+ </slot>
352
+ </span>
539
353
  </span>
540
-
541
354
  </ComboboxTrigger>
542
-
543
355
  </ComboboxAnchor>
544
356
 
545
-
546
-
547
- <ComboboxPortal>
548
-
357
+ <ComboboxPortal :to="portalTo">
549
358
  <ComboboxContent
550
359
  class="rs-select__content"
551
- :class="{ 'rs-select__content--match-trigger': matchTriggerWidth }"
360
+ :class="[
361
+ popupClassName,
362
+ { 'rs-select__content--match-trigger': matchTriggerWidth },
363
+ ]"
364
+ :style="{ '--rs-select-list-height': `${listHeight}px` }"
552
365
  align="start"
366
+ :side="placement"
553
367
  :side-offset="4"
554
368
  position="popper"
555
369
  >
556
-
557
- <div v-if="isSearchable" class="rs-select__search-bar">
558
- <div class="rs-select__search-wrap">
559
- <RsIcon name="search" :size="14" class="rs-select__search-icon" aria-hidden="true" />
560
- <ComboboxInput
561
- v-model="searchQuery"
562
- class="rs-select__search"
563
- :placeholder="resolvedSearchPlaceholder"
564
- auto-focus
565
- @keydown="onSearchKeydown"
566
- />
370
+ <slot name="dropdownRender">
371
+ <div v-if="$slots.header" class="rs-select__panel-header" @mousedown.prevent>
372
+ <slot name="header" />
567
373
  </div>
568
- </div>
569
-
570
-
571
-
572
- <div v-if="loading" class="rs-select__status">{{ resolvedLoadingText }}</div>
573
-
574
-
575
-
576
- <ComboboxEmpty v-if="!loading && !canCreate" class="rs-select__empty">
577
-
578
- {{ resolvedEmptyText }}
579
-
580
- </ComboboxEmpty>
581
-
582
-
583
-
584
- <ComboboxViewport class="rs-select__viewport">
585
-
586
- <ComboboxVirtualizer
587
-
588
- v-if="useVirtual"
589
-
590
- v-slot="{ option }"
591
-
592
- :options="virtualValues"
593
-
594
- :text-content="(value) => labelMap.get(String(value)) ?? String(value)"
595
-
596
- :estimate-size="36"
597
-
598
- >
599
-
600
- <ComboboxItem
601
- :value="option"
602
- :disabled="disabledMap.get(String(option))"
603
- :text-value="labelMap.get(String(option)) ?? String(option)"
604
- class="rs-select__item"
605
- :class="{ 'rs-select__item--create': canCreate && String(option) === createValue }"
606
- >
607
- <span class="rs-select__item-label">
608
- {{
609
- canCreate && String(option) === createValue
610
- ? createOptionLabel
611
- : (labelMap.get(String(option)) ?? fromComboboxValue(String(option)))
612
- }}
613
- </span>
614
- <ComboboxItemIndicator class="rs-select__item-check">
615
- <RsIcon name="check" :size="14" />
616
- </ComboboxItemIndicator>
617
- </ComboboxItem>
618
-
619
- </ComboboxVirtualizer>
620
-
621
374
 
375
+ <div v-if="isSearchable" class="rs-select__search-bar">
376
+ <div class="rs-select__search-wrap">
377
+ <RsIcon name="search" :size="14" class="rs-select__search-icon" aria-hidden="true" />
378
+ <ComboboxInput
379
+ v-model="searchQuery"
380
+ class="rs-select__search"
381
+ :placeholder="resolvedSearchPlaceholder"
382
+ auto-focus
383
+ @keydown="onSearchKeydown"
384
+ />
385
+ </div>
386
+ </div>
622
387
 
623
- <template v-else>
388
+ <div v-if="loading" class="rs-select__status">
389
+ <slot name="loading">{{ resolvedLoadingText }}</slot>
390
+ </div>
624
391
 
625
- <ComboboxItem
626
- v-if="canCreate"
627
- :value="createValue"
628
- :text-value="createValue"
629
- class="rs-select__item rs-select__item--create"
392
+ <ComboboxEmpty v-if="!loading && !canCreate" class="rs-select__empty">
393
+ <slot name="empty">{{ resolvedEmptyText }}</slot>
394
+ </ComboboxEmpty>
395
+
396
+ <ComboboxViewport class="rs-select__viewport">
397
+ <ComboboxVirtualizer
398
+ v-if="useVirtual"
399
+ v-slot="{ option }"
400
+ :options="virtualValues"
401
+ :text-content="(value) => labelMap.get(String(value)) ?? String(value)"
402
+ :estimate-size="36"
630
403
  >
631
- <span class="rs-select__item-label">{{ createOptionLabel }}</span>
632
- <ComboboxItemIndicator class="rs-select__item-check">
633
- <RsIcon name="check" :size="14" />
634
- </ComboboxItemIndicator>
635
- </ComboboxItem>
636
-
637
- <template v-for="(entry, index) in displayOptions" :key="index">
638
-
639
- <ComboboxGroup v-if="isSelectOptionGroup(entry)" class="rs-select__group">
640
-
641
- <ComboboxLabel class="rs-select__group-label">
404
+ <ComboboxItem
405
+ :value="option"
406
+ :disabled="isTokenDisabled(String(option))"
407
+ :text-value="labelMap.get(String(option)) ?? String(option)"
408
+ class="rs-select__item"
409
+ :class="{ 'rs-select__item--create': canCreate && String(option) === createValue }"
410
+ :title="optionFromToken(String(option))?.title"
411
+ >
412
+ <span class="rs-select__item-label">
413
+ <slot
414
+ name="option"
415
+ :option="optionOrCreate(String(option))"
416
+ :selected="selectedValues.includes(String(option))"
417
+ >
418
+ {{
419
+ canCreate && String(option) === createValue
420
+ ? createOptionLabel
421
+ : (labelMap.get(String(option)) ?? String(option))
422
+ }}
423
+ </slot>
424
+ </span>
425
+ <ComboboxItemIndicator class="rs-select__item-check">
426
+ <RsIcon name="check" :size="14" />
427
+ </ComboboxItemIndicator>
428
+ </ComboboxItem>
429
+ </ComboboxVirtualizer>
642
430
 
643
- {{ entry.label }}
431
+ <template v-else>
432
+ <ComboboxItem
433
+ v-if="canCreate"
434
+ :value="createValue"
435
+ :text-value="createValue"
436
+ class="rs-select__item rs-select__item--create"
437
+ >
438
+ <span class="rs-select__item-label">{{ createOptionLabel }}</span>
439
+ <ComboboxItemIndicator class="rs-select__item-check">
440
+ <RsIcon name="check" :size="14" />
441
+ </ComboboxItemIndicator>
442
+ </ComboboxItem>
644
443
 
645
- </ComboboxLabel>
444
+ <template v-for="(entry, index) in displayOptions" :key="index">
445
+ <ComboboxGroup v-if="isSelectOptionGroup(entry)" class="rs-select__group">
446
+ <ComboboxLabel class="rs-select__group-label">
447
+ {{ entry.label }}
448
+ </ComboboxLabel>
449
+ <ComboboxItem
450
+ v-for="opt in entry.options"
451
+ :key="toComboboxValue(opt.value)"
452
+ :value="toComboboxValue(opt.value)"
453
+ :disabled="opt.disabled || isOptionLimited(opt)"
454
+ :text-value="opt.label"
455
+ class="rs-select__item"
456
+ :title="opt.title"
457
+ >
458
+ <span class="rs-select__item-label">
459
+ <slot
460
+ name="option"
461
+ :option="opt"
462
+ :selected="selectedValues.includes(toComboboxValue(opt.value))"
463
+ >
464
+ {{ opt.label }}
465
+ </slot>
466
+ </span>
467
+ <ComboboxItemIndicator class="rs-select__item-check">
468
+ <RsIcon name="check" :size="14" />
469
+ </ComboboxItemIndicator>
470
+ </ComboboxItem>
471
+ </ComboboxGroup>
646
472
 
647
473
  <ComboboxItem
648
- v-for="opt in entry.options"
649
- :key="toComboboxValue(opt.value)"
650
- :value="toComboboxValue(opt.value)"
651
- :disabled="opt.disabled"
652
- :text-value="opt.label"
474
+ v-else
475
+ :key="toComboboxValue(entry.value)"
476
+ :value="toComboboxValue(entry.value)"
477
+ :disabled="entry.disabled || isOptionLimited(entry)"
478
+ :text-value="entry.label"
653
479
  class="rs-select__item"
480
+ :title="entry.title"
654
481
  >
655
- <span class="rs-select__item-label">{{ opt.label }}</span>
482
+ <span class="rs-select__item-label">
483
+ <slot
484
+ name="option"
485
+ :option="entry"
486
+ :selected="selectedValues.includes(toComboboxValue(entry.value))"
487
+ >
488
+ {{ entry.label }}
489
+ </slot>
490
+ </span>
656
491
  <ComboboxItemIndicator class="rs-select__item-check">
657
492
  <RsIcon name="check" :size="14" />
658
493
  </ComboboxItemIndicator>
659
494
  </ComboboxItem>
660
-
661
- </ComboboxGroup>
662
-
663
-
664
-
665
- <ComboboxItem
666
- v-else
667
- :key="toComboboxValue(entry.value)"
668
- :value="toComboboxValue(entry.value)"
669
- :disabled="entry.disabled"
670
- :text-value="entry.label"
671
- class="rs-select__item"
672
- >
673
- <span class="rs-select__item-label">{{ entry.label }}</span>
674
- <ComboboxItemIndicator class="rs-select__item-check">
675
- <RsIcon name="check" :size="14" />
676
- </ComboboxItemIndicator>
677
- </ComboboxItem>
678
-
495
+ </template>
679
496
  </template>
497
+ </ComboboxViewport>
680
498
 
681
- </template>
682
-
683
- </ComboboxViewport>
684
-
499
+ <div v-if="$slots.footer" class="rs-select__panel-footer" @mousedown.prevent>
500
+ <slot name="footer" />
501
+ </div>
502
+ </slot>
685
503
  </ComboboxContent>
686
-
687
504
  </ComboboxPortal>
688
-
689
505
  </ComboboxRoot>
690
-
691
506
  </template>
692
507
 
693
-
694
-
695
508
  <style scoped>
696
-
697
509
  .rs-select {
698
-
699
510
  display: inline-flex;
700
-
701
511
  width: 100%;
702
-
703
512
  max-width: 100%;
704
-
705
513
  vertical-align: middle;
706
-
707
514
  box-sizing: border-box;
708
-
709
515
  }
710
516
 
711
517
  .rs-select__trigger {
712
-
713
518
  display: inline-flex;
714
-
715
519
  align-items: center;
716
-
717
520
  justify-content: space-between;
718
-
719
521
  gap: var(--rs-space-sm);
720
-
721
522
  box-sizing: border-box;
722
-
723
523
  width: 100%;
724
-
725
524
  height: var(--rs-control-height-md);
726
-
727
525
  min-height: var(--rs-control-height-md);
728
-
729
526
  padding: 0 var(--rs-space-md);
730
-
731
527
  border-radius: var(--rs-select-radius, var(--rs-radius-sm));
732
-
733
528
  border: 1px solid var(--rs-input-border, var(--rs-border));
734
-
735
529
  background: var(--rs-input-bg);
736
-
737
530
  color: var(--rs-text);
738
-
739
531
  font-size: var(--rs-font-size-sm);
740
-
741
532
  line-height: var(--rs-line-height-tight);
742
-
743
533
  text-align: left;
744
-
745
534
  cursor: pointer;
746
-
535
+ outline: none;
747
536
  box-shadow: var(--rs-input-shadow, none);
748
-
749
537
  transition:
750
-
751
538
  border-color var(--rs-transition-fast),
752
-
753
539
  box-shadow var(--rs-transition-fast),
754
-
755
540
  background var(--rs-transition-fast);
756
-
757
541
  }
758
542
 
759
543
  .rs-select--multiple .rs-select__trigger {
760
-
761
544
  min-height: var(--rs-control-height-md);
762
-
763
545
  height: auto;
764
-
765
546
  padding-top: var(--rs-space-xs);
766
-
767
547
  padding-bottom: var(--rs-space-xs);
768
-
769
548
  }
770
549
 
771
550
  .rs-select--block {
@@ -817,238 +596,180 @@ defineExpose({
817
596
  }
818
597
 
819
598
  .rs-select__trigger:hover:not([data-disabled]) {
820
-
821
599
  border-color: var(--rs-input-border-hover, var(--rs-border));
822
-
823
600
  }
824
601
 
825
602
  .rs-select__trigger:focus-visible {
826
-
827
603
  outline: none;
828
-
829
604
  border-color: var(--rs-focus-border, var(--rs-primary));
830
-
831
605
  background: var(--rs-input-bg);
832
-
833
606
  box-shadow:
834
-
835
607
  var(--rs-input-shadow, none),
836
-
837
608
  0 0 0 var(--rs-focus-ring-width, 2px) var(--rs-focus-ring);
838
-
839
609
  }
840
610
 
841
611
  .rs-select__trigger[data-disabled] {
842
-
843
612
  opacity: 0.38;
844
-
845
613
  cursor: not-allowed;
846
-
847
614
  background: var(--rs-surface-hover);
615
+ }
848
616
 
617
+ .rs-select__trigger--invalid {
618
+ border-color: var(--rs-danger);
849
619
  }
850
620
 
851
- .rs-select__value {
621
+ .rs-select__trigger--invalid:focus-visible {
622
+ border-color: var(--rs-danger);
623
+ box-shadow:
624
+ var(--rs-input-shadow, none),
625
+ 0 0 0 var(--rs-focus-ring-width, 2px)
626
+ color-mix(in srgb, var(--rs-danger) 14%, transparent);
627
+ }
852
628
 
853
- flex: 1;
629
+ .rs-select__trigger--warning {
630
+ border-color: var(--rs-warning, #d97706);
631
+ }
854
632
 
855
- min-width: 0;
633
+ .rs-select__trigger--warning:focus-visible {
634
+ border-color: var(--rs-warning, #d97706);
635
+ box-shadow:
636
+ var(--rs-input-shadow, none),
637
+ 0 0 0 var(--rs-focus-ring-width, 2px)
638
+ color-mix(in srgb, var(--rs-warning, #d97706) 14%, transparent);
639
+ }
856
640
 
641
+ .rs-select__value {
642
+ flex: 1;
643
+ min-width: 0;
857
644
  text-align: left;
858
-
859
645
  overflow: hidden;
860
-
861
646
  text-overflow: ellipsis;
862
-
863
647
  white-space: nowrap;
864
-
865
648
  }
866
649
 
867
650
  .rs-select__value--multiple {
868
-
869
651
  display: flex;
870
-
871
652
  flex-wrap: nowrap;
872
-
873
653
  gap: var(--rs-space-xs);
874
-
875
654
  align-items: center;
876
-
877
655
  white-space: nowrap;
878
-
879
656
  overflow: hidden;
657
+ }
880
658
 
659
+ .rs-select__prefix {
660
+ display: inline-flex;
661
+ flex-shrink: 0;
662
+ align-items: center;
663
+ color: var(--rs-muted);
881
664
  }
882
665
 
883
- .rs-select__placeholder {
666
+ .rs-select__tag--rest {
667
+ padding-right: 0.5rem;
668
+ color: var(--rs-muted);
669
+ background: var(--rs-surface-hover);
670
+ border-color: var(--rs-border);
671
+ }
884
672
 
673
+ .rs-select__placeholder {
885
674
  color: var(--rs-placeholder);
886
-
887
675
  }
888
676
 
889
677
  .rs-select__single-label {
890
-
891
678
  display: block;
892
-
893
679
  overflow: hidden;
894
-
895
680
  text-overflow: ellipsis;
896
-
897
681
  white-space: nowrap;
898
-
899
682
  }
900
683
 
901
684
  .rs-select__tag {
902
-
903
685
  display: inline-flex;
904
-
905
686
  align-items: center;
906
-
907
687
  gap: 0.125rem;
908
-
909
688
  flex: 0 0 auto;
910
-
911
689
  max-width: 10rem;
912
-
913
690
  padding: 0.125rem 0.25rem 0.125rem 0.5rem;
914
-
915
691
  border-radius: var(--rs-radius-xs);
916
-
917
692
  border: 1px solid color-mix(in srgb, var(--rs-primary) 24%, transparent);
918
-
919
693
  background: var(--rs-primary-container);
920
-
921
694
  color: var(--rs-on-primary-container);
922
-
923
695
  font-size: var(--rs-font-size-xs);
924
-
925
696
  line-height: var(--rs-line-height-tight);
926
-
927
697
  }
928
698
 
929
699
  .rs-select__tag-label {
930
-
931
700
  overflow: hidden;
932
-
933
701
  text-overflow: ellipsis;
934
-
935
702
  white-space: nowrap;
936
-
937
703
  }
938
704
 
939
705
  .rs-select__tag-remove {
940
-
941
706
  display: inline-flex;
942
-
943
707
  align-items: center;
944
-
945
708
  justify-content: center;
946
-
947
709
  width: 1rem;
948
-
949
710
  height: 1rem;
950
-
951
711
  padding: 0;
952
-
953
712
  border: none;
954
-
955
713
  border-radius: var(--rs-radius-xs);
956
-
957
714
  background: transparent;
958
-
959
715
  color: var(--rs-muted);
960
-
961
716
  cursor: pointer;
962
-
963
717
  transition:
964
-
965
718
  color var(--rs-transition-fast),
966
-
967
719
  background var(--rs-transition-fast);
968
-
969
720
  }
970
721
 
971
722
  .rs-select__tag-remove:hover {
972
-
973
723
  color: var(--rs-on-primary-container);
974
-
975
724
  background: color-mix(in srgb, var(--rs-primary) 16%, transparent);
976
-
977
725
  }
978
726
 
979
727
  .rs-select__actions {
980
-
981
728
  display: inline-flex;
982
-
983
729
  align-items: center;
984
-
985
730
  gap: var(--rs-space-xs);
986
-
987
731
  flex-shrink: 0;
988
-
989
732
  }
990
733
 
991
734
  .rs-select__clear {
992
-
993
735
  display: inline-flex;
994
-
995
736
  align-items: center;
996
-
997
737
  justify-content: center;
998
-
999
738
  width: 1.25rem;
1000
-
1001
739
  height: 1.25rem;
1002
-
1003
740
  padding: 0;
1004
-
1005
741
  border: none;
1006
-
1007
742
  border-radius: var(--rs-radius-xs);
1008
-
1009
743
  background: transparent;
1010
-
1011
744
  color: var(--rs-muted);
1012
-
1013
745
  cursor: pointer;
1014
-
1015
746
  transition:
1016
-
1017
747
  color var(--rs-transition-fast),
1018
-
1019
748
  background var(--rs-transition-fast);
1020
-
1021
749
  }
1022
750
 
1023
751
  .rs-select__clear:hover {
1024
-
1025
752
  color: var(--rs-text);
1026
-
1027
753
  background: var(--rs-item-hover);
754
+ }
1028
755
 
756
+ .rs-select__suffix {
757
+ display: inline-flex;
758
+ align-items: center;
1029
759
  }
1030
760
 
1031
761
  .rs-select__icon {
1032
-
1033
762
  color: var(--rs-muted);
1034
-
1035
763
  flex-shrink: 0;
1036
-
1037
764
  transition: transform var(--rs-transition-fast);
1038
-
1039
765
  }
1040
766
 
1041
767
  .rs-select__trigger[data-state='open'] .rs-select__icon {
1042
-
1043
768
  transform: rotate(180deg);
1044
-
1045
769
  }
1046
770
 
1047
771
  .rs-select__item--create .rs-select__item-label {
1048
772
  color: var(--rs-primary);
1049
- font-weight: 500;
773
+ font-weight: var(--rs-font-weight-medium);
1050
774
  }
1051
-
1052
775
  </style>
1053
-
1054
-