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.
- package/CHANGELOG.md +28 -1
- package/package.json +1 -1
- package/src/__tests__/RsDatePicker.spec.ts +21 -0
- package/src/__tests__/RsDialog.spec.ts +61 -0
- package/src/__tests__/RsForm.spec.ts +5 -5
- package/src/__tests__/RsFormItem.spec.ts +478 -0
- package/src/__tests__/RsFormList.spec.ts +109 -0
- package/src/__tests__/RsInput.spec.ts +93 -0
- package/src/__tests__/RsInputNumber.spec.ts +33 -0
- package/src/__tests__/RsSelect.spec.ts +423 -1
- package/src/__tests__/RsSwitch.spec.ts +30 -0
- package/src/__tests__/RsTimePicker.spec.ts +9 -0
- package/src/__tests__/RsTooltip.spec.ts +43 -0
- package/src/__tests__/css-token.spec.ts +51 -0
- package/src/__tests__/date-picker-utils.spec.ts +49 -0
- package/src/__tests__/dialog-utils.spec.ts +43 -0
- package/src/__tests__/form-path.spec.ts +46 -0
- package/src/__tests__/form-rules.spec.ts +63 -0
- package/src/__tests__/theme.spec.ts +2 -0
- package/src/components/RsAlert.vue +1 -1
- package/src/components/RsAvatar.vue +1 -1
- package/src/components/RsBadge.vue +1 -1
- package/src/components/RsBreadcrumb.vue +1 -1
- package/src/components/RsButton.vue +4 -4
- package/src/components/RsCalendarGrid.vue +3 -3
- package/src/components/RsCard.vue +4 -4
- package/src/components/RsCodeBlock.vue +4 -4
- package/src/components/RsCodeEditor.vue +8 -8
- package/src/components/RsConfirmDialog.vue +2 -2
- package/src/components/RsContextMenu.vue +6 -4
- package/src/components/RsDatePicker.vue +75 -31
- package/src/components/RsDateTimePicker.vue +9 -1
- package/src/components/RsDescriptions.vue +2 -2
- package/src/components/RsDialog.vue +26 -16
- package/src/components/RsDrawer.vue +1 -1
- package/src/components/RsDropdown.vue +1 -1
- package/src/components/RsDynamicTags.vue +18 -4
- package/src/components/RsEmpty.vue +1 -1
- package/src/components/RsForm.vue +68 -1
- package/src/components/RsFormItem.vue +423 -0
- package/src/components/RsFormItemControl.vue +21 -0
- package/src/components/RsFormList.vue +117 -0
- package/src/components/RsInput.vue +411 -18
- package/src/components/RsInputNumber.vue +13 -4
- package/src/components/RsLink.vue +1 -1
- package/src/components/RsMarkdown.vue +5 -5
- package/src/components/RsMenu.vue +2 -2
- package/src/components/RsMonacoEditor.vue +9 -3
- package/src/components/RsPagination.vue +1 -1
- package/src/components/RsProseEditor.vue +3 -3
- package/src/components/RsSelect.vue +332 -611
- package/src/components/RsSidebarGroup.vue +1 -1
- package/src/components/RsSplitPane.vue +22 -1
- package/src/components/RsStatCard.vue +2 -2
- package/src/components/RsSteps.vue +2 -2
- package/src/components/RsSwitch.vue +20 -7
- package/src/components/RsTabs.vue +9 -9
- package/src/components/RsTag.vue +1 -1
- package/src/components/RsTerminal.vue +58 -19
- package/src/components/RsTimePicker.vue +28 -4
- package/src/components/RsTimePickerColumns.vue +4 -4
- package/src/components/RsToaster.vue +7 -7
- package/src/components/RsTooltip.vue +10 -1
- package/src/components/RsTooltipProvider.vue +11 -1
- package/src/components/RsTree.vue +6 -6
- package/src/components/RsUpload.vue +2 -2
- package/src/components/date-picker-utils.ts +113 -2
- package/src/components/dialog-utils.ts +37 -2
- package/src/components/form-item-control.ts +55 -0
- package/src/components/form-path.ts +92 -0
- package/src/components/form-rules.ts +64 -10
- package/src/components/form-utils.ts +94 -2
- package/src/components/select-utils.ts +272 -12
- package/src/components/table/RsTableHeaderFilter.vue +1 -1
- package/src/components/table/rs-table.css +1 -1
- package/src/components/use-rs-select.ts +436 -0
- package/src/composables/useRsConfig.ts +10 -3
- package/src/composables/useRsI18n.ts +16 -5
- package/src/index.ts +69 -1
- package/src/locale/interpolate.ts +32 -0
- package/src/locale/messages.ts +10 -0
- package/src/styles.css +68 -12
- package/src/theme/brand.example.css +4 -1
- package/src/theme/css-token.ts +107 -0
- package/src/theme/types.ts +1 -0
|
@@ -7,6 +7,12 @@ import RsSelect from '../components/RsSelect.vue'
|
|
|
7
7
|
import {
|
|
8
8
|
filterSelectOptions,
|
|
9
9
|
fromComboboxValue,
|
|
10
|
+
normalizeSelectOptions,
|
|
11
|
+
optionDisplayLabel,
|
|
12
|
+
packSelectModel,
|
|
13
|
+
restoreSelectValue,
|
|
14
|
+
sortSelectOptions,
|
|
15
|
+
splitByTokenSeparators,
|
|
10
16
|
toComboboxValue,
|
|
11
17
|
RS_SELECT_EMPTY_VALUE,
|
|
12
18
|
} from '../components/select-utils'
|
|
@@ -40,6 +46,59 @@ describe('RsSelect', () => {
|
|
|
40
46
|
expect(wrapper.find('.rs-select__placeholder').text()).toBe('请选择')
|
|
41
47
|
})
|
|
42
48
|
|
|
49
|
+
it('keeps numeric option values on v-model including 0', async () => {
|
|
50
|
+
const numeric = [
|
|
51
|
+
{ label: '静态配置', value: 0 },
|
|
52
|
+
{ label: '服务发现', value: 1 },
|
|
53
|
+
]
|
|
54
|
+
const wrapper = mount(RsSelect, {
|
|
55
|
+
props: { options: numeric, modelValue: 0 },
|
|
56
|
+
})
|
|
57
|
+
expect(wrapper.find('.rs-select__single-label').text()).toBe('静态配置')
|
|
58
|
+
const root = wrapper.getComponent({ name: 'ComboboxRoot' })
|
|
59
|
+
await root.vm.$emit('update:modelValue', '1')
|
|
60
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([1])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('collapses overflow tags with maxTagCount', () => {
|
|
64
|
+
const wrapper = mount(RsSelect, {
|
|
65
|
+
props: {
|
|
66
|
+
options,
|
|
67
|
+
modelValue: ['gpt-4o', 'claude'],
|
|
68
|
+
multiple: true,
|
|
69
|
+
maxTagCount: 1,
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
expect(wrapper.findAll('.rs-select__tag')).toHaveLength(2)
|
|
73
|
+
expect(wrapper.find('.rs-select__tag--rest').text()).toBe('+1')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('renders prefix and option slots', async () => {
|
|
77
|
+
const wrapper = mount(RsSelect, {
|
|
78
|
+
props: { options, modelValue: '' },
|
|
79
|
+
slots: {
|
|
80
|
+
prefix: () => h('span', { class: 'pref' }, 'P'),
|
|
81
|
+
option: ({ option }: { option: { label: string } }) =>
|
|
82
|
+
h('span', { class: 'opt' }, option.label),
|
|
83
|
+
},
|
|
84
|
+
attachTo: document.body,
|
|
85
|
+
})
|
|
86
|
+
expect(wrapper.find('.pref').text()).toBe('P')
|
|
87
|
+
await wrapper.find('.rs-select__trigger').trigger('click')
|
|
88
|
+
await flushPromises()
|
|
89
|
+
expect(document.body.querySelector('.opt')?.textContent).toContain('GPT-4o')
|
|
90
|
+
wrapper.unmount()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('marks invalid on the trigger instead of the combobox root', () => {
|
|
94
|
+
const wrapper = mount(RsSelect, {
|
|
95
|
+
props: { options, modelValue: '', invalid: true },
|
|
96
|
+
})
|
|
97
|
+
expect(wrapper.find('.rs-select__trigger').classes()).toContain('rs-select__trigger--invalid')
|
|
98
|
+
expect(wrapper.find('.rs-select__trigger').attributes('aria-invalid')).toBe('true')
|
|
99
|
+
expect(wrapper.find('.rs-select').attributes('aria-invalid')).toBeUndefined()
|
|
100
|
+
})
|
|
101
|
+
|
|
43
102
|
it('uses en-US default placeholder inside RsConfigProvider', () => {
|
|
44
103
|
const wrapper = mount(RsConfigProvider, {
|
|
45
104
|
props: { locale: 'en-US' },
|
|
@@ -214,7 +273,7 @@ describe('RsSelect', () => {
|
|
|
214
273
|
},
|
|
215
274
|
template: `
|
|
216
275
|
<RsForm ref="formRef">
|
|
217
|
-
<RsSelect v-model="role" :options="roleOptions" required />
|
|
276
|
+
<RsSelect v-model="role" name="role" :options="roleOptions" required />
|
|
218
277
|
</RsForm>
|
|
219
278
|
`,
|
|
220
279
|
})
|
|
@@ -294,6 +353,260 @@ describe('RsSelect', () => {
|
|
|
294
353
|
expect(matched!.classList.contains('rs-select__content--match-trigger')).toBe(true)
|
|
295
354
|
wrapper.unmount()
|
|
296
355
|
})
|
|
356
|
+
|
|
357
|
+
it('packs labelInValue including numeric 0', async () => {
|
|
358
|
+
const numeric = [
|
|
359
|
+
{ label: '静态配置', value: 0 },
|
|
360
|
+
{ label: '服务发现', value: 1 },
|
|
361
|
+
]
|
|
362
|
+
const wrapper = mount(RsSelect, {
|
|
363
|
+
props: { options: numeric, modelValue: '', labelInValue: true },
|
|
364
|
+
})
|
|
365
|
+
expect(wrapper.find('.rs-select__placeholder').exists()).toBe(true)
|
|
366
|
+
const root = wrapper.getComponent({ name: 'ComboboxRoot' })
|
|
367
|
+
await root.vm.$emit('update:modelValue', '0')
|
|
368
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
|
|
369
|
+
{ label: '静态配置', value: 0 },
|
|
370
|
+
])
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('echoes labelInValue snapshot for number 0', () => {
|
|
374
|
+
const numeric = [
|
|
375
|
+
{ label: '静态配置', value: 0 },
|
|
376
|
+
{ label: '服务发现', value: 1 },
|
|
377
|
+
]
|
|
378
|
+
const wrapper = mount(RsSelect, {
|
|
379
|
+
props: {
|
|
380
|
+
options: numeric,
|
|
381
|
+
modelValue: { label: '静态配置', value: 0 },
|
|
382
|
+
labelInValue: true,
|
|
383
|
+
},
|
|
384
|
+
})
|
|
385
|
+
expect(wrapper.find('.rs-select__single-label').text()).toBe('静态配置')
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('packs labelInValue arrays in multiple mode', async () => {
|
|
389
|
+
const wrapper = mount(RsSelect, {
|
|
390
|
+
props: { options, modelValue: [], multiple: true, labelInValue: true },
|
|
391
|
+
})
|
|
392
|
+
const root = wrapper.getComponent({ name: 'ComboboxRoot' })
|
|
393
|
+
await root.vm.$emit('update:modelValue', ['gpt-4o'])
|
|
394
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
|
|
395
|
+
[{ label: 'GPT-4o', value: 'gpt-4o' }],
|
|
396
|
+
])
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('emits select, deselect and clear', async () => {
|
|
400
|
+
const wrapper = mount(RsSelect, {
|
|
401
|
+
props: { options, modelValue: '', clearable: true },
|
|
402
|
+
})
|
|
403
|
+
const root = wrapper.getComponent({ name: 'ComboboxRoot' })
|
|
404
|
+
await root.vm.$emit('update:modelValue', 'claude')
|
|
405
|
+
expect(wrapper.emitted('select')?.at(-1)?.[0]).toBe('claude')
|
|
406
|
+
|
|
407
|
+
await wrapper.setProps({ modelValue: 'claude' })
|
|
408
|
+
await root.vm.$emit('update:modelValue', 'gpt-4o')
|
|
409
|
+
expect(wrapper.emitted('deselect')?.at(-1)?.[0]).toBe('claude')
|
|
410
|
+
expect(wrapper.emitted('select')?.at(-1)?.[0]).toBe('gpt-4o')
|
|
411
|
+
|
|
412
|
+
await wrapper.setProps({ modelValue: 'gpt-4o' })
|
|
413
|
+
await wrapper.find('.rs-select__clear').trigger('click')
|
|
414
|
+
expect(wrapper.emitted('clear')).toHaveLength(1)
|
|
415
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([''])
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it('truncates tag text with maxTagTextLength', () => {
|
|
419
|
+
const wrapper = mount(RsSelect, {
|
|
420
|
+
props: {
|
|
421
|
+
options: [{ label: 'TypeScript', value: 'ts' }],
|
|
422
|
+
modelValue: ['ts'],
|
|
423
|
+
multiple: true,
|
|
424
|
+
maxTagTextLength: 4,
|
|
425
|
+
},
|
|
426
|
+
})
|
|
427
|
+
expect(wrapper.find('.rs-select__tag-label').text()).toBe('Type…')
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('applies warning status on the trigger', () => {
|
|
431
|
+
const wrapper = mount(RsSelect, {
|
|
432
|
+
props: { options, modelValue: '', status: 'warning' },
|
|
433
|
+
})
|
|
434
|
+
expect(wrapper.find('.rs-select__trigger').classes()).toContain('rs-select__trigger--warning')
|
|
435
|
+
expect(wrapper.find('.rs-select__trigger').classes()).not.toContain(
|
|
436
|
+
'rs-select__trigger--invalid',
|
|
437
|
+
)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('treats status error as invalid', () => {
|
|
441
|
+
const wrapper = mount(RsSelect, {
|
|
442
|
+
props: { options, modelValue: '', status: 'error' },
|
|
443
|
+
})
|
|
444
|
+
expect(wrapper.find('.rs-select__trigger').classes()).toContain('rs-select__trigger--invalid')
|
|
445
|
+
expect(wrapper.find('.rs-select__trigger').classes()).not.toContain(
|
|
446
|
+
'rs-select__trigger--warning',
|
|
447
|
+
)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('hides arrow when showArrow is false', () => {
|
|
451
|
+
const wrapper = mount(RsSelect, {
|
|
452
|
+
props: { options, modelValue: '', showArrow: false },
|
|
453
|
+
})
|
|
454
|
+
expect(wrapper.find('.rs-select__suffix').exists()).toBe(false)
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('sorts options with filterSort', async () => {
|
|
458
|
+
const unsorted = [
|
|
459
|
+
{ label: 'Zulu', value: 'z' },
|
|
460
|
+
{ label: 'Alpha', value: 'a' },
|
|
461
|
+
]
|
|
462
|
+
const wrapper = mount(RsSelect, {
|
|
463
|
+
props: {
|
|
464
|
+
options: unsorted,
|
|
465
|
+
modelValue: '',
|
|
466
|
+
filterSort: (a, b) => String(a.label).localeCompare(String(b.label)),
|
|
467
|
+
},
|
|
468
|
+
attachTo: document.body,
|
|
469
|
+
})
|
|
470
|
+
await wrapper.find('.rs-select__trigger').trigger('click')
|
|
471
|
+
await flushPromises()
|
|
472
|
+
const items = [...document.body.querySelectorAll('.rs-select__item-label')].map(
|
|
473
|
+
(el) => el.textContent?.trim(),
|
|
474
|
+
)
|
|
475
|
+
expect(items[0]).toBe('Alpha')
|
|
476
|
+
expect(items[1]).toBe('Zulu')
|
|
477
|
+
wrapper.unmount()
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it('renders header and footer slots', async () => {
|
|
481
|
+
const wrapper = mount(RsSelect, {
|
|
482
|
+
props: { options, modelValue: '' },
|
|
483
|
+
slots: {
|
|
484
|
+
header: () => h('div', { class: 'sel-head' }, 'HEAD'),
|
|
485
|
+
footer: () => h('div', { class: 'sel-foot' }, 'FOOT'),
|
|
486
|
+
},
|
|
487
|
+
attachTo: document.body,
|
|
488
|
+
})
|
|
489
|
+
await wrapper.find('.rs-select__trigger').trigger('click')
|
|
490
|
+
await flushPromises()
|
|
491
|
+
expect(document.body.querySelector('.sel-head')?.textContent).toBe('HEAD')
|
|
492
|
+
expect(document.body.querySelector('.sel-foot')?.textContent).toBe('FOOT')
|
|
493
|
+
wrapper.unmount()
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
it('applies popupClassName and listHeight on the panel', async () => {
|
|
497
|
+
const wrapper = mount(RsSelect, {
|
|
498
|
+
props: {
|
|
499
|
+
options,
|
|
500
|
+
modelValue: '',
|
|
501
|
+
popupClassName: 'extra-popup',
|
|
502
|
+
listHeight: 120,
|
|
503
|
+
},
|
|
504
|
+
attachTo: document.body,
|
|
505
|
+
})
|
|
506
|
+
await wrapper.find('.rs-select__trigger').trigger('click')
|
|
507
|
+
await flushPromises()
|
|
508
|
+
const content = document.body.querySelector('.rs-select__content') as HTMLElement | null
|
|
509
|
+
expect(content?.classList.contains('extra-popup')).toBe(true)
|
|
510
|
+
expect(content?.style.getPropertyValue('--rs-select-list-height')).toBe('120px')
|
|
511
|
+
wrapper.unmount()
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
it('commits tokens when typing a separator', async () => {
|
|
515
|
+
const wrapper = mount(RsSelect, {
|
|
516
|
+
props: {
|
|
517
|
+
options,
|
|
518
|
+
modelValue: [],
|
|
519
|
+
multiple: true,
|
|
520
|
+
searchable: true,
|
|
521
|
+
tokenSeparators: [','],
|
|
522
|
+
},
|
|
523
|
+
attachTo: document.body,
|
|
524
|
+
})
|
|
525
|
+
await wrapper.find('.rs-select__trigger').trigger('click')
|
|
526
|
+
await flushPromises()
|
|
527
|
+
const input = document.querySelector('.rs-select__search') as HTMLInputElement | null
|
|
528
|
+
expect(input).toBeTruthy()
|
|
529
|
+
input!.value = 'gpt-4o,'
|
|
530
|
+
input!.dispatchEvent(new Event('input', { bubbles: true }))
|
|
531
|
+
await flushPromises()
|
|
532
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['gpt-4o']])
|
|
533
|
+
wrapper.unmount()
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
it('maps fieldNames including numeric 0', async () => {
|
|
537
|
+
const raw = [
|
|
538
|
+
{ name: '静态配置', id: 0 },
|
|
539
|
+
{ name: '服务发现', id: 1 },
|
|
540
|
+
]
|
|
541
|
+
const wrapper = mount(RsSelect, {
|
|
542
|
+
props: {
|
|
543
|
+
options: raw,
|
|
544
|
+
modelValue: 0,
|
|
545
|
+
fieldNames: { label: 'name', value: 'id' },
|
|
546
|
+
},
|
|
547
|
+
})
|
|
548
|
+
expect(wrapper.find('.rs-select__single-label').text()).toBe('静态配置')
|
|
549
|
+
const root = wrapper.getComponent({ name: 'ComboboxRoot' })
|
|
550
|
+
await root.vm.$emit('update:modelValue', '1')
|
|
551
|
+
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([1])
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
it('shows optionLabelProp on the trigger', () => {
|
|
555
|
+
const wrapper = mount(RsSelect, {
|
|
556
|
+
props: { options, modelValue: 'claude', optionLabelProp: 'value' },
|
|
557
|
+
})
|
|
558
|
+
expect(wrapper.find('.rs-select__single-label').text()).toBe('claude')
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it('binds searchValue and emits updates', async () => {
|
|
562
|
+
const wrapper = mount(RsSelect, {
|
|
563
|
+
props: { options, modelValue: '', searchable: true, searchValue: 'cla' },
|
|
564
|
+
attachTo: document.body,
|
|
565
|
+
})
|
|
566
|
+
await wrapper.find('.rs-select__trigger').trigger('click')
|
|
567
|
+
await flushPromises()
|
|
568
|
+
const input = document.querySelector('.rs-select__search') as HTMLInputElement | null
|
|
569
|
+
expect(input?.value).toBe('cla')
|
|
570
|
+
input!.value = 'gpt'
|
|
571
|
+
input!.dispatchEvent(new Event('input', { bubbles: true }))
|
|
572
|
+
await flushPromises()
|
|
573
|
+
expect(wrapper.emitted('update:searchValue')?.at(-1)).toEqual(['gpt'])
|
|
574
|
+
wrapper.unmount()
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('renders dropdownRender instead of the default panel body', async () => {
|
|
578
|
+
const wrapper = mount(RsSelect, {
|
|
579
|
+
props: { options, modelValue: '' },
|
|
580
|
+
slots: {
|
|
581
|
+
dropdownRender: () => h('div', { class: 'custom-dd' }, 'CUSTOM'),
|
|
582
|
+
},
|
|
583
|
+
attachTo: document.body,
|
|
584
|
+
})
|
|
585
|
+
await wrapper.find('.rs-select__trigger').trigger('click')
|
|
586
|
+
await flushPromises()
|
|
587
|
+
expect(document.body.querySelector('.custom-dd')?.textContent).toBe('CUSTOM')
|
|
588
|
+
expect(document.body.querySelector('.rs-select__item')).toBeNull()
|
|
589
|
+
wrapper.unmount()
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it('mounts the panel into getPopupContainer', async () => {
|
|
593
|
+
const host = document.createElement('div')
|
|
594
|
+
host.id = 'rs-select-portal-host'
|
|
595
|
+
document.body.appendChild(host)
|
|
596
|
+
const wrapper = mount(RsSelect, {
|
|
597
|
+
props: {
|
|
598
|
+
options,
|
|
599
|
+
modelValue: '',
|
|
600
|
+
getPopupContainer: () => host,
|
|
601
|
+
},
|
|
602
|
+
attachTo: document.body,
|
|
603
|
+
})
|
|
604
|
+
await wrapper.find('.rs-select__trigger').trigger('click')
|
|
605
|
+
await flushPromises()
|
|
606
|
+
expect(host.querySelector('.rs-select__content')).toBeTruthy()
|
|
607
|
+
wrapper.unmount()
|
|
608
|
+
host.remove()
|
|
609
|
+
})
|
|
297
610
|
})
|
|
298
611
|
|
|
299
612
|
describe('select-utils', () => {
|
|
@@ -303,10 +616,119 @@ describe('select-utils', () => {
|
|
|
303
616
|
it('maps empty string to combobox sentinel and back', () => {
|
|
304
617
|
expect(toComboboxValue('')).toBe(RS_SELECT_EMPTY_VALUE)
|
|
305
618
|
expect(toComboboxValue('claude')).toBe('claude')
|
|
619
|
+
expect(toComboboxValue(0)).toBe('0')
|
|
306
620
|
expect(fromComboboxValue(RS_SELECT_EMPTY_VALUE)).toBe('')
|
|
307
621
|
expect(fromComboboxValue('claude')).toBe('claude')
|
|
308
622
|
})
|
|
309
623
|
|
|
624
|
+
it('restoreSelectValue keeps option number type', () => {
|
|
625
|
+
const numeric = [
|
|
626
|
+
{ label: '静态配置', value: 0 },
|
|
627
|
+
{ label: '服务发现', value: 1 },
|
|
628
|
+
]
|
|
629
|
+
expect(restoreSelectValue('0', numeric)).toBe(0)
|
|
630
|
+
expect(restoreSelectValue('1', numeric)).toBe(1)
|
|
631
|
+
expect(restoreSelectValue('custom', numeric)).toBe('custom')
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
it('filterSelectOptions honors filterOption and optionFilterProp', () => {
|
|
635
|
+
const numeric = [
|
|
636
|
+
{ label: '静态配置', value: 0 },
|
|
637
|
+
{ label: '服务发现', value: 1 },
|
|
638
|
+
]
|
|
639
|
+
const byValue = filterSelectOptions(numeric, '0', contains, true, 'value')
|
|
640
|
+
expect(byValue).toHaveLength(1)
|
|
641
|
+
expect(byValue[0]).toMatchObject({ value: 0 })
|
|
642
|
+
|
|
643
|
+
const custom = filterSelectOptions(numeric, '发现', contains, (q, opt) =>
|
|
644
|
+
String(opt.label).includes(q),
|
|
645
|
+
)
|
|
646
|
+
expect(custom).toHaveLength(1)
|
|
647
|
+
expect(custom[0]).toMatchObject({ value: 1 })
|
|
648
|
+
|
|
649
|
+
const unfiltered = filterSelectOptions(numeric, 'zzz', contains, false)
|
|
650
|
+
expect(unfiltered).toHaveLength(2)
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
it('packSelectModel keeps number 0 and labelInValue', () => {
|
|
654
|
+
const numeric = [
|
|
655
|
+
{ label: '静态配置', value: 0 },
|
|
656
|
+
{ label: '服务发现', value: 1 },
|
|
657
|
+
]
|
|
658
|
+
expect(packSelectModel([0], numeric, false, false)).toBe(0)
|
|
659
|
+
expect(packSelectModel([0], numeric, false, true)).toEqual({
|
|
660
|
+
label: '静态配置',
|
|
661
|
+
value: 0,
|
|
662
|
+
})
|
|
663
|
+
expect(packSelectModel([0, 1], numeric, true, true)).toEqual([
|
|
664
|
+
{ label: '静态配置', value: 0 },
|
|
665
|
+
{ label: '服务发现', value: 1 },
|
|
666
|
+
])
|
|
667
|
+
expect(packSelectModel([], numeric, false, true)).toBe('')
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
it('sortSelectOptions sorts flat and grouped lists', () => {
|
|
671
|
+
const flat = [
|
|
672
|
+
{ label: 'Zulu', value: 'z' },
|
|
673
|
+
{ label: 'Alpha', value: 'a' },
|
|
674
|
+
]
|
|
675
|
+
const sorted = sortSelectOptions(flat, (a, b) => String(a.label).localeCompare(String(b.label)))
|
|
676
|
+
expect(sorted.map((item) => ('value' in item ? item.value : ''))).toEqual(['a', 'z'])
|
|
677
|
+
|
|
678
|
+
const grouped = [
|
|
679
|
+
{
|
|
680
|
+
label: '后端',
|
|
681
|
+
options: [
|
|
682
|
+
{ label: 'Go', value: 'go' },
|
|
683
|
+
{ label: 'Ada', value: 'ada' },
|
|
684
|
+
],
|
|
685
|
+
},
|
|
686
|
+
]
|
|
687
|
+
const groupedSorted = sortSelectOptions(grouped, (a, b) =>
|
|
688
|
+
String(a.label).localeCompare(String(b.label)),
|
|
689
|
+
)
|
|
690
|
+
expect(groupedSorted[0]).toMatchObject({ label: '后端' })
|
|
691
|
+
if ('options' in groupedSorted[0]) {
|
|
692
|
+
expect(groupedSorted[0].options.map((opt) => opt.value)).toEqual(['ada', 'go'])
|
|
693
|
+
}
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
it('splitByTokenSeparators splits on the first matching separator', () => {
|
|
697
|
+
expect(splitByTokenSeparators('a,b,c', [','])).toEqual(['a', 'b', 'c'])
|
|
698
|
+
expect(splitByTokenSeparators('ab', [','])).toBeNull()
|
|
699
|
+
expect(splitByTokenSeparators('a;b', [',', ';'])).toEqual(['a', 'b'])
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
it('normalizeSelectOptions maps fieldNames and keeps number 0', () => {
|
|
703
|
+
const raw = [
|
|
704
|
+
{ name: '静态配置', id: 0 },
|
|
705
|
+
{
|
|
706
|
+
name: '后端',
|
|
707
|
+
children: [
|
|
708
|
+
{ name: 'Go', id: 'go' },
|
|
709
|
+
{ name: 'Ada', id: 'ada' },
|
|
710
|
+
],
|
|
711
|
+
},
|
|
712
|
+
]
|
|
713
|
+
const normalized = normalizeSelectOptions(raw, {
|
|
714
|
+
label: 'name',
|
|
715
|
+
value: 'id',
|
|
716
|
+
options: 'children',
|
|
717
|
+
})
|
|
718
|
+
expect(normalized[0]).toMatchObject({ label: '静态配置', value: 0 })
|
|
719
|
+
expect(normalized[1]).toMatchObject({ label: '后端' })
|
|
720
|
+
if ('options' in normalized[1]) {
|
|
721
|
+
expect(normalized[1].options.map((opt) => opt.value)).toEqual(['go', 'ada'])
|
|
722
|
+
}
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
it('optionDisplayLabel reads optionLabelProp', () => {
|
|
726
|
+
const option = { label: 'Claude', value: 'claude', code: 'CL' }
|
|
727
|
+
expect(optionDisplayLabel(option)).toBe('Claude')
|
|
728
|
+
expect(optionDisplayLabel(option, 'value')).toBe('claude')
|
|
729
|
+
expect(optionDisplayLabel(option, 'code')).toBe('CL')
|
|
730
|
+
})
|
|
731
|
+
|
|
310
732
|
it('filterSelectOptions filters flat and grouped options', () => {
|
|
311
733
|
const grouped = [
|
|
312
734
|
{
|
|
@@ -41,4 +41,34 @@ describe('RsSwitch', () => {
|
|
|
41
41
|
await wrapper.find('.rs-switch__root').trigger('click')
|
|
42
42
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
43
43
|
})
|
|
44
|
+
|
|
45
|
+
it('writes custom checkedValue / uncheckedValue to v-model', async () => {
|
|
46
|
+
const Host = defineComponent({
|
|
47
|
+
components: { RsSwitch },
|
|
48
|
+
data: () => ({ flag: 'N' }),
|
|
49
|
+
template: '<RsSwitch v-model="flag" checked-value="Y" unchecked-value="N" />',
|
|
50
|
+
})
|
|
51
|
+
const wrapper = mount(Host)
|
|
52
|
+
|
|
53
|
+
expect(wrapper.find('.rs-switch--checked').exists()).toBe(false)
|
|
54
|
+
await wrapper.find('.rs-switch__root').trigger('click')
|
|
55
|
+
await nextTick()
|
|
56
|
+
expect((wrapper.vm as { flag: string }).flag).toBe('Y')
|
|
57
|
+
expect(wrapper.find('.rs-switch--checked').exists()).toBe(true)
|
|
58
|
+
|
|
59
|
+
await wrapper.find('.rs-switch__root').trigger('click')
|
|
60
|
+
await nextTick()
|
|
61
|
+
expect((wrapper.vm as { flag: string }).flag).toBe('N')
|
|
62
|
+
expect(wrapper.find('.rs-switch--checked').exists()).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('treats numeric 1 / 0 as checked and unchecked', async () => {
|
|
66
|
+
const wrapper = mount(RsSwitch, {
|
|
67
|
+
props: { modelValue: 0, checkedValue: 1, uncheckedValue: 0 },
|
|
68
|
+
})
|
|
69
|
+
expect(wrapper.find('.rs-switch--checked').exists()).toBe(false)
|
|
70
|
+
await wrapper.find('.rs-switch__root').trigger('click')
|
|
71
|
+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([1])
|
|
72
|
+
expect(wrapper.emitted('change')?.[0]).toEqual([1])
|
|
73
|
+
})
|
|
44
74
|
})
|
|
@@ -85,4 +85,13 @@ describe('RsTimePicker', () => {
|
|
|
85
85
|
})
|
|
86
86
|
expect(wrapper.find('.rs-time-picker__trigger').attributes('disabled')).toBeDefined()
|
|
87
87
|
})
|
|
88
|
+
|
|
89
|
+
it('marks invalid on the trigger instead of the field wrapper', () => {
|
|
90
|
+
const wrapper = mount(RsTimePicker, { props: { modelValue: '', invalid: true } })
|
|
91
|
+
expect(wrapper.find('.rs-time-picker__trigger').classes()).toContain(
|
|
92
|
+
'rs-time-picker__trigger--invalid',
|
|
93
|
+
)
|
|
94
|
+
expect(wrapper.find('.rs-time-picker__trigger').attributes('aria-invalid')).toBe('true')
|
|
95
|
+
expect(wrapper.attributes('aria-invalid')).toBeUndefined()
|
|
96
|
+
})
|
|
88
97
|
})
|
|
@@ -54,6 +54,49 @@ describe('RsTooltip', () => {
|
|
|
54
54
|
wrapper.unmount()
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
+
it('does not open on non-keyboard focus when ignoreNonKeyboardFocus is on', async () => {
|
|
58
|
+
const wrapper = mountTooltip(
|
|
59
|
+
'<button type="button" class="trigger">Hover</button>',
|
|
60
|
+
{ content: 'Focus tip' },
|
|
61
|
+
)
|
|
62
|
+
try {
|
|
63
|
+
const trigger = wrapper.find('.trigger').element as HTMLButtonElement
|
|
64
|
+
// 模拟 Dialog 关闭回焦:有 focus 但无 :focus-visible(jsdom 对 :focus-visible 不稳定)
|
|
65
|
+
const matchesSpy = vi
|
|
66
|
+
.spyOn(trigger, 'matches')
|
|
67
|
+
.mockImplementation((selectors: string) => !selectors.includes(':focus-visible'))
|
|
68
|
+
trigger.dispatchEvent(new FocusEvent('focus', { bubbles: true }))
|
|
69
|
+
await vi.runAllTimersAsync()
|
|
70
|
+
await flushPromises()
|
|
71
|
+
expect(document.body.querySelector('.rs-tooltip__content')).toBeNull()
|
|
72
|
+
matchesSpy.mockRestore()
|
|
73
|
+
} finally {
|
|
74
|
+
wrapper.unmount()
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('opens on keyboard-style focus-visible', async () => {
|
|
79
|
+
const wrapper = mountTooltip(
|
|
80
|
+
'<button type="button" class="trigger">Hover</button>',
|
|
81
|
+
{ content: 'Keyboard tip' },
|
|
82
|
+
)
|
|
83
|
+
try {
|
|
84
|
+
const trigger = wrapper.find('.trigger').element as HTMLButtonElement
|
|
85
|
+
const matchesSpy = vi
|
|
86
|
+
.spyOn(trigger, 'matches')
|
|
87
|
+
.mockImplementation((selectors: string) => selectors.includes(':focus-visible'))
|
|
88
|
+
trigger.dispatchEvent(new FocusEvent('focus', { bubbles: true }))
|
|
89
|
+
await vi.runAllTimersAsync()
|
|
90
|
+
await flushPromises()
|
|
91
|
+
expect(document.body.querySelector('.rs-tooltip__content')).not.toBeNull()
|
|
92
|
+
expect(document.body.textContent).toContain('Keyboard tip')
|
|
93
|
+
matchesSpy.mockRestore()
|
|
94
|
+
} finally {
|
|
95
|
+
wrapper.unmount()
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
|
|
57
100
|
it('renders custom content slot', async () => {
|
|
58
101
|
const wrapper = mount(
|
|
59
102
|
{
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
parseCssLengthToPx,
|
|
4
|
+
readCodeFontFamily,
|
|
5
|
+
readCodeFontSizePx,
|
|
6
|
+
readCssLengthPx,
|
|
7
|
+
readCssVar,
|
|
8
|
+
readTerminalFontFamily,
|
|
9
|
+
readTerminalFontSizePx,
|
|
10
|
+
readTerminalFontWeight,
|
|
11
|
+
readTerminalFontWeightBold,
|
|
12
|
+
} from '../theme/css-token'
|
|
13
|
+
|
|
14
|
+
describe('css-token', () => {
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
document.documentElement.style.cssText = ''
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('parses px / rem / unitless lengths', () => {
|
|
20
|
+
expect(parseCssLengthToPx('12px', 16)).toBe(12)
|
|
21
|
+
expect(parseCssLengthToPx('0.875rem', 16)).toBe(14)
|
|
22
|
+
expect(parseCssLengthToPx('1.5em', 16)).toBe(24)
|
|
23
|
+
expect(parseCssLengthToPx('14', 16)).toBe(14)
|
|
24
|
+
expect(parseCssLengthToPx('var(--x)', 16)).toBeNull()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('reads and unwraps nested custom properties', () => {
|
|
28
|
+
const root = document.documentElement
|
|
29
|
+
root.style.setProperty('--rs-font-size-sm', '0.875rem')
|
|
30
|
+
root.style.setProperty('--rs-code-font-size', 'var(--rs-font-size-sm)')
|
|
31
|
+
root.style.setProperty('--rs-font-mono', 'JetBrains Mono, monospace')
|
|
32
|
+
root.style.setProperty('--rs-code-font-family', 'var(--rs-font-mono)')
|
|
33
|
+
root.style.setProperty('--rs-font-weight-regular', '400')
|
|
34
|
+
root.style.setProperty('--rs-terminal-font-weight', 'var(--rs-font-weight-regular)')
|
|
35
|
+
|
|
36
|
+
expect(readCssVar('--rs-code-font-size')).toBe('0.875rem')
|
|
37
|
+
expect(readCssLengthPx('--rs-code-font-size', 99)).toBe(14)
|
|
38
|
+
expect(readCodeFontSizePx()).toBe(14)
|
|
39
|
+
expect(readCodeFontFamily()).toBe('JetBrains Mono, monospace')
|
|
40
|
+
expect(readTerminalFontWeight()).toBe('400')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('falls back when tokens are missing', () => {
|
|
44
|
+
expect(readCodeFontSizePx()).toBe(14)
|
|
45
|
+
expect(readTerminalFontSizePx()).toBe(14)
|
|
46
|
+
expect(readCodeFontFamily()).toContain('monospace')
|
|
47
|
+
expect(readTerminalFontFamily()).toContain('monospace')
|
|
48
|
+
expect(readTerminalFontWeight()).toBe('400')
|
|
49
|
+
expect(readTerminalFontWeightBold()).toBe('500')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -4,8 +4,10 @@ import {
|
|
|
4
4
|
formatDateTimeDisplay,
|
|
5
5
|
formatDateTimeValue,
|
|
6
6
|
formatDateValue,
|
|
7
|
+
fromInternalPickerValue,
|
|
7
8
|
parseDateTimeValue,
|
|
8
9
|
parseDateValue,
|
|
10
|
+
toInternalPickerValue,
|
|
9
11
|
} from '../components/date-picker-utils'
|
|
10
12
|
import { RS_DATE_FORMAT, RS_DATETIME_FORMAT } from '../lib/rs-dayjs'
|
|
11
13
|
|
|
@@ -68,3 +70,50 @@ describe('date-picker-utils (dayjs)', () => {
|
|
|
68
70
|
expect(formatDateTimeValue('not-a-date')).toBe('not-a-date')
|
|
69
71
|
})
|
|
70
72
|
})
|
|
73
|
+
|
|
74
|
+
describe('date-picker valueFormat conversion', () => {
|
|
75
|
+
const datetimeOpts = { valueFormat: 'iso', withTime: true }
|
|
76
|
+
const dateOpts = { valueFormat: 'iso', withTime: false }
|
|
77
|
+
|
|
78
|
+
it('converts internal datetime to local-offset RFC3339', () => {
|
|
79
|
+
const iso = fromInternalPickerValue('2025-06-16 14:30:00', datetimeOpts)
|
|
80
|
+
expect(iso).toMatch(/^2025-06-16T14:30:00[+-]\d{2}:\d{2}$/)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('converts internal date to RFC3339 at midnight local offset', () => {
|
|
84
|
+
const iso = fromInternalPickerValue('2025-06-16', dateOpts)
|
|
85
|
+
expect(iso).toMatch(/^2025-06-16T00:00:00[+-]\d{2}:\d{2}$/)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('round-trips iso datetime as wall clock', () => {
|
|
89
|
+
const iso = fromInternalPickerValue('2025-06-16 14:30:00', datetimeOpts)
|
|
90
|
+
expect(toInternalPickerValue(iso, datetimeOpts)).toBe('2025-06-16 14:30:00')
|
|
91
|
+
expect(toInternalPickerValue('2026-07-01T14:49:43Z', datetimeOpts)).toBe('2026-07-01 14:49:43')
|
|
92
|
+
expect(toInternalPickerValue('2026-08-14T14:38:42+08:00', datetimeOpts)).toBe('2026-08-14 14:38:42')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('keeps string format as internal wall clock', () => {
|
|
96
|
+
expect(fromInternalPickerValue('2025-06-16 14:30:00', { valueFormat: 'string', withTime: true }))
|
|
97
|
+
.toBe('2025-06-16 14:30:00')
|
|
98
|
+
expect(toInternalPickerValue('2025-06-16 14:30:00', { valueFormat: 'string', withTime: true }))
|
|
99
|
+
.toBe('2025-06-16 14:30:00')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('converts timestamp format', () => {
|
|
103
|
+
const ms = fromInternalPickerValue('2025-06-16 14:30:00', { valueFormat: 'timestamp', withTime: true })
|
|
104
|
+
expect(typeof ms).toBe('number')
|
|
105
|
+
expect(toInternalPickerValue(ms, { valueFormat: 'timestamp', withTime: true })).toBe('2025-06-16 14:30:00')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('supports custom dayjs value-format templates', () => {
|
|
109
|
+
const custom = { valueFormat: 'YYYY-MM-DDTHH:mm:ss', withTime: true }
|
|
110
|
+
expect(fromInternalPickerValue('2025-06-16 14:30:00', custom)).toBe('2025-06-16T14:30:00')
|
|
111
|
+
expect(toInternalPickerValue('2025-06-16T14:30:00', custom)).toBe('2025-06-16 14:30:00')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('emits null for empty iso and timestamp', () => {
|
|
115
|
+
expect(fromInternalPickerValue('', datetimeOpts)).toBeNull()
|
|
116
|
+
expect(fromInternalPickerValue('', { valueFormat: 'timestamp', withTime: true })).toBeNull()
|
|
117
|
+
expect(fromInternalPickerValue('', { valueFormat: 'string', withTime: true })).toBe('')
|
|
118
|
+
})
|
|
119
|
+
})
|