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
@@ -0,0 +1,43 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { resolveRsDialogCssWidth, resolveRsDialogWidthPx } from '../components/dialog-utils'
3
+
4
+ describe('resolveRsDialogWidthPx', () => {
5
+ afterEach(() => {
6
+ document.documentElement.style.removeProperty('font-size')
7
+ })
8
+
9
+ it('returns undefined for presets', () => {
10
+ expect(resolveRsDialogWidthPx('sm')).toBeUndefined()
11
+ expect(resolveRsDialogWidthPx('md')).toBeUndefined()
12
+ expect(resolveRsDialogWidthPx('lg')).toBeUndefined()
13
+ })
14
+
15
+ it('keeps numeric and px widths', () => {
16
+ expect(resolveRsDialogWidthPx(1200)).toBe(1200)
17
+ expect(resolveRsDialogWidthPx('960px')).toBe(960)
18
+ })
19
+
20
+ it('converts rem using root font size', () => {
21
+ document.documentElement.style.fontSize = '16px'
22
+ expect(resolveRsDialogWidthPx('40rem')).toBe(640)
23
+ })
24
+
25
+ it('converts percent against the given viewport width', () => {
26
+ expect(resolveRsDialogWidthPx('90%', 1000)).toBe(900)
27
+ expect(resolveRsDialogWidthPx('80%', 800)).toBe(640)
28
+ })
29
+
30
+ it('returns undefined for empty or unknown units', () => {
31
+ expect(resolveRsDialogWidthPx('')).toBeUndefined()
32
+ expect(resolveRsDialogWidthPx('min(90vw, 1200px)')).toBeUndefined()
33
+ expect(resolveRsDialogWidthPx(0)).toBeUndefined()
34
+ })
35
+ })
36
+
37
+ describe('resolveRsDialogCssWidth', () => {
38
+ it('maps number to px and keeps custom CSS strings', () => {
39
+ expect(resolveRsDialogCssWidth('md')).toBeUndefined()
40
+ expect(resolveRsDialogCssWidth(520)).toBe('520px')
41
+ expect(resolveRsDialogCssWidth('90%')).toBe('90%')
42
+ })
43
+ })
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ concatNamePath,
4
+ getByNamePath,
5
+ hasByNamePath,
6
+ namePathKey,
7
+ normalizeNamePath,
8
+ setByNamePath,
9
+ } from '../components/form-path'
10
+
11
+ describe('form-path', () => {
12
+ it('normalizes string and array name paths', () => {
13
+ expect(normalizeNamePath('user.email')).toEqual(['user', 'email'])
14
+ expect(normalizeNamePath(['user', 'email'])).toEqual(['user', 'email'])
15
+ expect(namePathKey(['a', 0, 'b'])).toBe('a.0.b')
16
+ })
17
+
18
+ it('gets and sets nested values', () => {
19
+ const model: Record<string, unknown> = {}
20
+ setByNamePath(model, 'user.email', 'a@b.com')
21
+ expect(model).toEqual({ user: { email: 'a@b.com' } })
22
+ expect(getByNamePath(model, ['user', 'email'])).toBe('a@b.com')
23
+ expect(hasByNamePath(model, 'user.email')).toBe(true)
24
+ })
25
+
26
+ it('walks multi-level JSON paths', () => {
27
+ const model: Record<string, unknown> = {}
28
+ setByNamePath(model, 'config.headerConfig.isRequestHeader', true)
29
+ expect(model).toEqual({
30
+ config: { headerConfig: { isRequestHeader: true } },
31
+ })
32
+ expect(getByNamePath(model, 'config.headerConfig.isRequestHeader')).toBe(true)
33
+ expect(hasByNamePath(model, 'config.headerConfig.headerName')).toBe(false)
34
+ })
35
+
36
+ it('preserves arrays when setting nested list fields', () => {
37
+ const model: Record<string, unknown> = { users: [{ name: 'A' }] }
38
+ setByNamePath(model, ['users', 0, 'name'], 'B')
39
+ expect(model.users).toEqual([{ name: 'B' }])
40
+ expect(Array.isArray(model.users)).toBe(true)
41
+ })
42
+
43
+ it('concatenates list prefix and item name', () => {
44
+ expect(concatNamePath('users', [0, 'name'])).toEqual(['users', 0, 'name'])
45
+ })
46
+ })
@@ -63,6 +63,11 @@ describe('form-rules', () => {
63
63
  expect(rules[1]?.type).toBe('email')
64
64
  })
