niuma-ui 1.1.6 → 1.1.8

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,30 @@
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.1.8] - 2026-08-28
10
+
11
+ ### 新增
12
+
13
+ - `RsCodeBlock`:`editable`(默认 `false`,只读行为不变);可写时 `update:code` 同步正文;expose `getSelection` 返回选区文本与起止行号。可写态显示光标。`showBar`(默认 `true`)为 `false` 时隐藏语言条与复制/下载。
14
+ - `RsTree`:`node-contextmenu`(`node, key, event`);行节点带 `data-tree-key`,便于宿主挂右键菜单。
15
+ - `RsPopover.popupClassName`:附加到弹出层 class,对齐 `RsSelect`。
16
+ - `renderMarkdownInline`:行内 Markdown(不包 `<p>`),并从包入口导出。
17
+
18
+ ### 修复
19
+
20
+ - `RsButton`:内置 `tooltip` 打开时 Teleport 到 `document.body`(`position: fixed`),避免侧栏 / Dialog 等 overflow 父级裁切。关闭即卸载;延迟 300ms、Escape 关闭;有可见文案时用 `aria-describedby`,仅图标走 `aria-label` 不重复朗读。
21
+ - `RsMarkdown`:GFM 表格对齐 `align`;裸 URL 与行内代码中的 `http(s)` 可点击;任务列表改为 span 标记并禁止消毒后的 `<input>`。
22
+
23
+ ## [1.1.7] - 2026-08-21
24
+
25
+ ### 新增
26
+
27
+ - `RsDatePicker` / `RsCalendarGrid`:面板标题两侧增加上一年 / 下一年双箭头,月份仍用单箭头前后切换。
28
+
29
+ ### 变更
30
+
31
+ - `RsInput`:清除按钮与密码显隐按钮 `tabindex="-1"`,Tab 只停在输入框(对齐 `RsInputNumber` 步进按钮);鼠标点击与 `aria-label` 不变。
32
+
9
33
  ## [1.1.6] - 2026-08-20
10
34
 
11
35
  ### 变更
@@ -159,7 +183,9 @@
159
183
 
160
184
  - 1.0 之前的私有 tag(如 `v0.1.0`)仅作历史记录;新接入请依赖 `v1.0.0` 及之后版本。
161
185
 
162
- [Unreleased]: https://github.com/Blair-Shang/niuma-ui/compare/v1.1.6...HEAD
186
+ [Unreleased]: https://github.com/Blair-Shang/niuma-ui/compare/v1.1.8...HEAD
187
+ [1.1.8]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.1.8
188
+ [1.1.7]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.1.7
163
189
  [1.1.6]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.1.6
164
190
  [1.1.5]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.1.5
165
191
  [1.1.4]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.1.4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niuma-ui",
