niuma-ui 1.1.7 → 1.1.9

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 CHANGED
@@ -6,6 +6,32 @@
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.1.9] - 2026-08-28
10
+
11
+ ### 修复
12
+
13
+ - `RsTable`:子表 / 表头默认 `T = any`,避免 inject 子组件把插槽 `row` 收成 `object`。宿主 `#col="{ row }"` 即可用行字段,不必断言。公开类型仍导出 `RsTableSlots` / `RsTableColumnSlotProps`。
14
+ - `RsSelect`:增加泛型 `Value` / `Multiple` / `LabelInValue`,默认单选 `string`。`@update:model-value` 不再是 `string | number | 数组 | labeled` 大联合。数字 value 写 `RsSelect<number>`;`labelInValue` 仍可用。
15
+ - `RsSplitPane`:导出 `RsSplitPaneExpose` / `RsSplitPaneInstance`,模板 ref 用该类型,避免 `InstanceType<typeof RsSplitPane>` 把 vue-tsc 打爆。
16
+ - `RsMonacoEditor`:导出 `RsMonacoEditorExpose`,模板 ref 用该类型,避免 `InstanceType<typeof RsMonacoEditor>` 把 vue-tsc 打爆。
17
+ - `RsInput`:导出 `RsInputExpose` / `RsInputInstance`,模板 ref 用该类型,避免 `InstanceType<typeof RsInput>` 把 vue-tsc 打爆。
18
+
19
+ 运行时兼容 1.1.8;以上为 TypeScript 合约收口。
20
+
21
+ ## [1.1.8] - 2026-08-28
22
+
23
+ ### 新增
24
+
25
+ - `RsCodeBlock`:`editable`(默认 `false`,只读行为不变);可写时 `update:code` 同步正文;expose `getSelection` 返回选区文本与起止行号。可写态显示光标。`showBar`(默认 `true`)为 `false` 时隐藏语言条与复制/下载。
26
+ - `RsTree`:`node-contextmenu`(`node, key, event`);行节点带 `data-tree-key`,便于宿主挂右键菜单。
27
+ - `RsPopover.popupClassName`:附加到弹出层 class,对齐 `RsSelect`。
28
+ - `renderMarkdownInline`:行内 Markdown(不包 `<p>`),并从包入口导出。
29
+
30
+ ### 修复
31
+
32
+ - `RsButton`:内置 `tooltip` 打开时 Teleport 到 `document.body`(`position: fixed`),避免侧栏 / Dialog 等 overflow 父级裁切。关闭即卸载;延迟 300ms、Escape 关闭;有可见文案时用 `aria-describedby`,仅图标走 `aria-label` 不重复朗读。
33
+ - `RsMarkdown`:GFM 表格对齐 `align`;裸 URL 与行内代码中的 `http(s)` 可点击;任务列表改为 span 标记并禁止消毒后的 `<input>`。
34
+
9
35
  ## [1.1.7] - 2026-08-21
10
36
 
11
37
  ### 新增
@@ -169,7 +195,9 @@
169
195
 
170
196
  - 1.0 之前的私有 tag(如 `v0.1.0`)仅作历史记录;新接入请依赖 `v1.0.0` 及之后版本。
171
197
 
172
- [Unreleased]: https://github.com/Blair-Shang/niuma-ui/compare/v1.1.7...HEAD
198
+ [Unreleased]: https://github.com/Blair-Shang/niuma-ui/compare/v1.1.9...HEAD
199
+ [1.1.9]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.1.9
200
+ [1.1.8]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.1.8
173
201
  [1.1.7]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.1.7
174
202
  [1.1.6]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.1.6
175
203
  [1.1.5]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.1.5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niuma-ui",
3
- "version": "1.1.7",
3
+ "version": "1.1.9",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "description": "Vue 3 设计系统与 Rs* 组件库(--rs-* token)",
@@ -1,7 +1,12 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { describe, expect, it, vi } from 'vitest'
2
2
  import { mount } from '@vue/test-utils'
3
3
  import RsButton from '../components/RsButton.vue'
4
4
 
