niuma-ui 1.0.0 → 1.0.2

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 (159) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/NOTICE +2 -2
  3. package/package.json +13 -5
  4. package/src/__tests__/RsButton.spec.ts +47 -0
  5. package/src/__tests__/RsCard.spec.ts +160 -1
  6. package/src/__tests__/RsConfirmDialog.spec.ts +127 -0
  7. package/src/__tests__/RsContextMenu.spec.ts +12 -0
  8. package/src/__tests__/RsDateTimePicker.spec.ts +11 -0
  9. package/src/__tests__/RsDescriptions.spec.ts +50 -0
  10. package/src/__tests__/RsDialog.spec.ts +117 -0
  11. package/src/__tests__/RsDrawer.spec.ts +96 -3
  12. package/src/__tests__/RsDynamicTags.spec.ts +52 -0
  13. package/src/__tests__/RsForm.spec.ts +245 -1
  14. package/src/__tests__/RsInput.spec.ts +15 -1
  15. package/src/__tests__/RsMenu.spec.ts +406 -14
  16. package/src/__tests__/RsPagination.spec.ts +67 -2
  17. package/src/__tests__/RsSelect.spec.ts +42 -1
  18. package/src/__tests__/RsSwitch.spec.ts +44 -0
  19. package/src/__tests__/RsTable.a11y.spec.ts +83 -0
  20. package/src/__tests__/RsTable.dom-baseline.spec.ts +87 -0
  21. package/src/__tests__/RsTable.perf.spec.ts +23 -4
  22. package/src/__tests__/RsTable.spec.ts +63 -8
  23. package/src/__tests__/RsTable.ssr.spec.ts +71 -0
  24. package/src/__tests__/RsTable.summary.spec.ts +45 -0
  25. package/src/__tests__/RsTabs.spec.ts +102 -4
  26. package/src/__tests__/RsTerminal.spec.ts +1 -1
  27. package/src/__tests__/RsTimePicker.spec.ts +11 -0
  28. package/src/__tests__/RsUpload.spec.ts +62 -6
  29. package/src/__tests__/createRsDialog.spec.ts +145 -0
  30. package/src/__tests__/form-rules.spec.ts +76 -0
  31. package/src/__tests__/perf-budgets.ts +32 -0
  32. package/src/__tests__/rs-table-api.spec.ts +206 -0
  33. package/src/__tests__/rs-table-compat-matrix.spec.ts +108 -0
  34. package/src/__tests__/rs-table-echarts-adapter.spec.ts +43 -0
  35. package/src/__tests__/rs-table-grid-nav.spec.ts +63 -0
  36. package/src/__tests__/rs-table-isolation.spec.ts +158 -0
  37. package/src/__tests__/split-pane-utils.spec.ts +21 -0
  38. package/src/__tests__/table-chart-utils.spec.ts +88 -0
  39. package/src/__tests__/table-summary-utils.spec.ts +62 -0
  40. package/src/__tests__/table-utils.spec.ts +124 -0
  41. package/src/__tests__/theme.spec.ts +25 -0
  42. package/src/__tests__/useRsTableColumnResize.spec.ts +25 -0
  43. package/src/__tests__/useRsTableCore.perf.spec.ts +170 -0
  44. package/src/components/RsAlert.vue +212 -0
  45. package/src/components/RsButton.vue +145 -21
  46. package/src/components/RsCard.vue +200 -16
  47. package/src/components/RsConfirmDialog.vue +215 -28
  48. package/src/components/RsContextMenu.vue +51 -13
  49. package/src/components/RsDatePicker.vue +267 -34
  50. package/src/components/RsDateTimePicker.vue +19 -2
  51. package/src/components/RsDescriptions.vue +186 -0
  52. package/src/components/RsDescriptionsItem.vue +32 -0
  53. package/src/components/RsDialog.vue +363 -51
  54. package/src/components/RsDivider.vue +89 -0
  55. package/src/components/RsDrawer.vue +558 -33
  56. package/src/components/RsDropdown.vue +33 -2
  57. package/src/components/RsDynamicTags.vue +517 -0
  58. package/src/components/RsForm.vue +97 -8
  59. package/src/components/RsInput.vue +80 -82
  60. package/src/components/RsInputNumber.vue +64 -23
  61. package/src/components/RsLabel.vue +20 -5
  62. package/src/components/RsLoadingBar.vue +151 -0
  63. package/src/components/RsMenu.vue +73 -15
  64. package/src/components/RsMenuItems.vue +147 -9
  65. package/src/components/RsPagination.vue +93 -14
  66. package/src/components/RsRadio.vue +81 -0
  67. package/src/components/RsRadioItem.vue +156 -0
  68. package/src/components/RsSelect.vue +67 -58
  69. package/src/components/RsSplitPane.vue +69 -1
  70. package/src/components/RsSwitch.vue +179 -0
  71. package/src/components/RsTable.vue +653 -2555
  72. package/src/components/RsTabs.vue +415 -83
  73. package/src/components/RsTag.vue +191 -0
  74. package/src/components/RsTerminal.vue +3 -3
  75. package/src/components/RsTimePicker.vue +44 -5
  76. package/src/components/RsTimePickerColumns.vue +136 -77
  77. package/src/components/RsToaster.vue +58 -18
  78. package/src/components/RsTree.vue +39 -21
  79. package/src/components/RsUpload.vue +145 -12
  80. package/src/components/RsVNodeHost.vue +20 -0
  81. package/src/components/button-utils.ts +39 -0
  82. package/src/components/context-menu-utils.ts +6 -0
  83. package/src/components/date-picker-utils.ts +84 -4
  84. package/src/components/descriptions-utils.ts +24 -0
  85. package/src/components/dialog-utils.ts +147 -0
  86. package/src/components/dialog-window.ts +74 -9
  87. package/src/components/drawer-utils.ts +73 -0
  88. package/src/components/form-rules.ts +257 -0
  89. package/src/components/form-utils.ts +52 -2
  90. package/src/components/menu-utils.ts +11 -0
  91. package/src/components/overlay-utils.ts +3 -0
  92. package/src/components/radio-utils.ts +12 -0
  93. package/src/components/reka.ts +5 -0
  94. package/src/components/select-utils.ts +21 -2
  95. package/src/components/split-pane-utils.ts +65 -12
  96. package/src/components/table/RsTableBody.vue +217 -0
  97. package/src/components/table/RsTableBodyRow.vue +90 -1
  98. package/src/components/table/RsTableColGroup.vue +41 -0
  99. package/src/components/table/RsTableHeader.vue +158 -0
  100. package/src/components/table/RsTableSummaryRow.vue +52 -0
  101. package/src/components/table/rs-table-api.ts +99 -0
  102. package/src/components/table/rs-table-compat-matrix.ts +71 -0
  103. package/src/components/table/rs-table-echarts-adapter.ts +81 -0
  104. package/src/components/table/rs-table-feature-host.ts +178 -0
  105. package/src/components/table/rs-table-grid-nav.ts +122 -0
  106. package/src/components/table/rs-table-header-types.ts +47 -0
  107. package/src/components/table/rs-table-module-registry.ts +72 -0
  108. package/src/components/table/rs-table-props.ts +274 -0
  109. package/src/components/table/rs-table-view-context.ts +258 -0
  110. package/src/components/table/rs-table.css +84 -6
  111. package/src/components/table/table-chart-utils.ts +165 -0
  112. package/src/components/table/table-features.ts +284 -0
  113. package/src/components/table/table-summary-utils.ts +194 -0
  114. package/src/components/table-utils.ts +452 -2
  115. package/src/components/tabs-utils.ts +142 -2
  116. package/src/components/terminal-utils.ts +1 -1
  117. package/src/components/terminal-wheel.ts +1 -1
  118. package/src/components/time-picker-utils.ts +76 -3
  119. package/src/components/upload-utils.ts +122 -0
  120. package/src/components/virtual-list-utils.ts +15 -2
  121. package/src/composables/assembleRsTableApi.ts +79 -0
  122. package/src/composables/bindRsTableViewContext.ts +269 -0
  123. package/src/composables/createRsDialog.ts +357 -0
  124. package/src/composables/createRsTableEditEmitBridge.ts +38 -0
  125. package/src/composables/useRsLoadingBar.ts +36 -0
  126. package/src/composables/useRsTable.ts +134 -0
  127. package/src/composables/useRsTableA11y.ts +52 -0
  128. package/src/composables/useRsTableChartBridge.ts +79 -0
  129. package/src/composables/useRsTableColumnLayout.ts +320 -0
  130. package/src/composables/useRsTableColumnResize.ts +244 -0
  131. package/src/composables/useRsTableColumnVirtual.ts +180 -0
  132. package/src/composables/useRsTableColumns.ts +200 -0
  133. package/src/composables/useRsTableContextMenu.ts +257 -0
  134. package/src/composables/useRsTableCore.ts +256 -0
  135. package/src/composables/useRsTableEditActions.ts +606 -0
  136. package/src/composables/useRsTableEditLayer.ts +251 -0
  137. package/src/composables/useRsTableEngine.ts +357 -0
  138. package/src/composables/useRsTableGridKeyboard.ts +123 -0
  139. package/src/composables/useRsTableHeadless.ts +137 -0
  140. package/src/composables/useRsTableInteraction.ts +433 -0
  141. package/src/composables/useRsTableScrollHost.ts +75 -0
  142. package/src/composables/useRsTableScrollLayout.ts +255 -0
  143. package/src/composables/useRsTableSelectionSource.ts +89 -0
  144. package/src/composables/useRsTableShell.ts +416 -0
  145. package/src/composables/useRsTableShellChrome.ts +170 -0
  146. package/src/composables/useRsTableSummary.ts +113 -0
  147. package/src/composables/useRsTableViewProvide.ts +263 -0
  148. package/src/composables/useRsTableVirtual.ts +154 -0
  149. package/src/composables/useRsTabsNav.ts +4 -0
  150. package/src/composables/useTableEdit.ts +1 -1
  151. package/src/icons/lucide.ts +2 -2
  152. package/src/index.ts +309 -9
  153. package/src/locale/messages.ts +30 -0
  154. package/src/styles.css +305 -52
  155. package/src/theme/brand.example.css +21 -2
  156. package/src/theme/presets.ts +4 -0
  157. package/src/theme/types.ts +67 -0
  158. package/src/components/RsTableColgroup.vue +0 -17
  159. package/src/components/RsTableHeaderRow.vue +0 -30