3
- "version": "1.1.6",
3
+ "version": "1.1.8",
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 () => {
@@ -60,12 +60,31 @@ describe('RsCalendarGrid', () => {
60
60
  expect(disabledCount).toBeGreaterThanOrEqual(3)
61
61
  })
62
62
 
63
- it('updates view month via navigation', async () => {
63
+ it('updates view month via month navigation', async () => {
64
64
  const wrapper = mount(RsCalendarGrid, {
65
65
  props: { viewYear: 2025, viewMonth: 6 },
66
66
  })
67
- await wrapper.findAll('.rs-calendar-grid__nav')[1].trigger('click')
67
+ await wrapper.find('.rs-calendar-grid__nav--next-month').trigger('click')
68
68
  expect(wrapper.emitted('update:viewMonth')?.[0]).toEqual([7])
69
+ expect(wrapper.emitted('update:viewYear')).toBeUndefined()
70
+ })
71
+
72
+ it('updates view year via year navigation and keeps the month', async () => {
73
+ const next = mount(RsCalendarGrid, {
74
+ props: { viewYear: 2025, viewMonth: 6 },
75
+ })
76
+ await next.find('.rs-calendar-grid__nav--next-year').trigger('click')
77
+ expect(next.emitted('update:viewYear')?.[0]).toEqual([2026])
78
+ expect(next.emitted('update:viewMonth')).toBeUndefined()
79
+ next.unmount()
80
+
81
+ const prev = mount(RsCalendarGrid, {
82
+ props: { viewYear: 2025, viewMonth: 6 },
83
+ })
84
+ await prev.find('.rs-calendar-grid__nav--prev-year').trigger('click')
85
+ expect(prev.emitted('update:viewYear')?.[0]).toEqual([2024])
86
+ expect(prev.emitted('update:viewMonth')).toBeUndefined()
87
+ prev.unmount()
69
88
  })
70
89
 
71
90
  it('highlights range between start and end', () => {
@@ -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
 
@@ -244,6 +244,7 @@ describe('RsInput', () => {
244
244
  expect(wrapper.find('.rs-input-group--has-suffix').exists()).toBe(true)
245
245
  const toggle = wrapper.find('button.rs-input-group__action')
246
246
  expect(toggle.exists()).toBe(true)
247
+ expect(toggle.attributes('tabindex')).toBe('-1')
247
248
  await toggle.trigger('click')
248
249
  expect(wrapper.find('input').attributes('type')).toBe('text')
249
250
  })
@@ -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: {
@@ -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 {
@@ -65,6 +65,14 @@ const weeks = computed(() => {
65
65
  return rows
66
66
  })
67
67
 
68
+ function goPrevYear(): void {
69
+ viewYear.value -= 1
70
+ }
71
+
72
+ function goNextYear(): void {
73
+ viewYear.value += 1
74
+ }
75
+
68
76
  function goPrevMonth(): void {
69
77
  if (viewMonth.value === 1) {
70
78
  viewYear.value -= 1
@@ -133,23 +141,43 @@ function selectDate(cell: RsCalendarCell): void {
133
141
  <template>
134
142
  <div class="rs-calendar-grid">
135
143
  <div class="rs-calendar-grid__header">
136
- <button
137
- type="button"
138
- class="rs-calendar-grid__nav"
139
- :aria-label="t('datePicker.prevMonth')"
140
- @click="goPrevMonth"
141
- >
142
- <RsIcon name="chevron-left" :size="16" />
143
- </button>
144
+ <div class="rs-calendar-grid__nav-group">
145
+ <button
146
+ type="button"
147
+ class="rs-calendar-grid__nav rs-calendar-grid__nav--prev-year"
148
+ :aria-label="t('datePicker.prevYear')"
149
+ @click="goPrevYear"
150
+ >
151
+ <RsIcon name="chevrons-left" :size="16" />
152
+ </button>
153
+ <button
154
+ type="button"
155
+ class="rs-calendar-grid__nav rs-calendar-grid__nav--prev-month"
156
+ :aria-label="t('datePicker.prevMonth')"
157
+ @click="goPrevMonth"
158
+ >
159
+ <RsIcon name="chevron-left" :size="16" />
160
+ </button>
161
+ </div>
144
162
  <span class="rs-calendar-grid__title">{{ monthLabel }}</span>
145
- <button
146
- type="button"
147
- class="rs-calendar-grid__nav"
148
- :aria-label="t('datePicker.nextMonth')"
149
- @click="goNextMonth"
150
- >
151
- <RsIcon name="chevron-right" :size="16" />
152
- </button>
163
+ <div class="rs-calendar-grid__nav-group">
164
+ <button
165
+ type="button"
166
+ class="rs-calendar-grid__nav rs-calendar-grid__nav--next-month"
167
+ :aria-label="t('datePicker.nextMonth')"
168
+ @click="goNextMonth"
169
+ >
170
+ <RsIcon name="chevron-right" :size="16" />
171
+ </button>
172
+ <button
173
+ type="button"
174
+ class="rs-calendar-grid__nav rs-calendar-grid__nav--next-year"
175
+ :aria-label="t('datePicker.nextYear')"
176
+ @click="goNextYear"
177
+ >
178
+ <RsIcon name="chevrons-right" :size="16" />
179
+ </button>
180
+ </div>
153
181
  </div>
154
182
 
155
183
  <table class="rs-calendar-grid__table">
@@ -200,8 +228,14 @@ function selectDate(cell: RsCalendarCell): void {
200
228
  justify-content: space-between;
201
229
  gap: var(--rs-space-sm);
202
230
  }
231
+ .rs-calendar-grid__nav-group {
232
+ display: inline-flex;
233
+ align-items: center;
234
+ flex: 0 0 auto;
235
+ }
203
236
  .rs-calendar-grid__title {
204
237
  flex: 1;
238
+ min-width: 0;
205
239
  text-align: center;
206
240
  font-size: var(--rs-font-size-sm);
207
241
  font-weight: var(--rs-font-weight-semibold);
@@ -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
 
@@ -728,6 +728,8 @@ defineExpose({
728
728
 
729
729
  class="rs-input-group__action"
730
730
 
731
+ tabindex="-1"
732
+
731
733
  :aria-label="t('input.clear')"
732
734
 
733
735
  @pointerdown.prevent
@@ -748,6 +750,8 @@ defineExpose({
748
750
 
749
751
  class="rs-input-group__action"
750
752
 
753
+ tabindex="-1"
754
+
751
755
  :aria-label="passwordVisible ? t('input.hidePassword') : t('input.showPassword')"
752
756
 
753
757
  :aria-pressed="passwordVisible"
@@ -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>
@@ -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"
@@ -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
 
package/src/index.ts CHANGED
@@ -171,6 +171,7 @@ export {
171
171
  isSafeHref,
172
172
  isSafeImageSrc,
173
173
  renderMarkdown,
174
+ renderMarkdownInline,
174
175
  resolveMarkdownHeight,
175
176
  resolveMarkdownMode,
176
177
  } from './components/markdown-utils'
@@ -101,6 +101,8 @@ export const zhCN: RsLocaleMessages = {
101
101
  'datePicker.placeholder': '选择日期',
102
102
  'datePicker.rangePlaceholder': '选择日期范围',
103
103
  'datePicker.clear': '清空日期',
104
+ 'datePicker.prevYear': '上一年',
105
+ 'datePicker.nextYear': '下一年',
104
106
  'datePicker.prevMonth': '上个月',
105
107
  'datePicker.nextMonth': '下个月',
106
108
  'datePicker.today': '今天',
@@ -300,6 +302,8 @@ export const enUS: RsLocaleMessages = {
300
302
  'datePicker.placeholder': 'Select date',
301
303
  'datePicker.rangePlaceholder': 'Select date range',
302
304
  'datePicker.clear': 'Clear date',
305
+ 'datePicker.prevYear': 'Previous year',
306
+ 'datePicker.nextYear': 'Next year',
303
307
  'datePicker.prevMonth': 'Previous month',
304
308
  'datePicker.nextMonth': 'Next month',
305
309
  'datePicker.today': 'Today',