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 +29 -1
- package/package.json +1 -1
- package/src/__tests__/RsButton.spec.ts +39 -6
- package/src/__tests__/RsDialog.spec.ts +1 -1
- package/src/__tests__/RsDrawer.spec.ts +7 -1
- package/src/__tests__/RsMarkdown.spec.ts +37 -0
- package/src/__tests__/RsPopover.spec.ts +14 -0
- package/src/__tests__/rs-select-model.types.spec.ts +28 -0
- package/src/__tests__/rs-table-slots.types.spec.ts +20 -0
- package/src/components/RsButton.vue +130 -51
- package/src/components/RsCodeBlock.vue +56 -7
- package/src/components/RsInput.vue +13 -1
- package/src/components/RsMarkdown.vue +21 -2
- package/src/components/RsMonacoEditor.vue +11 -1
- package/src/components/RsPopover.vue +3 -1
- package/src/components/RsSelect.vue +9 -8
- package/src/components/RsSplitPane.vue +7 -1
- package/src/components/RsTable.vue +1 -1
- package/src/components/RsTree.vue +3 -0
- package/src/components/markdown-utils.ts +53 -5
- package/src/components/select-utils.ts +21 -0
- package/src/components/split-pane-utils.ts +14 -0
- package/src/components/table/RsTableBody.vue +1 -1
- package/src/components/table/RsTableBodyRow.vue +1 -1
- package/src/components/table/RsTableCell.vue +1 -1
- package/src/components/table/RsTableCellEditor.vue +3 -10
- package/src/components/table/RsTableHeader.vue +1 -1
- package/src/components/table/rs-table-props.ts +66 -0
- package/src/index.ts +13 -0
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.
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
110
|
-
const
|
|
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
|
|
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
|
-
|
|
143
|
+
left = rect.right
|
|
144
|
+
transform = 'translateX(-100%)'
|
|
120
145
|
} else if (mid < vw * 0.28) {
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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:
|
|
468
|
-
|
|
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(
|
|
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
|
-
|
|
102
|
-
|
|
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
|
|
155
|
-
|
|
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-
|
|
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-
|
|
326
|
-
|
|
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<
|
|
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?:
|
|
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?:
|
|
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
|
|
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({
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
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 {
|
|
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:
|
|
227
|
-
const
|
|
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'
|