@@ -5,10 +5,13 @@ withDefaults(
5
5
  required?: boolean
6
6
  hint?: string
7
7
  disabled?: boolean
8
+ /** 文案单行不换行(表单左侧标签等场景) */
9
+ nowrap?: boolean
8
10
  }>(),
9
11
  {
10
12
  required: false,
11
13
  disabled: false,
14
+ nowrap: false,
12
15
  },
13
16
  )
14
17
  </script>
@@ -16,7 +19,10 @@ withDefaults(
16
19
  <template>
17
20
  <label
18
21
  class="rs-label"
19
- :class="{ 'rs-label--disabled': disabled }"
22
+ :class="{
23
+ 'rs-label--disabled': disabled,
24
+ 'rs-label--nowrap': nowrap,
25
+ }"
20
26
  :for="forId"
21
27
  >
22
28
  <span class="rs-label__text">
@@ -28,19 +34,28 @@ withDefaults(
28
34
  </template>
29
35
 
30
36
  <style scoped>
37
+ /*
38
+ * 消费全局 token(styles.css):
39
+ * --rs-label-font-size / --rs-label-font-weight / --rs-label-color / --rs-label-line-height
40
+ * 与 .rs-field__label 共用;父级覆盖即可,无需 :deep。
41
+ */
31
42
  .rs-label {
32
43
  display: flex;
33
44
  flex-direction: column;
34
45
  gap: 0.25rem;
35
- font-size: var(--rs-font-size-sm);
36
- color: var(--rs-text);
46
+ font-size: var(--rs-label-font-size);
47
+ line-height: var(--rs-label-line-height);
48
+ color: var(--rs-label-color);
37
49
  }
38
50
  .rs-label--disabled {
39
51
  opacity: 0.5;
40
52
  cursor: not-allowed;
41
53
  }
54
+ .rs-label--nowrap .rs-label__text {
55
+ white-space: nowrap;
56
+ }
42
57
  .rs-label__text {
43
- font-weight: 500;
58
+ font-weight: var(--rs-label-font-weight);
44
59
  }
45
60
  .rs-label__required {
46
61
  margin-left: 0.125rem;
@@ -48,7 +63,7 @@ withDefaults(
48
63
  }
49
64
  .rs-label__hint {
50
65
  font-size: var(--rs-font-size-xs);
51
- font-weight: 400;
66
+ font-weight: var(--rs-font-weight-regular);
52
67
  color: var(--rs-muted);
53
68
  }
54
69
  </style>
@@ -0,0 +1,151 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, provide, ref, shallowRef, watch } from 'vue'
3
+ import { RS_LOADING_BAR_KEY, type RsLoadingBarApi } from '../composables/useRsLoadingBar'
4
+
5
+ const props = withDefaults(
6
+ defineProps<{
7
+ /** 受控进度 0-100;未传时由 start/finish/error 驱动 */
8
+ progress?: number
9
+ height?: number
10
+ color?: string
11
+ errorColor?: string
12
+ }>(),
13
+ {
14
+ height: 2,
15
+ color: 'var(--rs-primary)',
16
+ errorColor: 'var(--rs-danger)',
17
+ },
18
+ )
19
+
20
+ const internalProgress = ref(0)
21
+ const visible = ref(false)
22
+ const errored = ref(false)
23
+ let trickleTimer: ReturnType<typeof setInterval> | null = null
24
+
25
+ const displayProgress = computed(() =>
26
+ props.progress !== undefined ? Math.min(100, Math.max(0, props.progress)) : internalProgress.value,
27
+ )
28
+
29
+ const isVisible = computed(
30
+ () => visible.value || (props.progress !== undefined && props.progress > 0),
31
+ )
32
+
33
+ const barStyle = computed(() => ({
34
+ height: `${props.height}px`,
35
+ width: `${displayProgress.value}%`,
36
+ background: errored.value ? props.errorColor : props.color,
37
+ }))
38
+
39
+ function clearTrickle(): void {
40
+ if (trickleTimer) {
41
+ clearInterval(trickleTimer)
42
+ trickleTimer = null
43
+ }
44
+ }
45
+
46
+ function start(): void {
47
+ clearTrickle()
48
+ errored.value = false
49
+ visible.value = true
50
+ internalProgress.value = 8
51
+ trickleTimer = setInterval(() => {
52
+ if (internalProgress.value >= 92) return
53
+ const step = Math.max(0.5, (92 - internalProgress.value) * 0.08)
54
+ internalProgress.value = Math.min(92, internalProgress.value + step)
55
+ }, 200)
56
+ }
57
+
58
+ function finish(): void {
59
+ clearTrickle()
60
+ errored.value = false
61
+ visible.value = true
62
+ internalProgress.value = 100
63
+ window.setTimeout(() => {
64
+ visible.value = false
65
+ internalProgress.value = 0
66
+ }, 280)
67
+ }
68
+
69
+ function error(): void {
70
+ clearTrickle()
71
+ errored.value = true
72
+ visible.value = true
73
+ internalProgress.value = 100
74
+ window.setTimeout(() => {
75
+ visible.value = false
76
+ errored.value = false
77
+ internalProgress.value = 0
78
+ }, 400)
79
+ }
80
+
81
+ const api = shallowRef<RsLoadingBarApi | null>({ start, finish, error })
82
+ provide(RS_LOADING_BAR_KEY, api)
83
+
84
+ watch(
85
+ () => props.progress,
86
+ (value) => {
87
+ if (value === undefined) return
88
+ visible.value = value > 0 && value < 100
89
+ if (value >= 100) {
90
+ visible.value = true
91
+ window.setTimeout(() => {
92
+ visible.value = false
93
+ }, 280)
94
+ }
95
+ },
96
+ )
97
+
98
+ onBeforeUnmount(() => {
99
+ clearTrickle()
100
+ api.value = null
101
+ })
102
+
103
+ defineExpose({
104
+ start,
105
+ finish,
106
+ error,
107
+ })
108
+ </script>
109
+
110
+ <template>
111
+ <!--
112
+ 默认插槽内的后代可通过 useRsLoadingBar() 获取 API。
113
+ 进度条本身 fixed 定位,不参与布局。
114
+ -->
115
+ <div
116
+ class="rs-loading-bar"
117
+ :class="{ 'rs-loading-bar--visible': isVisible }"
118
+ :role="isVisible ? 'progressbar' : undefined"
119
+ :aria-hidden="isVisible ? undefined : 'true'"
120
+ :aria-valuenow="isVisible ? Math.round(displayProgress) : undefined"
121
+ :aria-valuemin="isVisible ? 0 : undefined"
122
+ :aria-valuemax="isVisible ? 100 : undefined"
123
+ >
124
+ <div class="rs-loading-bar__peg" :style="barStyle" />
125
+ </div>
126
+ <slot />
127
+ </template>
128
+
129
+ <style scoped>
130
+ .rs-loading-bar {
131
+ position: fixed;
132
+ top: 0;
133
+ left: 0;
134
+ right: 0;
135
+ z-index: var(--rs-z-loading-bar);
136
+ pointer-events: none;
137
+ opacity: 0;
138
+ transition: opacity 0.2s ease;
139
+ }
140
+
141
+ .rs-loading-bar--visible {
142
+ opacity: 1;
143
+ }
144
+
145
+ .rs-loading-bar__peg {
146
+ height: 2px;
147
+ transition:
148
+ width 0.2s ease,
149
+ background-color 0.2s ease;
150
+ }
151
+ </style>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { watch } from 'vue'
2
+ import { computed, watch } from 'vue'
3
3
  import { useRsI18n } from '../composables/useRsI18n'
4
4
  import {
5
5
  findMenuParentKeys,
@@ -16,10 +16,13 @@ const props = withDefaults(
16
16
  items: RsMenuData
17
17
  mode?: 'vertical' | 'horizontal'
18
18
  collapsed?: boolean
19
+ /** 选中叶子时是否同时高亮祖先父级(仅字体色,无背景);默认 false 只高亮叶子 */
20
+ highlightParent?: boolean
19
21
  }>(),
20
22
  {
21
23
  mode: 'vertical',
22
24
  collapsed: false,
25
+ highlightParent: false,
23
26
  },
24
27
  )
25
28
 
@@ -29,6 +32,11 @@ const emit = defineEmits<{
29
32
 
30
33
  const { t } = useRsI18n()
31
34
 
35
+ /** collapsed 仅对垂直模式生效;水平模式忽略,避免空气泡子菜单 */
36
+ const isCollapsedVertical = computed(
37
+ () => props.mode === 'vertical' && props.collapsed,
38
+ )
39
+
32
40
  function onSelect(key: string) {
33
41
  model.value = key
34
42
  emit('select', key)
@@ -67,19 +75,20 @@ watch(
67
75
  <template>
68
76
  <nav
69
77
  class="rs-menu"
70
- :class="[`rs-menu--${mode}`, { 'rs-menu--collapsed': collapsed }]"
78
+ :class="[`rs-menu--${mode}`, { 'rs-menu--collapsed': isCollapsedVertical }]"
71
79
  :aria-label="t('menu.label')"
72
80
  >
73
81
  <ul class="rs-menu__root-list">
74
82
  <template v-for="(entry, index) in items" :key="index">
75
83
  <li v-if="isMenuItemGroup(entry)" class="rs-menu__group">
76
- <span v-if="!collapsed" class="rs-menu__group-label">{{ entry.label }}</span>
84
+ <span v-if="!isCollapsedVertical" class="rs-menu__group-label">{{ entry.label }}</span>
77
85
  <RsMenuItems
78
86
  :items="entry.children"
79
87
  :active-key="model"
80
88
  :open-keys="openKeys"
81
- :collapsed="collapsed"
89
+ :collapsed="isCollapsedVertical"
82
90
  :mode="mode"
91
+ :highlight-parent="highlightParent"
83
92
  @select="onSelect"
84
93
  @toggle-open="onToggleOpen"
85
94
  />
@@ -90,8 +99,9 @@ watch(
90
99
  :items="[entry]"
91
100
  :active-key="model"
92
101
  :open-keys="openKeys"
93
- :collapsed="collapsed"
102
+ :collapsed="isCollapsedVertical"
94
103
  :mode="mode"
104
+ :highlight-parent="highlightParent"
95
105
  @select="onSelect"
96
106
  @toggle-open="onToggleOpen"
97
107
  />
@@ -159,14 +169,14 @@ watch(
159
169
  align-items: center;
160
170
  gap: 0.5rem;
161
171
  width: 100%;
162
- min-height: var(--rs-control-height-md);
172
+ min-height: var(--rs-menu-item-height);
163
173
  padding: 0 0.5rem;
164
174
  padding-left: calc(0.5rem + var(--rs-menu-depth, 0) * 0.875rem);
165
175
  border: none;
166
- border-radius: var(--rs-radius-sm);
176
+ border-radius: var(--rs-menu-item-radius);
167
177
  background: transparent;
168
- color: var(--rs-muted);
169
- font-size: var(--rs-font-size-sm);
178
+ color: var(--rs-menu-item-fg);
179
+ font-size: var(--rs-menu-item-font-size);
170
180
  text-align: left;
171
181
  cursor: pointer;
172
182
  transition:
@@ -188,23 +198,71 @@ watch(
188
198
  .rs-menu--collapsed .rs-menu__list--nested {
189
199
  display: none;
190
200
  }
201
+ .rs-menu__flyout {
202
+ z-index: var(--rs-z-dropdown);
203
+ min-width: 10rem;
204
+ max-width: min(16rem, 90vw);
205
+ padding: 0.25rem;
206
+ border-radius: var(--rs-radius-sm);
207
+ border: 1px solid var(--rs-border);
208
+ background: var(--rs-surface-elevated);
209
+ box-shadow: var(--rs-shadow);
210
+ outline: none;
211
+ }
212
+ .rs-menu__flyout-title {
213
+ display: block;
214
+ padding: 0.375rem 0.5rem 0.25rem;
215
+ margin-bottom: 0.125rem;
216
+ border-bottom: 1px solid var(--rs-border);
217
+ font-size: var(--rs-font-size-xs);
218
+ font-weight: 600;
219
+ color: var(--rs-muted);
220
+ letter-spacing: 0.04em;
221
+ line-height: var(--rs-line-height-tight);
222
+ user-select: none;
223
+ }
224
+ .rs-menu__flyout .rs-menu__list--nested {
225
+ display: flex;
226
+ }
191
227
  .rs-menu__item:hover:not(:disabled):not(.rs-menu__item--active) {
192
- color: var(--rs-text);
193
- background: var(--rs-surface-hover);
228
+ color: var(--rs-menu-item-fg-hover);
229
+ background: var(--rs-menu-item-bg-hover);
194
230
  }
195
231
  .rs-menu__item:focus-visible {
196
232
  outline: none;
197
233
  box-shadow: 0 0 0 var(--rs-focus-ring-width, 2px) var(--rs-focus-ring);
198
234
  }
199
235
  .rs-menu__item--active {
200
- color: var(--rs-primary);
201
- background: color-mix(in srgb, var(--rs-primary) 12%, transparent);
202
- font-weight: 500;
236
+ color: var(--rs-menu-item-active-fg);
237
+ background: var(--rs-menu-item-active-bg);
238
+ font-weight: var(--rs-menu-item-active-weight);
239
+ }
240
+ /* 子菜单选中时父级仅字体高亮,不加背景 */
241
+ .rs-menu__item--active-parent {
242
+ color: var(--rs-menu-item-active-parent-fg);
243
+ background: var(--rs-menu-item-active-parent-bg);
244
+ font-weight: var(--rs-menu-item-active-weight);
245
+ }
246
+ .rs-menu__item--active-parent:hover:not(:disabled) {
247
+ color: var(--rs-menu-item-active-parent-fg);
248
+ background: var(--rs-menu-item-bg-hover);
203
249
  }
204
250
  .rs-menu__item:disabled {
205
251
  opacity: 0.38;
206
252
  cursor: not-allowed;
207
253
  }
254
+ /** 固定图标列宽:有/无图标时文案起点一致,嵌套缩进再叠在左侧 padding 上 */
255
+ .rs-menu__icon-slot {
256
+ display: inline-flex;
257
+ flex-shrink: 0;
258
+ align-items: center;
259
+ justify-content: center;
260
+ width: 1rem;
261
+ height: 1rem;
262
+ }
263
+ .rs-menu__icon {
264
+ display: block;
265
+ }
208
266
  .rs-menu__label {
209
267
  flex: 1;
210
268
  min-width: 0;
@@ -239,7 +297,7 @@ watch(
239
297
  position: absolute;
240
298
  top: calc(100% + 0.25rem);
241
299
  left: 0;
242
- z-index: 50;
300
+ z-index: var(--rs-z-dropdown);
243
301
  min-width: 10rem;
244
302
  padding: 0.25rem;
245
303
  border-radius: var(--rs-radius-sm);
@@ -1,12 +1,16 @@
1
1
  <script setup lang="ts">
2
- import { computed } from 'vue'
2
+ import { computed, onBeforeUnmount, ref } from 'vue'
3
3
  import {
4
4
  CollapsibleContent,
5
5
  CollapsibleRoot,
6
6
  CollapsibleTrigger,
7
+ PopoverContent,
8
+ PopoverPortal,
9
+ PopoverRoot,
10
+ PopoverTrigger,
7
11
  } from './reka'
8
12
  import { useRsI18n } from '../composables/useRsI18n'
9
- import type { RsMenuItem } from './menu-utils'
13
+ import { menuItemContainsKey, type RsMenuItem } from './menu-utils'
10
14
  import RsIcon from './RsIcon.vue'
11
15
  import RsMenuItems from './RsMenuItems.vue'
12
16
 
@@ -18,11 +22,14 @@ const props = withDefaults(
18
22
  collapsed?: boolean
19
23
  mode?: 'vertical' | 'horizontal'
20
24
  depth?: number
25
+ /** 选中叶子时是否同时高亮祖先父级(仅字体色,无背景) */
26
+ highlightParent?: boolean
21
27
  }>(),
22
28
  {
23
29
  collapsed: false,
24
30
  mode: 'vertical',
25
31
  depth: 0,
32
+ highlightParent: false,
26
33
  },
27
34
  )
28
35
 
@@ -35,14 +42,34 @@ const { t } = useRsI18n()
35
42
  const isCollapsedVertical = computed(() => props.mode === 'vertical' && props.collapsed)
36
43
  const canExpandSubmenu = computed(() => !isCollapsedVertical.value)
37
44
 
45
+ /** 垂直折叠态:当前悬停展开的子菜单 key */
46
+ const flyoutKey = ref<string | null>(null)
47
+ let flyoutCloseTimer: ReturnType<typeof setTimeout> | undefined
48
+
38
49
  function isOpen(key: string) {
39
50
  return props.openKeys.includes(key)
40
51
  }
41
52
 
42
- function isActive(key: string) {
53
+ function isExactActive(key: string) {
43
54
  return props.activeKey === key
44
55
  }
45
56
 
57
+ /** 开启 highlightParent 时:当前项是选中叶子的祖先(不含自身精确选中) */
58
+ function isParentActive(item: RsMenuItem) {
59
+ if (!props.highlightParent || !props.activeKey || !item.children?.length) {
60
+ return false
61
+ }
62
+ if (item.key === props.activeKey) {
63
+ return false
64
+ }
65
+ return menuItemContainsKey(item, props.activeKey)
66
+ }
67
+
68
+ /** 叶子精确选中,或开启 highlightParent 时祖先也视为高亮 */
69
+ function isActiveItem(item: RsMenuItem) {
70
+ return isExactActive(item.key) || isParentActive(item)
71
+ }
72
+
46
73
  function selectLeaf(item: RsMenuItem) {
47
74
  if (item.disabled) return
48
75
  emit('select', item.key)
@@ -52,6 +79,57 @@ function toggleOpen(key: string, open: boolean) {
52
79
  if (!canExpandSubmenu.value) return
53
80
  emit('toggle-open', key, open)
54
81
  }
82
+
83
+ function clearFlyoutCloseTimer() {
84
+ if (flyoutCloseTimer !== undefined) {
85
+ clearTimeout(flyoutCloseTimer)
86
+ flyoutCloseTimer = undefined
87
+ }
88
+ }
89
+
90
+ function openFlyout(key: string) {
91
+ clearFlyoutCloseTimer()
92
+ flyoutKey.value = key
93
+ }
94
+
95
+ function scheduleCloseFlyout() {
96
+ clearFlyoutCloseTimer()
97
+ // 短延迟以便指针从触发器移入浮层时不闪断
98
+ flyoutCloseTimer = setTimeout(() => {
99
+ flyoutKey.value = null
100
+ flyoutCloseTimer = undefined
101
+ }, 120)
102
+ }
103
+
104
+ function closeFlyout() {
105
+ clearFlyoutCloseTimer()
106
+ flyoutKey.value = null
107
+ }
108
+
109
+ function onFlyoutTriggerClick(item: RsMenuItem) {
110
+ if (item.disabled) return
111
+ if (flyoutKey.value === item.key) {
112
+ closeFlyout()
113
+ return
114
+ }
115
+ openFlyout(item.key)
116
+ }
117
+
118
+ function onFlyoutOpenChange(open: boolean) {
119
+ // 仅同步关闭;打开由 mouseenter / click 驱动,避免关闭后焦点回写再次打开
120
+ if (!open) {
121
+ closeFlyout()
122
+ }
123
+ }
124
+
125
+ function onFlyoutSelect(key: string) {
126
+ closeFlyout()
127
+ emit('select', key)
128
+ }
129
+
130
+ onBeforeUnmount(() => {
131
+ clearFlyoutCloseTimer()
132
+ })
55
133
  </script>
56
134
 
57
135
  <template>
@@ -61,7 +139,7 @@ function toggleOpen(key: string, open: boolean) {
61
139
  class="rs-menu__entry"
62
140
  :class="{
63
141
  'rs-menu__entry--submenu': !!item.children?.length,
64
- 'rs-menu__entry--active': isActive(item.key),
142
+ 'rs-menu__entry--active': isActiveItem(item),
65
143
  'rs-menu__entry--disabled': item.disabled,
66
144
  }"
67
145
  :style="{
@@ -76,12 +154,17 @@ function toggleOpen(key: string, open: boolean) {
76
154
  >
77
155
  <CollapsibleTrigger
78
156
  class="rs-menu__item rs-menu__item--submenu-trigger"
79
- :class="{ 'rs-menu__item--active': isActive(item.key) }"
157
+ :class="{
158
+ 'rs-menu__item--active': isExactActive(item.key),
159
+ 'rs-menu__item--active-parent': isParentActive(item),
160
+ }"
80
161
  :disabled="item.disabled"
81
162
  :aria-label="collapsed ? item.label : undefined"
82
163
  :title="collapsed ? item.label : undefined"
83
164
  >
84
- <RsIcon v-if="item.icon" :name="item.icon" :size="16" class="rs-menu__icon" />
165
+ <span v-if="!collapsed || item.icon" class="rs-menu__icon-slot" aria-hidden="true">
166
+ <RsIcon v-if="item.icon" :name="item.icon" :size="16" class="rs-menu__icon" />
167
+ </span>
85
168
  <span v-if="!collapsed" class="rs-menu__label">{{ item.label }}</span>
86
169
  <RsIcon
87
170
  v-if="!collapsed"
@@ -99,24 +182,79 @@ function toggleOpen(key: string, open: boolean) {
99
182
  :collapsed="collapsed"
100
183
  :mode="mode"
101
184
  :depth="depth + 1"
185
+ :highlight-parent="highlightParent"
102
186
  @select="emit('select', $event)"
103
187
  @toggle-open="(key, open) => emit('toggle-open', key, open)"
104
188
  />
105
189
  </CollapsibleContent>
106
190
  </CollapsibleRoot>
107
191
 
192
+ <PopoverRoot
193
+ v-else-if="item.children?.length && isCollapsedVertical"
194
+ :open="flyoutKey === item.key"
195
+ @update:open="onFlyoutOpenChange"
196
+ >
197
+ <PopoverTrigger as-child>
198
+ <button
199
+ type="button"
200
+ class="rs-menu__item rs-menu__item--submenu-trigger"
201
+ :class="{
202
+ 'rs-menu__item--active': isExactActive(item.key),
203
+ 'rs-menu__item--active-parent': isParentActive(item),
204
+ }"
205
+ :disabled="item.disabled"
206
+ :aria-label="item.label"
207
+ :aria-expanded="flyoutKey === item.key"
208
+ :aria-haspopup="true"
209
+ @mouseenter="!item.disabled && openFlyout(item.key)"
210
+ @mouseleave="scheduleCloseFlyout"
211
+ @click="onFlyoutTriggerClick(item)"
212
+ >
213
+ <RsIcon v-if="item.icon" :name="item.icon" :size="16" class="rs-menu__icon" />
214
+ </button>
215
+ </PopoverTrigger>
216
+ <PopoverPortal v-if="flyoutKey === item.key">
217
+ <PopoverContent
218
+ class="rs-menu__flyout"
219
+ side="right"
220
+ align="start"
221
+ :side-offset="8"
222
+ :collision-padding="8"
223
+ @mouseenter="openFlyout(item.key)"
224
+ @mouseleave="scheduleCloseFlyout"
225
+ @escape-key-down="closeFlyout"
226
+ @pointer-down-outside="closeFlyout"
227
+ >
228
+ <div class="rs-menu__flyout-title">{{ item.label }}</div>
229
+ <RsMenuItems
230
+ :items="item.children"
231
+ :active-key="activeKey"
232
+ :open-keys="openKeys"
233
+ :collapsed="false"
234
+ mode="vertical"
235
+ :depth="0"
236
+ :highlight-parent="highlightParent"
237
+ @select="onFlyoutSelect"
238
+ @toggle-open="(key, open) => emit('toggle-open', key, open)"
239
+ />
240
+ </PopoverContent>
241
+ </PopoverPortal>
242
+ </PopoverRoot>
243
+
108
244
  <button
109
245
  v-else
110
246
  type="button"
111
247
  class="rs-menu__item"
112
- :class="{ 'rs-menu__item--active': isActive(item.key) }"
248
+ :class="{ 'rs-menu__item--active': isExactActive(item.key) }"
113
249
  :disabled="item.disabled"
114
- :aria-current="isActive(item.key) ? 'page' : undefined"
250
+ :aria-current="isExactActive(item.key) ? 'page' : undefined"
115
251
  :aria-label="collapsed ? item.label : undefined"
116
252
  :title="collapsed ? item.label : undefined"
117
253
  @click="selectLeaf(item)"
118
254
  >
119
- <RsIcon v-if="item.icon" :name="item.icon" :size="16" class="rs-menu__icon" />
255
+ <span v-if="!collapsed || item.icon" class="rs-menu__icon-slot" aria-hidden="true">
256
+ <RsIcon v-if="item.icon" :name="item.icon" :size="16" class="rs-menu__icon" />
257
+ </span>
120
258
  <span v-if="!collapsed" class="rs-menu__label">{{ item.label }}</span>
121
259
  </button>
122
260
  </div>