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
@@ -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
+ })