5
+ async function openTooltip(wrapper: ReturnType<typeof mount>) {
6
+ await wrapper.trigger('mouseenter')
7
+ await vi.advanceTimersByTimeAsync(300)
8
+ }
9
+
5
10
  describe('RsButton', () => {
6
11
  it('renders slot', () => {
7
12
  const wrapper = mount(RsButton, {
@@ -92,17 +97,23 @@ describe('RsButton', () => {
92
97
  props: { icon: 'search', iconOnly: true, tooltip: '搜索' },
93
98
  })
94
99
  expect(wrapper.classes()).toContain('rs-btn--icon-only')
95
- expect(wrapper.find('.rs-btn__tooltip').text()).toBe('搜索')
96
100
  expect(wrapper.attributes('aria-label')).toBe('搜索')
101
+ expect(document.body.querySelector('.rs-btn__tooltip')).toBeNull()
102
+ wrapper.unmount()
97
103
  })
98
104
 
99
- it('iconOnly uses slot text in floating tooltip when tooltip omitted', () => {
105
+ it('iconOnly uses slot text in floating tooltip when tooltip omitted', async () => {
106
+ vi.useFakeTimers()
100
107
  const wrapper = mount(RsButton, {
101
108
  props: { icon: 'message-square', iconOnly: true },
102
109
  slots: { default: '消息' },
110
+ attachTo: document.body,
103
111
  })
104
112
  expect(wrapper.find('.rs-btn__label').exists()).toBe(false)
105
- expect(wrapper.find('.rs-btn__tooltip').text()).toBe('消息')
113
+ await openTooltip(wrapper)
114
+ expect(document.body.querySelector('.rs-btn__tooltip')?.textContent).toBe('消息')
115
+ wrapper.unmount()
116
+ vi.useRealTimers()
106
117
  })
107
118
 
108
119
  it('reveal-label mode keeps label in dom with reveal class', () => {
@@ -115,12 +126,34 @@ describe('RsButton', () => {
115
126
  expect(wrapper.text()).toContain('新建对话')
116
127
  })
117
128
 
118
- it('shows tooltip when tooltip prop is set with label', () => {
129
+ it('shows tooltip when tooltip prop is set with label', async () => {
130
+ vi.useFakeTimers()
119
131
  const wrapper = mount(RsButton, {
120
132
  props: { icon: 'plus', tooltip: 'Ctrl+N' },
121
133
  slots: { default: '新建' },
134
+ attachTo: document.body,
135
+ })
136
+ await openTooltip(wrapper)
137
+ const tip = document.body.querySelector('.rs-btn__tooltip')
138
+ expect(tip?.textContent).toBe('Ctrl+N')
139
+ expect(wrapper.attributes('aria-describedby')).toBe(tip?.id)
140
+ wrapper.unmount()
141
+ vi.useRealTimers()
142
+ })
143
+
144
+ it('portals tooltip to document.body so overflow parents cannot clip it', async () => {
145
+ vi.useFakeTimers()
146
+ const wrapper = mount(RsButton, {
147
+ props: { icon: 'plus', iconOnly: true, tooltip: '新建' },
148
+ attachTo: document.body,
122
149
  })
123
- expect(wrapper.find('.rs-btn__tooltip').text()).toBe('Ctrl+N')
150
+ await openTooltip(wrapper)
151
+ const tip = document.body.querySelector('.rs-btn__tooltip')
152
+ expect(tip).not.toBeNull()
153
+ expect(wrapper.element.contains(tip)).toBe(false)
154
+ expect(tip?.parentElement).toBe(document.body)
155
+ wrapper.unmount()
156
+ vi.useRealTimers()
124
157
  })
125
158
 
126
159
  it('loading: applies class, keeps enabled, shows inline spinner and label', async () => {
@@ -375,7 +375,7 @@ describe('RsDialog', () => {
375
375
  const wrapper = mount(Host, { attachTo: document.body })
376
376
  await flushPromises()
377
377
  const closeBtn = document.body.querySelector('.rs-dialog__actions button') as HTMLElement
378
- expect(closeBtn.querySelector('.rs-btn__tooltip')?.textContent).toContain('Close')
378
+ expect(closeBtn?.getAttribute('aria-label')).toContain('Close')
379
379
  wrapper.unmount()
380
380
  })
381
381
 
@@ -232,7 +232,13 @@ describe('RsDrawer', () => {
232
232
  const wrapper = mount(Host, { attachTo: document.body })
233
233
  await flushPromises()
234
234
  const closeBtn = document.body.querySelector('.rs-drawer__header button') as HTMLElement
235
- expect(closeBtn.querySelector('.rs-btn__tooltip')?.textContent).toContain('Close')
235
+ expect(closeBtn).not.toBeNull()
236
+ vi.useFakeTimers()
237
+ closeBtn.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }))
238
+ await vi.advanceTimersByTimeAsync(300)
239
+ await flushPromises()
240
+ expect(document.body.querySelector('.rs-btn__tooltip')?.textContent).toContain('Close')
241
+ vi.useRealTimers()
236
242
  wrapper.unmount()
237
243
  })
238
244
 
@@ -41,6 +41,12 @@ describe('markdown-utils', () => {
41
41
  expect(html).not.toContain('javascript:')
42
42
  })
43
43
 
44
+ it('keeps GFM table alignment attributes', () => {
45
+ const html = renderMarkdown('| a | b |\n| ---: | :---: |\n| 1 | 2 |')
46
+ expect(html).toContain('align="right"')
47
+ expect(html).toContain('align="center"')
48
+ })
49
+
44
50
  it('opens http links in a new tab with noopener', () => {
45
51
  const html = renderMarkdown('[docs](https://example.com)')
46
52
  expect(html).toContain('href="https://example.com"')
@@ -48,6 +54,37 @@ describe('markdown-utils', () => {
48
54
  expect(html).toContain('rel="noopener noreferrer"')
49
55
  })
50
56
 
57
+ it('autolinks bare http addresses', () => {
58
+ const html = renderMarkdown('然后打开 http://127.0.0.1:3000')
59
+ expect(html).toContain('href="http://127.0.0.1:3000"')
60
+ expect(html).toContain('target="_blank"')
61
+ })
62
+
63
+ it('turns inline-code http addresses into links', () => {
64
+ const html = renderMarkdown('然后打开 `http://127.0.0.1:3000`')
65
+ expect(html).toContain('<code>http://127.0.0.1:3000</code>')
66
+ expect(html).toContain('href="http://127.0.0.1:3000"')
67
+ expect(html).toContain('target="_blank"')
68
+ })
69
+
70
+ it('does not linkify inline code that is not a URL', () => {
71
+ const html = renderMarkdown('run `npm start`')
72
+ expect(html).toContain('<code>npm start</code>')
73
+ expect(html).not.toContain('href=')
74
+ })
75
+
76
+ it('renders GFM task lists without input tags', () => {
77
+ const html = renderMarkdown('- [ ] todo\n- [x] done')
78
+ expect(html).toContain('rs-markdown__task')
79
+ expect(html).toContain('rs-markdown__task--on')
80
+ expect(html.toLowerCase()).not.toContain('<input')
81
+ })
82
+
83
+ it('strips raw HTML input tags', () => {
84
+ const html = renderMarkdown('Hello <input type="text" name="x">')
85
+ expect(html.toLowerCase()).not.toContain('<input')
86
+ })
87
+
51
88
  it('sanitizes raw HTML injection', () => {
52
89
  const html = renderMarkdown('Hello <img src=x onerror=alert(1)>')
53
90
  expect(html.toLowerCase()).not.toContain('onerror')
@@ -50,6 +50,20 @@ describe('RsPopover', () => {
50
50
  wrapper.unmount()
51
51
  })
52
52
 
53
+ it('applies popupClassName on content', async () => {
54
+ const wrapper = mount(RsPopover, {
55
+ props: { open: true, popupClassName: 'custom-pop' },
56
+ slots: {
57
+ default: '<button type="button" class="trigger">Open</button>',
58
+ content: '<p>Custom chrome</p>',
59
+ },
60
+ attachTo: document.body,
61
+ })
62
+ await flushPromises()
63
+ expect(document.body.querySelector('.rs-popover__content.custom-pop')).not.toBeNull()
64
+ wrapper.unmount()
65
+ })
66
+
53
67
  it('lazy-mounts content when closed by default', async () => {
54
68
  const wrapper = mount(RsPopover, {
55
69
  slots: {
@@ -0,0 +1,28 @@
1
+ import { describe, expectTypeOf, it } from 'vitest'
2
+ import type { RsSelectLabeledValue, RsSelectResolvedModel } from '../components/select-utils'
3
+
4
+ describe('RsSelect resolved model', () => {
5
+ it('defaults to a string (single select)', () => {
6
+ expectTypeOf<RsSelectResolvedModel>().toEqualTypeOf<string>()
7
+ })
8
+
9
+ it('multiple literal true is a string array', () => {
10
+ expectTypeOf<RsSelectResolvedModel<string, true>>().toEqualTypeOf<string[]>()
11
+ })
12
+
13
+ it('number value stays number | empty', () => {
14
+ expectTypeOf<RsSelectResolvedModel<number>>().toEqualTypeOf<number | ''>()
15
+ })
16
+
17
+ it('labelInValue literal true is labeled or empty', () => {
18
+ expectTypeOf<RsSelectResolvedModel<string, false, true>>().toEqualTypeOf<
19
+ RsSelectLabeledValue | ''
20
+ >()
21
+ })
22
+
23
+ it('boolean multiple keeps the union so runtime flags still type-check', () => {
24
+ expectTypeOf<RsSelectResolvedModel<string, boolean>>().toEqualTypeOf<
25
+ string | string[]
26
+ >()
27
+ })
28
+ })
@@ -0,0 +1,20 @@
1
+ import { describe, expectTypeOf, it } from 'vitest'
2
+ import type {
3
+ RsTableColumnSlotProps,
4
+ RsTableSlots,
5
+ } from '../components/table/rs-table-props'
6
+
7
+ describe('RsTable slot types', () => {
8
+ it('column slot row is the table generic', () => {
9
+ type Row = { id: string; name: string }
10
+ expectTypeOf<RsTableColumnSlotProps<Row>['row']>().toEqualTypeOf<Row>()
11
+ expectTypeOf<RsTableColumnSlotProps<Row>['index']>().toEqualTypeOf<number>()
12
+ })
13
+
14
+ it('named column slots stay callable with the row generic', () => {
15
+ type Row = { id: string; name: string }
16
+ type NameSlot = NonNullable<RsTableSlots<Row>['name']>
17
+ expectTypeOf<NameSlot>().toBeFunction()
18
+ expectTypeOf<NameSlot>().parameter(0).toMatchTypeOf<{ row: Row; index: number }>()
19
+ })
20
+ })
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, nextTick, ref, useSlots, watch } from 'vue'
2
+ import { computed, nextTick, onUnmounted, ref, useId, useSlots, watch } from 'vue'
3
3
  import type { RsComponentSize, RsRadius } from '../theme/types'
4
4
  import { RS_COMPONENT_SIZE_ICON_PX } from '../theme/types'
5
5
  import {
@@ -35,7 +35,10 @@ const props = withDefaults(
35
35
  iconSize?: number
36
36
  /** 仅显示图标,文字通过 tooltip / slot 悬浮展示 */
37
37
  iconOnly?: boolean
38
- /** 悬浮提示文案 */
38
+ /**
39
+ * 悬浮提示。打开时 Teleport 到 `document.body`(对齐 WAI-ARIA APG Tooltip / Reka Portal)。
40
+ * 仅图标按钮时作可视提示;无障碍名称仍走 `aria-label`,避免与 tip 重复朗读。
41
+ */
39
42
  tooltip?: string
40
43
  /** icon-only 时无障碍标签(与 tooltip 二选一,避免与外部 RsTooltip 重复) */
41
44
  ariaLabel?: string
@@ -106,24 +109,120 @@ const ariaLabel = computed(() => {
106
109
  return props.ariaLabel || props.tooltip || undefined
107
110
  })
108
111
 
109
- /** tooltip 水平对齐方式:center / left / right */
110
- const tooltipAlign = ref<'center' | 'left' | 'right'>('center')
112
+ /** RsTooltipProvider 默认一致:避免路过按钮时闪一下 */
113
+ const TOOLTIP_DELAY_MS = 300
114
+
115
+ const tipId = useId()
116
+ const tipOpen = ref(false)
117
+ const tipStyle = ref<Record<string, string>>({})
118
+
119
+ let openTimer: ReturnType<typeof setTimeout> | null = null
120
+ let posRaf = 0
121
+
122
+ /** 有可见文案时 tip 是补充说明;仅图标时名称已在 aria-label,不再 describedby(避免读两遍) */
123
+ const tipDescribedBy = computed(() =>
124
+ tipOpen.value && showFloatingText.value && !props.iconOnly ? tipId : undefined,
125
+ )
126
+
127
+ function clearOpenTimer(): void {
128
+ if (openTimer == null) return
129
+ clearTimeout(openTimer)
130
+ openTimer = null
131
+ }
111
132
 
112
- function updateTooltipAlign(): void {
133
+ function updateTipPosition(): void {
113
134
  const el = btnRef.value
114
135
  if (!el) return
115
136
  const rect = el.getBoundingClientRect()
116
137
  const mid = rect.left + rect.width / 2
117
138
  const vw = window.innerWidth
139
+ const gap = 6
140
+ let left = mid
141
+ let transform = 'translateX(-50%)'
118
142
  if (mid > vw * 0.72) {
119
- tooltipAlign.value = 'right'
143
+ left = rect.right
144
+ transform = 'translateX(-100%)'
120
145
  } else if (mid < vw * 0.28) {
121
- tooltipAlign.value = 'left'
146
+ left = rect.left
147
+ transform = 'none'
148
+ }
149
+ tipStyle.value = {
150
+ top: `${Math.round(rect.bottom + gap)}px`,
151
+ left: `${Math.round(left)}px`,
152
+ transform,
153
+ }
154
+ }
155
+
156
+ function scheduleTipPosition(): void {
157
+ if (typeof window === 'undefined' || posRaf) return
158
+ posRaf = window.requestAnimationFrame(() => {
159
+ posRaf = 0
160
+ if (tipOpen.value) updateTipPosition()
161
+ })
162
+ }
163
+
164
+ function onDocKeydown(e: KeyboardEvent): void {
165
+ if (e.key !== 'Escape' || !tipOpen.value) return
166
+ e.preventDefault()
167
+ e.stopPropagation()
168
+ closeTip()
169
+ }
170
+
171
+ function bindTipFollow(on: boolean): void {
172
+ if (typeof window === 'undefined') return
173
+ if (on) {
174
+ window.addEventListener('scroll', scheduleTipPosition, true)
175
+ window.addEventListener('resize', scheduleTipPosition)
176
+ window.addEventListener('keydown', onDocKeydown, true)
122
177
  } else {
123
- tooltipAlign.value = 'center'
178
+ window.removeEventListener('scroll', scheduleTipPosition, true)
179
+ window.removeEventListener('resize', scheduleTipPosition)
180
+ window.removeEventListener('keydown', onDocKeydown, true)
181
+ if (posRaf) {
182
+ window.cancelAnimationFrame(posRaf)
183
+ posRaf = 0
184
+ }
185
+ }
186
+ }
187
+
188
+ function openTipNow(): void {
189
+ if (!showFloatingText.value || props.disabled || props.loading) return
190
+ updateTipPosition()
191
+ if (!tipOpen.value) {
192
+ tipOpen.value = true
193
+ bindTipFollow(true)
124
194
  }
125
195
  }
126
196
 
197
+ function scheduleOpenTip(): void {
198
+ if (!showFloatingText.value || props.disabled || props.loading) return
199
+ clearOpenTimer()
200
+ openTimer = setTimeout(() => {
201
+ openTimer = null
202
+ openTipNow()
203
+ }, TOOLTIP_DELAY_MS)
204
+ }
205
+
206
+ function closeTip(): void {
207
+ clearOpenTimer()
208
+ if (!tipOpen.value) return
209
+ tipOpen.value = false
210
+ bindTipFollow(false)
211
+ }
212
+
213
+ function onFocus(e: FocusEvent): void {
214
+ const el = e.currentTarget
215
+ if (!(el instanceof HTMLElement)) return
216
+ if (el.matches(':focus-visible')) scheduleOpenTip()
217
+ }
218
+
219
+ watch(
220
+ () => props.disabled || props.loading,
221
+ (off) => {
222
+ if (off) closeTip()
223
+ },
224
+ )
225
+
127
226
  watch(
128
227
  () => props.loading,
129
228
  async (loading) => {
@@ -140,6 +239,10 @@ watch(
140
239
  }
141
240
  },
142
241
  )
242
+
243
+ onUnmounted(() => {
244
+ closeTip()
245
+ })
143
246
  </script>
144
247
 
145
248
  <template>
@@ -153,7 +256,11 @@ watch(
153
256
  :aria-busy="loading || undefined"
154
257
  :aria-disabled="disabled || loading || undefined"
155
258
  :aria-label="ariaLabel"
156
- @mouseenter="updateTooltipAlign"
259
+ :aria-describedby="tipDescribedBy"
260
+ @mouseenter="scheduleOpenTip"
261
+ @mouseleave="closeTip"
262
+ @focus="onFocus"
263
+ @blur="closeTip"
157
264
  >
158
265
  <span v-if="loading" class="rs-btn__spinner" aria-hidden="true">
159
266
  <span class="rs-btn__spinner-ring" />
@@ -172,15 +279,18 @@ watch(
172
279
  >
173
280
  <slot />
174
281
  </span>
175
- <span
176
- v-if="showFloatingText"
177
- class="rs-btn__tooltip"
178
- :class="`rs-btn__tooltip--${tooltipAlign}`"
179
- role="tooltip"
180
- >
181
- <template v-if="tooltip">{{ tooltip }}</template>
182
- <slot v-else-if="iconOnly" />
183
- </span>
282
+ <Teleport to="body">
283
+ <span
284
+ v-if="tipOpen"
285
+ :id="tipId"
286
+ class="rs-btn__tooltip"
287
+ :style="tipStyle"
288
+ role="tooltip"
289
+ >
290
+ <template v-if="tooltip">{{ tooltip }}</template>
291
+ <slot v-else-if="iconOnly" />
292
+ </span>
293
+ </Teleport>
184
294
  </button>
185
295
  </template>
186
296
 
@@ -464,10 +574,8 @@ watch(
464
574
  opacity: 1;
465
575
  }
466
576
  .rs-btn__tooltip {
467
- position: absolute;
468
- left: 50%;
469
- top: calc(100% + 0.375rem);
470
- transform: translateX(-50%) translateY(-2px);
577
+ position: fixed;
578
+ z-index: calc(var(--rs-z-modal) + 2);
471
579
  padding: 0.25rem 0.5rem;
472
580
  border-radius: var(--rs-radius-sm);
473
581
  border: 1px solid var(--rs-border);
@@ -478,36 +586,7 @@ watch(
478
586
  line-height: 1.25rem;
479
587
  white-space: nowrap;
480
588
  box-shadow: var(--rs-shadow-sm);
481
- opacity: 0;
482
589
  pointer-events: none;
483
- transition:
484
- opacity 0.15s ease,
485
- transform 0.15s ease;
486
- z-index: var(--rs-z-tooltip);
487
- }
488
- /* 靠左边的按钮:tooltip 左对齐 */
489
- .rs-btn__tooltip--left {
490
- left: 0;
491
- transform: translateY(-2px);
492
- }
493
- /* 靠右边的按钮:tooltip 右对齐 */
494
- .rs-btn__tooltip--right {
495
- left: auto;
496
- right: 0;
497
- transform: translateY(-2px);
498
- }
499
- .rs-btn:hover:not(:disabled) .rs-btn__tooltip,
500
- .rs-btn:focus-visible .rs-btn__tooltip {
501
- opacity: 1;
502
- transform: translateX(-50%) translateY(0);
503
- }
504
- .rs-btn:hover:not(:disabled) .rs-btn__tooltip--left,
505
- .rs-btn:focus-visible .rs-btn__tooltip--left {
506
- transform: translateY(0);
507
- }
508
- .rs-btn:hover:not(:disabled) .rs-btn__tooltip--right,
509
- .rs-btn:focus-visible .rs-btn__tooltip--right {
510
- transform: translateY(0);
511
590
  }
512
591
  @keyframes rs-spin {
513
592
  to {
@@ -19,10 +19,18 @@ const props = withDefaults(
19
19
  downloadFilename?: string
20
20
  /** 覆盖内置"下载"文案 */
21
21
  downloadLabel?: string
22
+ /** 为 true 时允许改正文,并向外同步 */
23
+ editable?: boolean
24
+ /** 为 false 时隐藏语言条与复制/下载(嵌入编辑器用) */
25
+ showBar?: boolean
22
26
  }>(),
23
- { lang: 'text' },
27
+ { lang: 'text', editable: false, showBar: true },
24
28
  )
25
29
 
30
+ const emit = defineEmits<{
31
+ 'update:code': [value: string]
32
+ }>()
33
+
26
34
  const { t } = useRsI18n()
27
35
 
28
36
  const editorEl = ref<HTMLElement | null>(null)
@@ -62,10 +70,15 @@ async function initEditor() {
62
70
  doc: props.code,
63
71
  extensions: [
64
72
  basicSetup,
65
- EditorView.editable.of(false),
73
+ EditorView.editable.of(props.editable),
66
74
  EditorView.lineWrapping,
67
75
  ...langExts,
68
76
  ...(isLight() ? [] : [oneDark]),
77
+ EditorView.updateListener.of((u) => {
78
+ if (props.editable && u.docChanged) {
79
+ emit('update:code', u.state.doc.toString())
80
+ }
81
+ }),
69
82
  ],
70
83
  })
71
84
 
@@ -98,8 +111,29 @@ onUnmounted(() => {
98
111
  }
99
112
  })
100
113
 
101
- // lang 变化时重新初始化(切换语言高亮)
102
- watch(() => props.lang, () => { void initEditor() })
114
+ watch(() => [props.lang, props.editable] as const, () => { void initEditor() })
115
+
116
+ function getSelection(): { text: string; startLine: number; endLine: number } | null {
117
+ const ed = view.value
118
+ if (!ed) {
119
+ return null
120
+ }
121
+ const range = ed.state.selection.main
122
+ if (range.empty) {
123
+ return null
124
+ }
125
+ const text = ed.state.sliceDoc(range.from, range.to)
126
+ if (!text.trim()) {
127
+ return null
128
+ }
129
+ return {
130
+ text,
131
+ startLine: ed.state.doc.lineAt(range.from).number,
132
+ endLine: ed.state.doc.lineAt(range.to).number,
133
+ }
134
+ }
135
+
136
+ defineExpose({ getSelection })
103
137
 
104
138
  // 代码内容更新时同步文档(不需要重建整个编辑器)
105
139
  watch(
@@ -151,8 +185,14 @@ function download() {
151
185
  </script>
152
186
 
153
187
  <template>
154
- <figure class="rs-code-block">
155
- <figcaption class="rs-code-block__bar">
188
+ <figure
189
+ class="rs-code-block"
190
+ :class="{
191
+ 'rs-code-block--editable': editable,
192
+ 'rs-code-block--plain': !showBar,
193
+ }"
194
+ >
195
+ <figcaption v-if="showBar" class="rs-code-block__bar">
156
196
  <span class="rs-code-block__lang">{{ lang }}</span>
157
197
  <div class="rs-code-block__actions">
158
198
  <button
@@ -182,6 +222,15 @@ function download() {
182
222
  font-size: var(--rs-font-size-sm);
183
223
  }
184
224
 
225
+ .rs-code-block--plain {
226
+ border: 0;
227
+ border-radius: 0;
228
+ }
229
+
230
+ .rs-code-block--plain .rs-code-block__editor :deep(.cm-editor) {
231
+ max-height: none;
232
+ }
233
+
185
234
  .rs-code-block__bar {
186
235
  display: flex;
187
236
  align-items: center;
@@ -257,7 +306,7 @@ function download() {
257
306
  }
258
307
 
259
308
  /* 只读时隐藏光标,保持纯展示外观 */
260
- .rs-code-block__editor :deep(.cm-cursor) {
309
+ .rs-code-block:not(.rs-code-block--editable) :deep(.cm-cursor) {
261
310
  display: none;
262
311
  }
263
312
 
@@ -31,7 +31,19 @@ import RsVNodeHost from './RsVNodeHost.vue'
31
31
 
32
32
  import RsIcon from './RsIcon.vue'
33
33
 
34
+ /**
35
+ * RsInput 模板 ref 请用此类型。
36
+ * 不要写 `InstanceType<typeof RsInput>`:组件实例类型过深,vue-tsc 会报 Excessive stack depth。
37
+ */
38
+ export interface RsInputExpose {
39
+ validate: (trigger?: RsFormRuleTrigger) => Promise<boolean>
40
+ clearValidation: () => void
41
+ setValue: (value: unknown) => void
42
+ setError: (message: string) => void
43
+ }
34
44
 
45
+ /** 模板 ref 实例:expose + 根节点 */
46
+ export type RsInputInstance = RsInputExpose & { $el: HTMLElement }
35
47
 
36
48
  const { t } = useRsI18n()
37
49
 
@@ -537,7 +549,7 @@ useRsFormField(() => ({
537
549
  setError,
538
550
  }))
539
551
 
540
- defineExpose({
552
+ defineExpose<RsInputExpose>({
541
553
  validate: runValidate,
542
554
  clearValidation,
543
555
  setValue,
@@ -247,6 +247,10 @@ function setMode(next: RsMarkdownMode): void {
247
247
  color: var(--rs-primary);
248
248
  text-decoration: underline;
249
249
  text-underline-offset: 0.15em;
250
+ cursor: pointer;
251
+ }
252
+ .rs-markdown__prose a code {
253
+ color: inherit;
250
254
  }
251
255
  .rs-markdown__prose a:hover {
252
256
  opacity: 0.85;
@@ -322,8 +326,23 @@ function setMode(next: RsMarkdownMode): void {
322
326
  height: auto;
323
327
  border-radius: var(--rs-radius);
324
328
  }
325
- .rs-markdown__prose input[type='checkbox'] {
326
- margin-right: 0.35em;
329
+ .rs-markdown__task {
330
+ display: inline-block;
331
+ width: 0.9em;
332
+ height: 0.9em;
333
+ margin: 0 0.4em 0.05em 0;
334
+ border: 1px solid var(--rs-border);
335
+ border-radius: 0.15em;
327
336
  vertical-align: middle;
337
+ background: var(--rs-surface);
338
+ }
339
+ .rs-markdown__task--on {
340
+ background: var(--rs-primary);
341
+ border-color: var(--rs-primary);
342
+ box-shadow: inset 0 0 0 0.12em var(--rs-surface);
343
+ }
344
+ .rs-markdown__prose li:has(> .rs-markdown__task) {
345
+ list-style: none;
346
+ margin-left: -1.15em;
328
347
  }
329
348
  </style>
@@ -31,6 +31,16 @@ export interface MonacoCompletionSnippet {
31
31
  preselect?: boolean
32
32
  }
33
33
 
34
+ /**
35
+ * RsMonacoEditor 模板 ref 请用此类型。
36
+ * 不要写 `InstanceType<typeof RsMonacoEditor>`:组件实例类型过深,vue-tsc 会报 Excessive stack depth。
37
+ */
38
+ export interface RsMonacoEditorExpose {
39
+ format: () => void
40
+ getEditor: () => import('monaco-editor').editor.IStandaloneCodeEditor | null
41
+ revealLine: (line: number) => void
42
+ }
43
+
34
44
  /** MonacoCompletionContext 描述一次补全请求的编辑器上下文。 */
35
45
  export interface MonacoCompletionContext {
36
46
  text: string
@@ -498,7 +508,7 @@ watch(() => props.completionTriggerCharacters, () => applySnippets(), { deep: tr
498
508
  watch(() => props.completionPrefixResolver, () => applySnippets())
499
509
 
500
510
  // ── Expose ────────────────────────────────────────────────────────────
501
- defineExpose({
511
+ defineExpose<RsMonacoEditorExpose>({
502
512
  /** 格式化文档(等价于 Shift+Alt+F) */
503
513
  format(): void {
504
514
  editor?.getAction('editor.action.formatDocument')?.run().catch(() => undefined)
@@ -14,6 +14,8 @@ const props = withDefaults(
14
14
  width?: RsPopoverWidth
15
15
  lazyMount?: boolean
16
16
  forceMount?: boolean
17
+ /** 附加到弹出层,对齐 RsSelect popupClassName */
18
+ popupClassName?: string
17
19
  }>(),
18
20
  {
19
21
  side: 'bottom',
@@ -38,7 +40,7 @@ const portalMounted = computed(() => props.forceMount || !props.lazyMount || ope
38
40
  <PopoverPortal v-if="portalMounted">
39
41
  <PopoverContent
40
42
  class="rs-popover__content"
41
- :class="`rs-popover__content--${width}`"
43
+ :class="[`rs-popover__content--${width}`, popupClassName]"
42
44
  :side="side"
43
45
  :align="align"
44
46
  :side-offset="sideOffset"
@@ -1,5 +1,5 @@
1
- <script setup lang="ts">
2
- import { computed, ref, useAttrs } from 'vue'
1
+ <script setup lang="ts" generic="Value extends string | number = string, Multiple extends boolean = false, LabelInValue extends boolean = false">
2
+ import { computed, ref, useAttrs, type ModelRef } from 'vue'
3
3
 
4
4
  import { useRsI18n } from '../composables/useRsI18n'
5
5
  import type { RsComponentSize, RsRadius } from '../theme/types'
@@ -31,6 +31,7 @@ import {
31
31
  type RsSelectOptionFilterProp,
32
32
  type RsSelectOptionsInput,
33
33
  type RsSelectPlacement,
34
+ type RsSelectResolvedModel,
34
35
  type RsSelectStatus,
35
36
  type RsSelectValue,
36
37
  } from './select-utils'
@@ -53,7 +54,9 @@ import {
53
54
 
54
55
  defineOptions({ inheritAttrs: false })
55
56
 
56
- const model = defineModel<RsSelectModelValue>({ default: '' })
57
+ const model = defineModel<RsSelectResolvedModel<Value, Multiple, LabelInValue>>({
58
+ default: '' as never,
59
+ })
57
60
  const open = defineModel<boolean>('open', { default: false })
58
61
  const searchQuery = defineModel<string>('searchValue', { default: '' })
59
62
 
@@ -69,7 +72,7 @@ const props = withDefaults(
69
72
  * 开启后若未显式关 searchable,将自动启用搜索框。
70
73
  */
71
74
  creatable?: boolean
72
- multiple?: boolean
75
+ multiple?: Multiple
73
76
  required?: boolean
74
77
  name?: string
75
78
  clearable?: boolean
@@ -98,7 +101,7 @@ const props = withDefaults(
98
101
  maxTagCount?: number
99
102
  maxTagPlaceholder?: string | ((omitted: number) => string)
100
103
  multipleLimit?: number
101
- labelInValue?: boolean
104
+ labelInValue?: LabelInValue
102
105
  filterSort?: RsSelectFilterSort
103
106
  maxTagTextLength?: number
104
107
  maxTagTooltip?: boolean
@@ -125,7 +128,6 @@ const props = withDefaults(
125
128
  disabled: false,
126
129
  searchable: false,
127
130
  creatable: false,
128
- multiple: false,
129
131
  required: false,
130
132
  clearable: false,
131
133
  virtual: false,
@@ -137,7 +139,6 @@ const props = withDefaults(
137
139
  filterOption: true,
138
140
  optionFilterProp: 'label',
139
141
  optionLabelProp: 'label',
140
- labelInValue: false,
141
142
  maxTagTooltip: true,
142
143
  autoClearSearchValue: true,
143
144
  fillSearchWithValue: false,
@@ -198,7 +199,7 @@ const {
198
199
  removeTag,
199
200
  setValue,
200
201
  resetSearch,
201
- } = useRsSelect(props, model, open, searchQuery, emit, t)
202
+ } = useRsSelect(props, model as ModelRef<RsSelectModelValue>, open, searchQuery, emit, t)
202
203
 
203
204
  const resolvedDisabled = computed(() => props.disabled || formContext?.disabled.value || false)
204
205
  const resolvedSize = useResolvedRsComponentSize(() => props.size)
@@ -13,6 +13,7 @@ import {
13
13
  roundSize,
14
14
  splitSizesEqual,
15
15
  type RsSplitOrientation,
16
+ type RsSplitPaneExpose,
16
17
  type RsSplitPaneItem,
17
18
  } from './split-pane-utils'
18
19
 
@@ -440,7 +441,12 @@ function reset(): void {
440
441
  emit('resize-end', sizes.value.slice())
441
442
  }
442
443
 
443
- defineExpose({ collapse, expand, reset, getSizes: () => sizes.value.slice() })
444
+ defineExpose<RsSplitPaneExpose>({
445
+ collapse,
446
+ expand,
447
+ reset,
448
+ getSizes: () => sizes.value.slice(),
449
+ })
444
450
  </script>
445
451
 
446
452
  <template>
@@ -1,4 +1,4 @@
1
- <script setup lang="ts" generic="T extends import('./table-utils').RsTableRowData">
1
+ <script setup lang="ts" generic="T extends import('./table-utils').RsTableRowData = any">
2
2
  import { computed, ref, useSlots } from 'vue'
3
3
  import RsContextMenu from './RsContextMenu.vue'
4
4
  import { assembleRsTableApi } from '../composables/assembleRsTableApi'
@@ -116,6 +116,7 @@ const props = withDefaults(
116
116
  const emit = defineEmits<{
117
117
  'node-click': [node: RsTreeNode, key: string]
118
118
  'node-dblclick': [node: RsTreeNode, key: string]
119
+ 'node-contextmenu': [node: RsTreeNode, key: string, event: MouseEvent]
119
120
  expand: [key: string, expanded: boolean]
120
121
  check: [keys: string[], halfCheckedKeys: string[], node: RsTreeNode, key: string]
121
122
  'node-drop': [dragKey: string, dropKey: string, position: RsTreeDropPosition]
@@ -742,7 +743,9 @@ defineExpose({
742
743
  'rs-tree__row--drop-after': dropTargetKey === entry.key && dropPosition === 'after',
743
744
  'rs-tree__row--last': showLine && entry.isLast,
744
745
  }"
746
+ :data-tree-key="entry.key"
745
747
  :style="[rowIndentStyle(entry.depth), { minHeight: `${rowHeight}px` }]"
748
+ @contextmenu="emit('node-contextmenu', entry.node, entry.key, $event)"
746
749
  @keydown="handleKeydown"
747
750
  @dragstart="onDragStart(entry.key, $event)"
748
751
  @dragover="onDragOver(entry.key, $event)"
@@ -29,6 +29,21 @@ export function isSafeHref(href: string): boolean {
29
29
  return /^(https?:|mailto:)/i.test(value)
30
30
  }
31
31
 
32
+ /** 行内代码整段就是 http(s) 地址时,转成可点击链接(模型常用反引号包 URL)。 */
33
+ function isStandaloneHttpUrl(text: string): boolean {
34
+ const value = text.trim()
35
+ if (!value || /\s/.test(value)) return false
36
+ if (!/^https?:\/\//i.test(value)) return false
37
+ return isSafeHref(value)
38
+ }
39
+
40
+ function renderSafeLink(href: string, innerHtml: string, title?: string): string {
41
+ const url = href.trim()
42
+ if (!isSafeHref(url)) return innerHtml
43
+ const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''
44
+ return `<a href="${escapeHtml(url)}"${titleAttr} target="_blank" rel="noopener noreferrer">${innerHtml}</a>`
45
+ }
46
+
32
47
  export function isSafeImageSrc(src: string): boolean {
33
48
  const value = src.trim()
34
49
  if (!value) return false
@@ -60,11 +75,19 @@ function wrapTables(html: string): string {
60
75
  })
61
76
  }
62
77
 
78
+ function renderTaskMarker(checked: boolean): string {
79
+ const on = checked ? ' rs-markdown__task--on' : ''
80
+ return `<span class="rs-markdown__task${on}" aria-hidden="true"></span> `
81
+ }
82
+
63
83
  function createMarked(breaks: boolean): Marked {
64
84
  return new Marked({
65
85
  gfm: true,
66
86
  breaks,
67
87
  renderer: {
88
+ checkbox({ checked }) {
89
+ return renderTaskMarker(Boolean(checked))
90
+ },
68
91
  code({ text, lang }) {
69
92
  return renderCodeBlock(text, lang)
70
93
  },
@@ -73,10 +96,12 @@ function createMarked(breaks: boolean): Marked {
73
96
  token: Tokens.Link,
74
97
  ) {
75
98
  const text = this.parser.parseInline(token.tokens)
76
- const href = token.href?.trim() ?? ''
77
- if (!isSafeHref(href)) return text
78
- const title = token.title ? ` title="${escapeHtml(token.title)}"` : ''
79
- return `<a href="${escapeHtml(href)}"${title} target="_blank" rel="noopener noreferrer">${text}</a>`
99
+ return renderSafeLink(token.href ?? '', text, token.title ?? undefined)
100
+ },
101
+ codespan({ text }) {
102
+ const inner = `<code>${escapeHtml(text)}</code>`
103
+ if (!isStandaloneHttpUrl(text)) return inner
104
+ return renderSafeLink(text.trim(), inner)
80
105
  },
81
106
  image({ href, title, text }) {
82
107
  const src = href?.trim() ?? ''
@@ -106,9 +131,32 @@ export function renderMarkdown(source = '', options?: RsMarkdownRenderOptions):
106
131
  if (!source.trim()) return ''
107
132
  const breaks = options?.breaks !== false
108
133
  const dirty = wrapTables(getMarked(breaks).parse(source, { async: false }) as string)
134
+ return sanitizeMarkdownHtml(dirty)
135
+ }
136
+
137
+ /** 行内 Markdown(不包 <p>),供对话里夹杂公式的片段使用。 */
138
+ export function renderMarkdownInline(source = ''): string {
139
+ if (!source) return ''
140
+ const dirty = getMarked(true).parseInline(source, { async: false }) as string
141
+ return sanitizeMarkdownHtml(dirty)
142
+ }
143
+
144
+ function sanitizeMarkdownHtml(dirty: string): string {
109
145
  return DOMPurify.sanitize(dirty, {
110
146
  USE_PROFILES: { html: true },
111
- ADD_ATTR: ['class', 'target', 'rel', 'loading', 'decoding', 'alt', 'src', 'data-rs-md-lang'],
147
+ FORBID_TAGS: ['input', 'form', 'textarea', 'select', 'option', 'button'],
148
+ ADD_ATTR: [
149
+ 'class',
150
+ 'target',
151
+ 'rel',
152
+ 'loading',
153
+ 'decoding',
154
+ 'alt',
155
+ 'src',
156
+ 'data-rs-md-lang',
157
+ 'align',
158
+ 'start',
159
+ ],
112
160
  })
113
161
  }
114
162
 
@@ -57,6 +57,27 @@ export type RsSelectModelValue =
57
57
  | RsSelectLabeledValue[]
58
58
  | ''
59
59
 
60
+ /**
61
+ * 按泛型收窄后的 v-model。
62
+ * 默认单选、值为 string:宿主 `@update:model-value="(v: string) => void"` 可直接赋值。
63
+ * `multiple` / `labelInValue` 为字面量 true 时收成数组或 labeled;为 `boolean` 时保留联合。
64
+ */
65
+ export type RsSelectResolvedModel<
66
+ Value extends RsSelectValue = string,
67
+ Multiple extends boolean = false,
68
+ LabelInValue extends boolean = false,
69
+ > = LabelInValue extends true
70
+ ? Multiple extends true
71
+ ? RsSelectLabeledValue[]
72
+ : Multiple extends false
73
+ ? RsSelectLabeledValue | ''
74
+ : RsSelectLabeledValue | RsSelectLabeledValue[] | ''
75
+ : Multiple extends true
76
+ ? Value[]
77
+ : Multiple extends false
78
+ ? Value | ''
79
+ : Value | Value[] | ''
80
+
60
81
  /**
61
82
  * Reka ComboboxItem 禁止 value 为空串(空串表示未选中 / placeholder)。
62
83
  * 选项若传入 value: '',对内映射为此哨兵,避免崩溃;对外读写仍为 ''。
@@ -27,6 +27,20 @@ export interface RsSplitPaneItem {
27
27
  resizerHandle?: boolean
28
28
  }
29
29
 
30
+ /**
31
+ * RsSplitPane 模板 ref 请用此类型。
32
+ * 不要写 `InstanceType<typeof RsSplitPane>`:组件实例类型过深,vue-tsc 会报 Excessive stack depth。
33
+ */
34
+ export interface RsSplitPaneExpose {
35
+ collapse: (key: string) => void
36
+ expand: (key: string, toSize?: number) => void
37
+ reset: () => void
38
+ getSizes: () => number[]
39
+ }
40
+
41
+ /** 模板 ref 实例:expose + 根节点 */
42
+ export type RsSplitPaneInstance = RsSplitPaneExpose & { $el: HTMLElement }
43
+
30
44
  /** 由 RsSplitPaneItem 解析出的规范化约束 */
31
45
  export interface RsSplitConstraint {
32
46
  min: number
@@ -1,4 +1,4 @@
1
- <script setup lang="ts" generic="T extends import('../table-utils').RsTableRowData">
1
+ <script setup lang="ts" generic="T extends import('../table-utils').RsTableRowData = any">
2
2
  /**
3
3
  * RsTable 表体视图:通过 ViewContext inject 取状态(多表互不串扰)。
4
4
  * 行级 v-memo 保留,避免抽离后丢失细粒度跳过渲染。
@@ -1,4 +1,4 @@
1
- <script setup lang="ts" generic="T extends import('../table-utils').RsTableRowData">
1
+ <script setup lang="ts" generic="T extends import('../table-utils').RsTableRowData = any">
2
2
  import type { RsTableColumn, RsTableRowDropPosition, RsTableSelectionType } from '../table-utils'
3
3
  import type { RsTableCellEditTrigger } from './table-edit-utils'
4
4
  import RsTableCell from './RsTableCell.vue'
@@ -1,4 +1,4 @@
1
- <script setup lang="ts" generic="T extends import('../table-utils').RsTableRowData">
1
+ <script setup lang="ts" generic="T extends import('../table-utils').RsTableRowData = any">
2
2
  import { computed, ref, watch } from 'vue'
3
3
  import type { RsTableColumn } from '../table-utils'
4
4
  import RsCheckbox from '../RsCheckbox.vue'
@@ -6,7 +6,7 @@ import RsDatePicker from '../RsDatePicker.vue'
6
6
  import RsInput from '../RsInput.vue'
7
7
  import RsInputNumber from '../RsInputNumber.vue'
8
8
  import RsSelect from '../RsSelect.vue'
9
- import type { RsSelectModelValue, RsSelectOptions } from '../select-utils'
9
+ import type { RsSelectOptions } from '../select-utils'
10
10
  import type { RsTableColumnEditorOptionsResolved, RsTableCellValueType } from '../table-utils'
11
11
  import {
12
12
  applyFocusMode,
@@ -223,15 +223,8 @@ function onKeydown(event: KeyboardEvent): void {
223
223
  }
224
224
  }
225
225
 
226
- function onSelectUpdate(value: RsSelectModelValue): void {
227
- const toToken = (item: RsSelectModelValue): string => {
228
- if (item == null || item === '') return ''
229
- if (typeof item === 'object' && !Array.isArray(item) && 'value' in item) {
230
- return String(item.value)
231
- }
232
- return String(item)
233
- }
234
- const tokens = Array.isArray(value) ? value.map(toToken) : toToken(value)
226
+ function onSelectUpdate(value: string | string[]): void {
227
+ const tokens = Array.isArray(value) ? value.map(String) : String(value)
235
228
  const empty =
236
229
  tokens === '' || (Array.isArray(tokens) && tokens.length === 0)
237
230
  // 表格内下拉默认不可清除:仅选择;显式 clearable 时才允许清空
@@ -1,4 +1,4 @@
1
- <script setup lang="ts" generic="T extends import('../table-utils').RsTableRowData">
1
+ <script setup lang="ts" generic="T extends import('../table-utils').RsTableRowData = any">
2
2
  import RsIcon from '../RsIcon.vue'
3
3
  import RsTableHeaderFilter from './RsTableHeaderFilter.vue'
4
4
  import { useRsTableView } from './rs-table-view-context'
@@ -211,6 +211,72 @@ export type RsTableEmits<T extends RsTableRowData = RsTableRowData> = {
211
211
  rowEditRollback: [row: T, index: number]
212
212
  }
213
213
 
214
+ /**
215
+ * 插槽回调按双变处理:宿主把 row 写成业务行类型时,不被逆变拒绝。
216
+ * 列插槽名是动态的,vue-tsc 不能从子组件 inject 推断 T,必须由 RsTable 自身 declare。
217
+ */
218
+ type RsTableSlotFn<P> = {
219
+ bivarianceHack(props: P): unknown
220
+ }['bivarianceHack']
221
+
222
+ /** 列单元格插槽参数(`#columnKey`) */
223
+ export interface RsTableColumnSlotProps<T extends RsTableRowData = RsTableRowData> {
224
+ row: T
225
+ column: RsTableColumn<T>
226
+ index: number
227
+ }
228
+
229
+ /** 自定义编辑器插槽参数(`#edit-columnKey`) */
230
+ export interface RsTableEditSlotProps<T extends RsTableRowData = RsTableRowData>
231
+ extends RsTableColumnSlotProps<T> {
232
+ draft: string
233
+ error: string | null
234
+ update: (value: string) => void
235
+ commit: () => void
236
+ cancel: () => void
237
+ }
238
+
239
+ /** 列头插槽参数(`#header-columnKey`) */
240
+ export interface RsTableHeaderSlotProps<T extends RsTableRowData = RsTableRowData> {
241
+ column: RsTableColumn<T>
242
+ }
243
+
244
+ /** 展开行插槽参数(`#expand`) */
245
+ export interface RsTableExpandSlotProps<T extends RsTableRowData = RsTableRowData> {
246
+ row: T
247
+ index: number
248
+ }
249
+
250
+ /** 分组行插槽参数(`#group`) */
251
+ export interface RsTableGroupSlotProps {
252
+ key: string
253
+ label: string
254
+ }
255
+
256
+ /**
257
+ * 按插槽名解析参数。列名是动态 key;`edit-*` / `header-*` 用模板字面量区分。
258
+ */
259
+ export type RsTableSlotPropsOf<T extends RsTableRowData, K extends string> = K extends
260
+ | 'empty'
261
+ | 'summary'
262
+ ? Record<string, never>
263
+ : K extends 'group'
264
+ ? RsTableGroupSlotProps
265
+ : K extends 'expand'
266
+ ? RsTableExpandSlotProps<T>
267
+ : K extends `header-${string}`
268
+ ? RsTableHeaderSlotProps<T>
269
+ : K extends `edit-${string}`
270
+ ? RsTableEditSlotProps<T>
271
+ : RsTableColumnSlotProps<T>
272
+
273
+ /**
274
+ * RsTable 公开插槽。用映射类型而不是 string 索引,避免 empty/group 与列插槽冲突。
275
+ */
276
+ export type RsTableSlots<T extends RsTableRowData = RsTableRowData> = {
277
+ [K in string]?: RsTableSlotFn<RsTableSlotPropsOf<T, K>>
278
+ }
279
+
214
280
  /** withDefaults 第二参(工厂默认用函数) */
215
281
  export const RS_TABLE_PROP_DEFAULTS = {
216
282
  loading: false,
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export type { RsLoadingBarApi } from './composables/useRsLoadingBar'
37
37
  export { default as RsDropdown } from './components/RsDropdown.vue'
38
38
  export { default as RsIcon } from './components/RsIcon.vue'
39
39
  export { default as RsInput } from './components/RsInput.vue'
40
+ export type { RsInputExpose, RsInputInstance } from './components/RsInput.vue'
40
41
  export { default as RsInputNumber } from './components/RsInputNumber.vue'
41
42
  export type { RsInputNumberValue } from './components/input-number-utils'
42
43
  export {
@@ -141,6 +142,7 @@ export type {
141
142
  MonacoCompletionPrefixResolver,
142
143
  MonacoCompletionRequest,
143
144
  MonacoCompletionSnippet,
145
+ RsMonacoEditorExpose,
144
146
  } from './components/RsMonacoEditor.vue'
145
147
  export type {
146
148
  MonacoBuiltinLanguage,
@@ -171,6 +173,7 @@ export {
171
173
  isSafeHref,
172
174
  isSafeImageSrc,
173
175
  renderMarkdown,
176
+ renderMarkdownInline,
174
177
  resolveMarkdownHeight,
175
178
  resolveMarkdownMode,
176
179
  } from './components/markdown-utils'
@@ -223,6 +226,7 @@ export type {
223
226
  RsSelectGetPopupContainer,
224
227
  RsSelectLabeledValue,
225
228
  RsSelectModelValue,
229
+ RsSelectResolvedModel,
226
230
  RsSelectOption,
227
231
  RsSelectOptionFilterProp,
228
232
  RsSelectOptionGroup,
@@ -329,6 +333,8 @@ export { isStepSeparatorCompleted, resolveStepStatus } from './components/steps-
329
333
  export type {
330
334
  RsSplitConstraint,
331
335
  RsSplitOrientation,
336
+ RsSplitPaneExpose,
337
+ RsSplitPaneInstance,
332
338
  RsSplitPaneItem,
333
339
  RsSplitPaneSize,
334
340
  } from './components/split-pane-utils'
@@ -478,6 +484,13 @@ export {
478
484
  export type {
479
485
  RsTableProps,
480
486
  RsTableEmits,
487
+ RsTableColumnSlotProps,
488
+ RsTableEditSlotProps,
489
+ RsTableHeaderSlotProps,
490
+ RsTableExpandSlotProps,
491
+ RsTableGroupSlotProps,
492
+ RsTableSlots,
493
+ RsTableSlotPropsOf,
481
494
  } from './components/table/rs-table-props'
482
495
  export { RS_TABLE_PROP_DEFAULTS } from './components/table/rs-table-props'
483
496
  export { useRsTableGridKeyboard } from './composables/useRsTableGridKeyboard'