65
65
 
66
+ it('interpolates required message with label', async () => {
67
+ const labeled = await runFormFieldRules('', [{ required: true }], { label: '邮箱' })
68
+ expect(labeled.message).toBe('请填写邮箱')
69
+ })
70
+
66
71
  it('supports message as function', async () => {
67
72
  const result = await runFormFieldRules('x', [
68
73
  {
@@ -73,4 +78,62 @@ describe('form-rules', () => {
73
78
  expect(result.valid).toBe(false)
74
79
  expect(result.message).toBe('当前「x」太短')
75
80
  })
81
+
82
+ it('validator can read other fields via context', async () => {
83
+ const result = await runFormFieldRules('two', [
84
+ {
85
+ validator(value, ctx) {
86
+ if (String(value) !== String(ctx.getFieldValue('a'))) return '不一致'
87
+ return true
88
+ },
89
+ },
90
+ ], {
91
+ getFieldValue: (name) => (String(name) === 'a' ? 'one' : undefined),
92
+ })
93
+ expect(result).toEqual({ valid: false, message: '不一致' })
94
+
95
+ const ok = await runFormFieldRules('one', [
96
+ {
97
+ validator(value, ctx) {
98
+ if (String(value) !== String(ctx.getFieldValue('a'))) return '不一致'
99
+ return true
100
+ },
101
+ },
102
+ ], {
103
+ getFieldValue: (name) => (String(name) === 'a' ? 'one' : undefined),
104
+ })
105
+ expect(ok.valid).toBe(true)
106
+ })
107
+
108
+ it('runs custom validator on empty value for cross-field required', async () => {
109
+ const result = await runFormFieldRules(
110
+ '',
111
+ [
112
+ {
113
+ validator(_value, ctx) {
114
+ if (ctx.getFieldValue('kind') === 'email') return '邮箱必填'
115
+ return true
116
+ },
117
+ },
118
+ ],
119
+ { getFieldValue: (name) => (String(name) === 'kind' ? 'email' : undefined) },
120
+ )
121
+ expect(result).toEqual({ valid: false, message: '邮箱必填' })
122
+ })
123
+
124
+ it('treats Error return and thrown errors as failure', async () => {
125
+ const returned = await runFormFieldRules('x', [
126
+ { validator: () => new Error('返回错误') as unknown as string },
127
+ ])
128
+ expect(returned).toEqual({ valid: false, message: '返回错误' })
129
+
130
+ const thrown = await runFormFieldRules('x', [
131
+ {
132
+ validator() {
133
+ throw new Error('抛出错误')
134
+ },
135
+ },
136
+ ])
137
+ expect(thrown).toEqual({ valid: false, message: '抛出错误' })
138
+ })
76
139
  })
@@ -17,6 +17,8 @@ describe('useRsConfig / theme', () => {
17
17
  expect(en.t('select.placeholder')).toBe('Select')
18
18
  expect(zh.t('input.required')).toBe('此项为必填')
19
19
  expect(en.t('input.required')).toBe('This field is required')
20
+ expect(zh.t('form.validate.required', { label: '邮箱' })).toBe('请填写邮箱')
21
+ expect(en.t('form.validate.required', { label: 'Email' })).toBe('Please enter Email')
20
22
  expect(en.t('breadcrumb.label')).toBe('Breadcrumb')
21
23
  })
22
24
 
@@ -108,7 +108,7 @@ function onClose(event: MouseEvent): void {
108
108
 
109
109
  .rs-alert__title {
110
110
  font-size: var(--rs-font-size-sm);
111
- font-weight: 600;
111
+ font-weight: var(--rs-font-weight-semibold);
112
112
  line-height: var(--rs-line-height-tight);
113
113
  color: inherit;
114
114
  }
@@ -90,7 +90,7 @@ const ariaLabel = computed(() => props.label ?? props.alt ?? props.name ?? undef
90
90
  width: 100%;
91
91
  height: 100%;
92
92
  font-size: var(--rs-font-size-xs);
93
- font-weight: 600;
93
+ font-weight: var(--rs-font-weight-semibold);
94
94
  }
95
95
  .rs-avatar__fallback--default {
96
96
  background: var(--rs-surface-hover);
@@ -22,7 +22,7 @@ withDefaults(
22
22
  padding: 0.125rem 0.625rem;
23
23
  border-radius: var(--rs-radius-full);
24
24
  font-size: var(--rs-font-size-xs);
25
- font-weight: 500;
25
+ font-weight: var(--rs-font-weight-medium);
26
26
  line-height: var(--rs-line-height-tight);
27
27
  letter-spacing: 0.02em;
28
28
  }
@@ -56,7 +56,7 @@ const renderItems = computed(() => buildBreadcrumbRenderItems(props.items))
56
56
  }
57
57
  .rs-breadcrumb__link--current {
58
58
  color: var(--rs-text);
59
- font-weight: 500;
59
+ font-weight: var(--rs-font-weight-medium);
60
60
  pointer-events: none;
61
61
  }
62
62
  .rs-breadcrumb__sep {
@@ -196,7 +196,7 @@ watch(
196
196
  border-radius: var(--rs-btn-radius, var(--rs-radius-full));
197
197
  border: 1px solid transparent;
198
198
  font-size: var(--rs-font-size-sm);
199
- font-weight: 500;
199
+ font-weight: var(--rs-font-weight-medium);
200
200
  letter-spacing: 0.01em;
201
201
  line-height: 1;
202
202
  white-space: nowrap;
@@ -329,7 +329,7 @@ watch(
329
329
  background: transparent;
330
330
  color: var(--rs-btn-tone, var(--rs-primary));
331
331
  box-shadow: none;
332
- font-weight: 500;
332
+ font-weight: var(--rs-font-weight-medium);
333
333
  }
334
334
  .rs-btn--link:hover:not(:disabled) {
335
335
  color: var(--rs-btn-tone-hover, var(--rs-primary-hover));
@@ -346,7 +346,7 @@ watch(
346
346
  border-color: transparent;
347
347
  background: transparent;
348
348
  box-shadow: none;
349
- font-weight: 400;
349
+ font-weight: var(--rs-font-weight-regular);
350
350
  color: var(--rs-btn-tone, var(--rs-text));
351
351
  }
352
352
  .rs-btn--text.rs-btn--ssm {
@@ -474,7 +474,7 @@ watch(
474
474
  background: var(--rs-surface-elevated);
475
475
  color: var(--rs-text);
476
476
  font-size: var(--rs-font-size-xs);
477
- font-weight: 500;
477
+ font-weight: var(--rs-font-weight-medium);
478
478
  line-height: 1.25rem;
479
479
  white-space: nowrap;
480
480
  box-shadow: var(--rs-shadow-sm);
@@ -204,7 +204,7 @@ function selectDate(cell: RsCalendarCell): void {
204
204
  flex: 1;
205
205
  text-align: center;
206
206
  font-size: var(--rs-font-size-sm);
207
- font-weight: 600;
207
+ font-weight: var(--rs-font-weight-semibold);
208
208
  color: var(--rs-text);
209
209
  }
210
210
  .rs-calendar-grid__nav {
@@ -233,8 +233,8 @@ function selectDate(cell: RsCalendarCell): void {
233
233
  .rs-calendar-grid__weekday {
234
234
  padding: 0.125rem 0;
235
235
  text-align: center;
236
- font-size: 0.6875rem;
237
- font-weight: 500;
236
+ font-size: var(--rs-font-size-xs);
237
+ font-weight: var(--rs-font-weight-medium);
238
238
  color: var(--rs-muted);
239
239
  }
240
240
  .rs-calendar-grid__day {
@@ -232,7 +232,7 @@ const rootStyle = computed(() => ({
232
232
  }
233
233
  .rs-card--plain .rs-card__title {
234
234
  font-size: var(--rs-font-size-xs);
235
- font-weight: 500;
235
+ font-weight: var(--rs-font-weight-medium);
236
236
  color: var(--rs-card-description-fg);
237
237
  letter-spacing: 0;
238
238
  }
@@ -255,7 +255,7 @@ const rootStyle = computed(() => ({
255
255
  box-shadow: none;
256
256
  }
257
257
  .rs-card--outlined .rs-card__title {
258
- font-weight: 500;
258
+ font-weight: var(--rs-font-weight-medium);
259
259
  letter-spacing: 0;
260
260
  }
261
261
  .rs-card--outlined .rs-card__body {
@@ -277,7 +277,7 @@ const rootStyle = computed(() => ({
277
277
  box-shadow: none;
278
278
  }
279
279
  .rs-card--filled .rs-card__title {
280
- font-weight: 500;
280
+ font-weight: var(--rs-font-weight-medium);
281
281
  letter-spacing: 0;
282
282
  }
283
283
  .rs-card--filled .rs-card__body {
@@ -385,7 +385,7 @@ const rootStyle = computed(() => ({
385
385
  .rs-card__title {
386
386
  margin: 0;
387
387
  font-size: var(--rs-card-title-size);
388
- font-weight: 600;
388
+ font-weight: var(--rs-font-weight-semibold);
389
389
  letter-spacing: -0.01em;
390
390
  color: var(--rs-card-title-fg);
391
391
  }
@@ -192,7 +192,7 @@ function download() {
192
192
  }
193
193
 
194
194
  .rs-code-block__lang {
195
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
195
+ font-family: var(--rs-code-font-family, var(--rs-font-mono));
196
196
  font-size: var(--rs-font-size-xs);
197
197
  color: var(--rs-muted);
198
198
  text-transform: lowercase;
@@ -251,9 +251,9 @@ function download() {
251
251
  }
252
252
 
253
253
  .rs-code-block__editor :deep(.cm-scroller) {
254
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
255
- font-size: var(--rs-font-size-sm);
256
- line-height: 1.6;
254
+ font-family: var(--rs-code-font-family, var(--rs-font-mono));
255
+ font-size: var(--rs-code-font-size, var(--rs-font-size-sm));
256
+ line-height: var(--rs-code-line-height, var(--rs-line-height-relaxed));
257
257
  }
258
258
 
259
259
  /* 只读时隐藏光标,保持纯展示外观 */
@@ -445,14 +445,14 @@ defineExpose({
445
445
  justify-content: space-between;
446
446
  margin-bottom: 0.5rem;
447
447
  font-size: var(--rs-font-size-xs);
448
- font-weight: 600;
448
+ font-weight: var(--rs-font-weight-semibold);
449
449
  color: var(--rs-foreground);
450
450
  }
451
451
  .rs-code-editor__inline-edit-head kbd {
452
452
  padding: 0.1rem 0.35rem;
453
453
  border-radius: 4px;
454
454
  border: 1px solid var(--rs-border-subtle);
455
- font-size: 0.7rem;
455
+ font-size: var(--rs-font-size-xs);
456
456
  color: var(--rs-muted);
457
457
  }
458
458
  .rs-code-editor__inline-preview {
@@ -461,8 +461,8 @@ defineExpose({
461
461
  border-radius: 4px;
462
462
  background: color-mix(in srgb, var(--rs-muted) 8%, transparent);
463
463
  color: var(--rs-muted);
464
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
465
- font-size: 0.72rem;
464
+ font-family: var(--rs-code-font-family, var(--rs-font-mono));
465
+ font-size: var(--rs-font-size-xs);
466
466
  line-height: 1.4;
467
467
  white-space: pre-wrap;
468
468
  max-height: 4.5rem;
@@ -520,9 +520,9 @@ defineExpose({
520
520
  background: var(--rs-surface-elevated) !important;
521
521
  }
522
522
  .rs-code-editor__surface :deep(.cm-scroller) {
523
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
524
- font-size: var(--rs-font-size-sm);
525
- line-height: 1.6;
523
+ font-family: var(--rs-code-font-family, var(--rs-font-mono));
524
+ font-size: var(--rs-code-font-size, var(--rs-font-size-sm));
525
+ line-height: var(--rs-code-line-height, var(--rs-line-height-relaxed));
526
526
  }
527
527
  .rs-code-editor__surface :deep(.cm-gutters) {
528
528
  background: color-mix(in srgb, var(--rs-surface-elevated) 85%, var(--rs-border) 15%) !important;
@@ -571,7 +571,7 @@ defineExpose({
571
571
  border: 1px solid var(--rs-border);
572
572
  background: var(--rs-surface-elevated);
573
573
  color: var(--rs-foreground);
574
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
574
+ font-family: var(--rs-code-font-family, var(--rs-font-mono));
575
575
  font-size: var(--rs-font-size-xs);
576
576
  line-height: 1.45;
577
577
  white-space: pre-wrap;
@@ -297,7 +297,7 @@ defineExpose({
297
297
  height: 2.5rem;
298
298
  flex: 0 0 auto;
299
299
  border-radius: var(--rs-radius);
300
- font-weight: 700;
300
+ font-weight: var(--rs-font-weight-bold);
301
301
  }
302
302
  .rs-confirm-dialog__icon-glyph {
303
303
  width: 1.25rem;
@@ -328,7 +328,7 @@ defineExpose({
328
328
  .rs-confirm-dialog__title {
329
329
  margin: 0;
330
330
  font-size: var(--rs-font-size-base);
331
- font-weight: 600;
331
+ font-weight: var(--rs-font-weight-semibold);
332
332
  letter-spacing: -0.01em;
333
333
  color: var(--rs-dialog-title-fg);
334
334
  }
@@ -152,9 +152,10 @@ function onSelect(item: RsContextMenuItem) {
152
152
  align-items: center;
153
153
  gap: 0;
154
154
  padding: 0 8px 0 0;
155
- height: 26px;
155
+ height: 24px;
156
156
  border-radius: 6px;
157
- font-size: 13px;
157
+ font-size: var(--rs-font-size-xs);
158
+ font-weight: var(--rs-font-weight-regular);
158
159
  line-height: 1;
159
160
  color: var(--rs-text);
160
161
  cursor: default;
@@ -185,10 +186,11 @@ function onSelect(item: RsContextMenuItem) {
185
186
  .rs-context-menu__shortcut {
186
187
  flex-shrink: 0;
187
188
  margin-left: 16px;
188
- font-size: 12px;
189
+ font-size: var(--rs-font-size-xs);
190
+ font-weight: var(--rs-font-weight-regular);
189
191
  color: var(--rs-ctx-shortcut);
190
192
  letter-spacing: 0.02em;
191
- font-family: -apple-system, 'SF Pro Text', system-ui, sans-serif;
193
+ font-family: var(--rs-font-sans);
192
194
  }
193
195
 
194
196
  /* 子菜单展开箭头 */
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, useId, watch } from 'vue'
2
+ import { computed, ref, useAttrs, useId, watch } from 'vue'
3
3
  import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from './reka'
4
4
  import RsCalendarGrid from './RsCalendarGrid.vue'
5
5
  import RsIcon from './RsIcon.vue'
@@ -7,7 +7,12 @@ import RsTimePicker from './RsTimePicker.vue'
7
7
  import { useRsI18n } from '../composables/useRsI18n'
8
8
  import { RS_COMPONENT_SIZE_ICON_PX, type RsComponentSize } from '../theme/types'
9
9
  import { useResolvedRsComponentSize } from './resolve-size'
10
- import { useRsFormContext, useRsFormField } from './form-utils'
10
+ import {
11
+ isRsFormItemBoundControl,
12
+ useRsFormContext,
13
+ useRsFormField,
14
+ useRsFormItemContext,
15
+ } from './form-utils'
11
16
  import {
12
17
  buildLocalInputRules,
13
18
  runFormFieldRules,
@@ -26,6 +31,7 @@ import {
26
31
  getNextMonth,
27
32
  getNowDateTime,
28
33
  formatTimestampValue,
34
+ fromInternalPickerValue,
29
35
  getTodayDate,
30
36
  isDateRangeEmpty,
31
37
  isDateRangeOrdered,
@@ -35,6 +41,8 @@ import {
35
41
  parseDateTimeValue,
36
42
  parseDateValue,
37
43
  parseTimestampValue,
44
+ toInternalPickerValue,
45
+ toRangeEndpointString,
38
46
  type RsDatePickerModelValue,
39
47
  type RsDatePickerShortcut,
40
48
  type RsDatePickerValueFormat,
@@ -55,6 +63,8 @@ function isRangeModel(value: RsDatePickerModelValue): value is RsDateRangeValue
55
63
  return typeof value === 'object' && value !== null && !Array.isArray(value) && ('start' in value || 'end' in value)
56
64
  }
57
65
 
66
+ defineOptions({ inheritAttrs: false })
67
+
58
68
  const model = defineModel<RsDatePickerModelValue>({ default: '' })
59
69
  const open = defineModel<boolean>('open', { default: false })
60
70
 
@@ -75,12 +85,19 @@ const props = withDefaults(
75
85
  * 是否选择秒。未传时:withTime 为 true 则默认带秒,纯日期为 false。
76
86
  */
77
87
  withSeconds?: boolean
78
- /** string:YYYY-MM-DD[/HH:mm:ss];timestamp:毫秒时间戳 / [start, end] */
88
+ /**
89
+ * 绑定值格式(展示仍为墙钟)。
90
+ * string:YYYY-MM-DD[/ HH:mm:ss];timestamp:毫秒;iso:本地偏移 RFC3339;
91
+ * 亦可传入 dayjs 模板(对齐 Element Plus value-format)。
92
+ */
79
93
  valueFormat?: RsDatePickerValueFormat
80
94
  labelPosition?: RsDatePickerLabelPosition
81
95
  /** 底部快捷选项(有值时替代「今天/现在」链接) */
82
96
  shortcuts?: RsDatePickerShortcut[]
83
97
  size?: RsComponentSize
98
+ id?: string
99
+ invalid?: boolean
100
+ showValidateMessage?: boolean
84
101
  }>(),
85
102
  {
86
103
  disabled: false,
@@ -89,10 +106,12 @@ const props = withDefaults(
89
106
  withTime: false,
90
107
  valueFormat: 'string',
91
108
  labelPosition: 'top',
109
+ showValidateMessage: true,
92
110
  },
93
111
  )
94
112
 
95
113
  const fieldId = useId()
114
+ const attrs = useAttrs()
96
115
  const { t } = useRsI18n()
97
116
  const resolvedSize = useResolvedRsComponentSize(() => props.size)
98
117
  const triggerIconSize = computed(() => RS_COMPONENT_SIZE_ICON_PX[resolvedSize.value])
@@ -101,7 +120,22 @@ const resolvedWithSeconds = computed(() =>
101
120
  props.withSeconds !== undefined ? props.withSeconds : props.withTime,
102
121
  )
103
122
  const formContext = useRsFormContext()
123
+ const formItem = useRsFormItemContext()
124
+ const boundToItem = computed(() =>
125
+ isRsFormItemBoundControl(formItem, { id: props.id, name: props.name }),
126
+ )
104
127
  const autoMessage = ref('')
128
+ const isInvalid = computed(() =>
129
+ Boolean(
130
+ props.invalid ||
131
+ (boundToItem.value && formItem?.invalid.value) ||
132
+ (!boundToItem.value && autoMessage.value),
133
+ ),
134
+ )
135
+ const triggerId = computed(() => props.id || fieldId)
136
+ const visibleMessage = computed(() =>
137
+ boundToItem.value || props.showValidateMessage === false ? '' : autoMessage.value,
138
+ )
105
139
 
106
140
  const viewYear = ref(getTodayDate().year)
107
141
  const viewMonth = ref(getTodayDate().month)
@@ -117,47 +151,52 @@ const draftTime = ref('')
117
151
  const draftStartTime = ref('')
118
152
  const draftEndTime = ref('')
119
153
 
154
+ const convertOptions = computed(() => ({
155
+ valueFormat: props.valueFormat ?? 'string',
156
+ withTime: props.withTime,
157
+ }))
158
+
120
159
  const useTimestamp = computed(() => props.valueFormat === 'timestamp')
121
160
 
122
161
  const rangeModel = computed<RsDateRangeValue>({
123
162
  get() {
124
163
  if (!props.range) return { ...EMPTY_DATE_RANGE }
125
164
  const value = model.value
126
- if (useTimestamp.value) {
127
- if (!isTimestampRange(value)) return { ...EMPTY_DATE_RANGE }
165
+ const opts = convertOptions.value
166
+ if (isTimestampRange(value)) {
128
167
  return {
129
- start: formatTimestampValue(value[0], props.withTime),
130
- end: formatTimestampValue(value[1], props.withTime),
168
+ start: formatTimestampValue(value[0], opts.withTime),
169
+ end: formatTimestampValue(value[1], opts.withTime),
131
170
  }
132
171
  }
133
- return isRangeModel(value) ? value : { ...EMPTY_DATE_RANGE }
172
+ if (!isRangeModel(value)) return { ...EMPTY_DATE_RANGE }
173
+ return {
174
+ start: toInternalPickerValue(value.start, opts) || undefined,
175
+ end: toInternalPickerValue(value.end, opts) || undefined,
176
+ }
134
177
  },
135
178
  set(value) {
179
+ const opts = convertOptions.value
136
180
  if (useTimestamp.value) {
137
181
  const start = parseTimestampValue(value.start)
138
182
  const end = parseTimestampValue(value.end)
139
183
  model.value = start != null && end != null ? [start, end] : null
140
184
  return
141
185
  }
142
- model.value = value
186
+ model.value = {
187
+ start: toRangeEndpointString(fromInternalPickerValue(value.start || '', opts)),
188
+ end: toRangeEndpointString(fromInternalPickerValue(value.end || '', opts)),
189
+ }
143
190
  },
144
191
  })
145
192
 
146
193
  const singleModel = computed({
147
194
  get() {
148
195
  if (props.range) return ''
149
- const value = model.value
150
- if (useTimestamp.value) {
151
- return typeof value === 'number' ? formatTimestampValue(value, props.withTime) : ''
152
- }
153
- return typeof value === 'string' ? value : ''
196
+ return toInternalPickerValue(model.value, convertOptions.value)
154
197
  },
155
198
  set(value: string) {
156
- if (useTimestamp.value) {
157
- model.value = value ? parseTimestampValue(value) : null
158
- return
159
- }
160
- model.value = value
199
+ model.value = fromInternalPickerValue(value, convertOptions.value)
161
200
  },
162
201
  })
163
202
 
@@ -422,11 +461,7 @@ function clearSelection(): void {
422
461
  draftEnd.value = null
423
462
  if (props.withTime) resetRangeTimeDrafts()
424
463
  } else {
425
- if (useTimestamp.value) {
426
- model.value = null
427
- } else {
428
- singleModel.value = ''
429
- }
464
+ singleModel.value = ''
430
465
  draftDate.value = null
431
466
  if (props.withTime) draftTime.value = currentTimeValue()
432
467
  }
@@ -577,15 +612,16 @@ watch(open, (isOpen) => {
577
612
  <div class="rs-date-picker" :class="`rs-date-picker--${resolvedSize}`">
578
613
  <PopoverRoot v-model:open="open">
579
614
  <PopoverTrigger
580
- :id="fieldId"
615
+ v-bind="attrs"
616
+ :id="triggerId"
581
617
  type="button"
582
618
  class="rs-date-picker__trigger"
583
619
  :class="{
584
620
  'rs-date-picker__trigger--placeholder': isEmpty,
585
- 'rs-date-picker__trigger--invalid': !!autoMessage,
621
+ 'rs-date-picker__trigger--invalid': isInvalid,
586
622
  }"
587
623
  :disabled="disabled"
588
- :aria-invalid="autoMessage ? true : undefined"
624
+ :aria-invalid="isInvalid || undefined"
589
625
  >
590
626
  <span class="rs-date-picker__leading">
591
627
  <RsIcon :name="triggerIcon" :size="triggerIconSize" class="rs-date-picker__icon" />
@@ -728,8 +764,8 @@ watch(open, (isOpen) => {
728
764
  </PopoverRoot>
729
765
  </div>
730
766
 
731
- <p v-if="autoMessage" class="rs-date-picker-field__error" role="alert">
732
- {{ autoMessage }}
767
+ <p v-if="visibleMessage" class="rs-date-picker-field__error" role="alert">
768
+ {{ visibleMessage }}
733
769
  </p>
734
770
  <span v-if="hint" class="rs-field__hint">{{ hint }}</span>
735
771
  </div>
@@ -758,6 +794,7 @@ watch(open, (isOpen) => {
758
794
  line-height: var(--rs-line-height-tight);
759
795
  text-align: left;
760
796
  cursor: pointer;
797
+ outline: none;
761
798
  box-shadow: var(--rs-input-shadow, none);
762
799
  transition:
763
800
  border-color var(--rs-transition-fast),
@@ -803,6 +840,13 @@ watch(open, (isOpen) => {
803
840
  .rs-date-picker__trigger--invalid {
804
841
  border-color: var(--rs-danger, var(--rs-color-danger, #dc2626));
805
842
  }
843
+ .rs-date-picker__trigger--invalid:focus-visible {
844
+ border-color: var(--rs-danger, var(--rs-color-danger, #dc2626));
845
+ box-shadow:
846
+ var(--rs-input-shadow, none),
847
+ 0 0 0 var(--rs-focus-ring-width, 2px)
848
+ color-mix(in srgb, var(--rs-danger, #dc2626) 14%, transparent);
849
+ }
806
850
  .rs-date-picker-field__error {
807
851
  margin: var(--rs-space-xs) 0 0;
808
852
  color: var(--rs-danger, var(--rs-color-danger, #dc2626));
@@ -874,7 +918,7 @@ watch(open, (isOpen) => {
874
918
  }
875
919
  .rs-date-picker__pane-title {
876
920
  font-size: var(--rs-font-size-xs);
877
- font-weight: 600;
921
+ font-weight: var(--rs-font-weight-semibold);
878
922
  color: var(--rs-muted);
879
923
  }
880
924
  .rs-date-picker__footer {
@@ -979,7 +1023,7 @@ watch(open, (isOpen) => {
979
1023
  .rs-date-picker__time-label {
980
1024
  flex-shrink: 0;
981
1025
  font-size: var(--rs-font-size-xs);
982
- font-weight: 500;
1026
+ font-weight: var(--rs-font-weight-medium);
983
1027
  color: var(--rs-muted);
984
1028
  }
985
1029
  .rs-date-picker__time-picker {