niuma-ui 1.1.0 → 1.1.3

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.3] - 2026-08-14
10
+
11
+ ### 新增
12
+
13
+ - **RsDrawer 内边缘拖拽缩放**:默认 `resizable`(`size=full` 除外)。左右改宽、上下改高,可配 `minSize` / `maxSize`,支持键盘方向键。
14
+
15
+ ### 修复
16
+
17
+ - **RsDrawer 贴边定位**:覆盖 Reka DialogContent 默认居中,避免抽屉被压成标题条。
18
+
19
+ ## [1.1.2] - 2026-08-14
20
+
21
+ ### 修复
22
+
23
+ - **RsIcon 在 pnpm/npm 宿主中为空**:不再用 `import.meta.glob` 扫 `node_modules/@lucide/vue`(该路径在 pnpm 虚拟 store 下不存在)。改为 `import * as LucideVue from '@lucide/vue'`,走正常包解析,宿主安装 `niuma-ui` 即可使用图标。
24
+
25
+ ## [1.1.1] - 2026-08-14
26
+
27
+ ### 修复
28
+
29
+ - `RsTableCellEditor`:单元格 Select 的 `update:model-value` 对齐 `RsSelectModelValue`(含 number / 多选 / labelInValue)。
30
+ - `useRsTableCore`:`compact` / `size` 为 undefined 时回退默认值,避免宿主 `vue-tsc` 失败。
31
+ - `useRsTableHeadless`:`rowKey` 区分无参 getter 与行访问器,避免误当成需传入 row 的函数调用。
32
+
9
33
  ## [1.1.0] - 2026-08-14
10
34
 
11
35
  ### 新增
@@ -98,7 +122,10 @@
98
122
 
99
123
  - 1.0 之前的私有 tag(如 `v0.1.0`)仅作历史记录;新接入请依赖 `v1.0.0` 及之后版本。
100
124
 
101
- [Unreleased]: https://github.com/Blair-Shang/niuma-ui/compare/v1.1.0...HEAD
125
+ [Unreleased]: https://github.com/Blair-Shang/niuma-ui/compare/v1.1.3...HEAD
126
+ [1.1.3]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.1.3
127
+ [1.1.2]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.1.2
128
+ [1.1.1]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.1.1
102
129
  [1.1.0]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.1.0
103
130
  [1.0.2]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.0.2
104
131
  [1.0.1]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.0.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niuma-ui",
3
- "version": "1.1.0",
3
+ "version": "1.1.3",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "description": "Vue 3 设计系统与 Rs* 组件库(--rs-* token)",
@@ -235,4 +235,52 @@ describe('RsDrawer', () => {
235
235
  expect(closeBtn.querySelector('.rs-btn__tooltip')?.textContent).toContain('Close')
236
236
  wrapper.unmount()
237
237
  })
238
+
239
+ it('renders an inner-edge resize handle by default', async () => {
240
+ const wrapper = mount(RsDrawer, {
241
+ props: { open: true, title: '帮助' },
242
+ attachTo: document.body,
243
+ })
244
+ await flushPromises()
245
+ const handle = document.body.querySelector('.rs-drawer__resize') as HTMLElement | null
246
+ expect(handle).toBeTruthy()
247
+ expect(handle?.getAttribute('aria-orientation')).toBe('vertical')
248
+ wrapper.unmount()
249
+ })
250
+
251
+ it('hides the resize handle when size is full or resizable is false', async () => {
252
+ const full = mount(RsDrawer, {
253
+ props: { open: true, title: '全屏', size: 'full' },
254
+ attachTo: document.body,
255
+ })
256
+ await flushPromises()
257
+ expect(document.body.querySelector('.rs-drawer__resize')).toBeNull()
258
+ full.unmount()
259
+
260
+ const locked = mount(RsDrawer, {
261
+ props: { open: true, title: '固定', resizable: false },
262
+ attachTo: document.body,
263
+ })
264
+ await flushPromises()
265
+ expect(document.body.querySelector('.rs-drawer__resize')).toBeNull()
266
+ locked.unmount()
267
+ })
268
+
269
+ it('widens a right drawer with ArrowLeft on the resize handle', async () => {
270
+ const wrapper = mount(RsDrawer, {
271
+ props: { open: true, title: '缩放', width: 360 },
272
+ attachTo: document.body,
273
+ })
274
+ await flushPromises()
275
+ const content = document.body.querySelector('.rs-drawer__content') as HTMLElement
276
+ Object.defineProperty(content, 'getBoundingClientRect', {
277
+ configurable: true,
278
+ value: () => ({ width: 360, height: 800, top: 0, left: 800, right: 1160, bottom: 800, x: 800, y: 0, toJSON: () => ({}) }),
279
+ })
280
+ const handle = document.body.querySelector('.rs-drawer__resize') as HTMLElement
281
+ await handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))
282
+ await flushPromises()
283
+ expect(content.style.width).toBe('376px')
284
+ wrapper.unmount()
285
+ })
238
286
  })
@@ -14,6 +14,10 @@ import {
14
14
  resolveDrawerOverlayStyle,
15
15
  resolveRsDrawerDimensionCss,
16
16
  resolveRsDrawerSizeCss,
17
+ resolveRsDrawerSizePx,
18
+ clampRsDrawerSize,
19
+ RS_DRAWER_MIN_SIZE_PX,
20
+ RS_DRAWER_MAX_VIEWPORT_RATIO,
17
21
  runRsDrawerBeforeClose,
18
22
  type RsDrawerBeforeClose,
19
23
  type RsDrawerCloseReason,
@@ -38,6 +42,15 @@ const props = withDefaults(
38
42
  width?: RsDrawerDimension
39
43
  /** 上下抽屉自定义高度(覆盖 size) */
40
44
  height?: RsDrawerDimension
45
+ /**
46
+ * 内边缘拖拽改尺寸。full 始终不可拖。
47
+ * 控制台 / 帮助栏等侧栏场景建议保持开启。
48
+ */
49
+ resizable?: boolean
50
+ /** 可拖下限(px / CSS);默认 256px */
51
+ minSize?: RsDrawerDimension
52
+ /** 可拖上限(px / CSS);默认视口 90% */
53
+ maxSize?: RsDrawerDimension
41
54
  /**
42
55
  * 是否模态:锁焦点并拦截背后交互。
43
56
  * 未显式传入时跟随 showOverlay(有遮罩=模态,无遮罩=非模态)。
@@ -78,6 +91,7 @@ const props = withDefaults(
78
91
  showClose: true,
79
92
  closeOnOverlayClick: true,
80
93
  closeOnEsc: true,
94
+ resizable: true,
81
95
  },
82
96
  )
83
97
 
@@ -85,6 +99,10 @@ const emit = defineEmits<{
85
99
  openChange: [open: boolean]
86
100
  afterOpen: []
87
101
  afterClose: [reason: RsDrawerCloseReason]
102
+ /** 拖拽过程与结束都会抛出当前像素尺寸 */
103
+ resize: [size: number]
104
+ 'update:width': [value: number]
105
+ 'update:height': [value: number]
88
106
  }>()
89
107
 
90
108
  const { t } = useRsI18n()
@@ -102,8 +120,15 @@ const overlayStyle = computed(() =>
102
120
  )
103
121
 
104
122
  const isHorizontal = computed(() => props.side === 'left' || props.side === 'right')
123
+ const enableResizable = computed(() => props.resizable && props.size !== 'full')
124
+
125
+ /** 本次打开后拖拽得到的像素尺寸;关闭时清空,回到 size/width/height */
126
+ const liveSizePx = ref<number | undefined>(undefined)
127
+ const resizing = ref(false)
128
+ const contentRef = ref<{ $el?: HTMLElement } | HTMLElement | null>(null)
105
129
 
106
130
  const panelSizeCss = computed(() => {
131
+ if (liveSizePx.value != null) return `${liveSizePx.value}px`
107
132
  if (isHorizontal.value) {
108
133
  return (
109
134
  resolveRsDrawerDimensionCss(props.width) ??
@@ -119,9 +144,50 @@ const panelSizeCss = computed(() => {
119
144
  })
120
145
 
121
146
  const contentStyle = computed(() => {
147
+ const style: Record<string, string> = {}
122
148
  const size = panelSizeCss.value
123
- if (!size || props.size === 'full') return undefined
124
- return { '--rs-drawer-panel-size': size } as Record<string, string>
149
+ if (size && props.size !== 'full') {
150
+ style['--rs-drawer-panel-size'] = size
151
+ }
152
+ if (liveSizePx.value != null && props.size !== 'full') {
153
+ if (isHorizontal.value) style.width = `${liveSizePx.value}px`
154
+ else style.height = `${liveSizePx.value}px`
155
+ }
156
+ // Reka DialogContent 默认居中;抽屉必须钉在对应边缘,否则只剩一条标题。
157
+ if (props.side === 'right') {
158
+ style.left = 'auto'
159
+ style.right = '0'
160
+ style.top = '0'
161
+ style.bottom = '0'
162
+ style.height = '100%'
163
+ style.maxHeight = 'none'
164
+ style.transform = 'none'
165
+ } else if (props.side === 'left') {
166
+ style.left = '0'
167
+ style.right = 'auto'
168
+ style.top = '0'
169
+ style.bottom = '0'
170
+ style.height = '100%'
171
+ style.maxHeight = 'none'
172
+ style.transform = 'none'
173
+ } else if (props.side === 'top') {
174
+ style.left = '0'
175
+ style.right = '0'
176
+ style.top = '0'
177
+ style.bottom = 'auto'
178
+ style.width = '100%'
179
+ style.maxWidth = 'none'
180
+ style.transform = 'none'
181
+ } else if (props.side === 'bottom') {
182
+ style.left = '0'
183
+ style.right = '0'
184
+ style.top = 'auto'
185
+ style.bottom = '0'
186
+ style.width = '100%'
187
+ style.maxWidth = 'none'
188
+ style.transform = 'none'
189
+ }
190
+ return Object.keys(style).length ? style : undefined
125
191
  })
126
192
 
127
193
  const contentClass = computed(() => [
@@ -130,6 +196,8 @@ const contentClass = computed(() => [
130
196
  {
131
197
  'rs-drawer__content--contained': contained.value,
132
198
  'rs-drawer__content--custom-size': Boolean(panelSizeCss.value) && props.size !== 'full',
199
+ 'rs-drawer__content--resizable': enableResizable.value,
200
+ 'rs-drawer__content--resizing': resizing.value,
133
201
  },
134
202
  ])
135
203
 
@@ -238,6 +306,8 @@ watch(open, (isOpen, wasOpen) => {
238
306
  return
239
307
  }
240
308
  if (!isOpen && wasOpen) {
309
+ liveSizePx.value = undefined
310
+ stopResize()
241
311
  releaseOutsideSuppress()
242
312
  clearAfterOpenTimer()
243
313
  emit('openChange', false)
@@ -246,6 +316,7 @@ watch(open, (isOpen, wasOpen) => {
246
316
  })
247
317
 
248
318
  onBeforeUnmount(() => {
319
+ stopResize()
249
320
  releaseOutsideSuppress()
250
321
  clearAfterOpenTimer()
251
322
  })
@@ -287,7 +358,8 @@ function onEscapeKeyDown(event: Event): void {
287
358
  function shouldBlockOutsideDismiss(): boolean {
288
359
  return (
289
360
  !props.closeOnOverlayClick ||
290
- suppressOutsideUntilGestureEnd.value
361
+ suppressOutsideUntilGestureEnd.value ||
362
+ resizing.value
291
363
  )
292
364
  }
293
365
 
@@ -316,6 +388,136 @@ async function onHeaderCloseClick(): Promise<void> {
316
388
  await requestClose('close')
317
389
  }
318
390
 
391
+ function resolveContentEl(): HTMLElement | null {
392
+ const inst = contentRef.value
393
+ if (!inst) return null
394
+ if (inst instanceof HTMLElement) return inst
395
+ const el = inst.$el
396
+ return el instanceof HTMLElement ? el : null
397
+ }
398
+
399
+ function rootFontPx(): number {
400
+ const raw = getComputedStyle(document.documentElement).fontSize
401
+ const n = Number.parseFloat(raw)
402
+ return Number.isFinite(n) && n > 0 ? n : 16
403
+ }
404
+
405
+ function viewportPx(): number {
406
+ return isHorizontal.value ? window.innerWidth : window.innerHeight
407
+ }
408
+
409
+ function sizeBounds(): { min: number; max: number } {
410
+ const vp = viewportPx()
411
+ const root = rootFontPx()
412
+ const min = resolveRsDrawerSizePx(props.minSize, RS_DRAWER_MIN_SIZE_PX, root, vp)
413
+ const max = resolveRsDrawerSizePx(
414
+ props.maxSize,
415
+ Math.round(vp * RS_DRAWER_MAX_VIEWPORT_RATIO),
416
+ root,
417
+ vp,
418
+ )
419
+ return { min, max }
420
+ }
421
+
422
+ function applyLiveSize(px: number): number {
423
+ const { min, max } = sizeBounds()
424
+ const next = clampRsDrawerSize(px, min, max)
425
+ const el = resolveContentEl()
426
+ if (el) {
427
+ el.style.setProperty('--rs-drawer-panel-size', `${next}px`)
428
+ if (isHorizontal.value) el.style.width = `${next}px`
429
+ else el.style.height = `${next}px`
430
+ }
431
+ return next
432
+ }
433
+
434
+ let resizePointerId: number | null = null
435
+ let resizeStartClient = 0
436
+ let resizeStartSize = 0
437
+
438
+ function stopResize(): void {
439
+ if (resizePointerId != null) {
440
+ window.removeEventListener('pointermove', onResizePointerMove)
441
+ window.removeEventListener('pointerup', onResizePointerUp)
442
+ window.removeEventListener('pointercancel', onResizePointerUp)
443
+ resizePointerId = null
444
+ }
445
+ resizing.value = false
446
+ document.body.style.removeProperty('cursor')
447
+ document.body.style.removeProperty('user-select')
448
+ }
449
+
450
+ function currentPanelPx(): number {
451
+ const el = resolveContentEl()
452
+ if (!el) return RS_DRAWER_MIN_SIZE_PX
453
+ const rect = el.getBoundingClientRect()
454
+ return isHorizontal.value ? rect.width : rect.height
455
+ }
456
+
457
+ function onResizePointerDown(event: PointerEvent): void {
458
+ if (!enableResizable.value || event.button !== 0) return
459
+ event.preventDefault()
460
+ event.stopPropagation()
461
+ resizeStartClient = isHorizontal.value ? event.clientX : event.clientY
462
+ resizeStartSize = currentPanelPx()
463
+ resizePointerId = event.pointerId
464
+ resizing.value = true
465
+ document.body.style.cursor = isHorizontal.value ? 'ew-resize' : 'ns-resize'
466
+ document.body.style.userSelect = 'none'
467
+ window.addEventListener('pointermove', onResizePointerMove)
468
+ window.addEventListener('pointerup', onResizePointerUp)
469
+ window.addEventListener('pointercancel', onResizePointerUp)
470
+ }
471
+
472
+ function deltaForPointer(event: PointerEvent): number {
473
+ const client = isHorizontal.value ? event.clientX : event.clientY
474
+ const raw = client - resizeStartClient
475
+ if (props.side === 'right' || props.side === 'bottom') return -raw
476
+ return raw
477
+ }
478
+
479
+ function onResizePointerMove(event: PointerEvent): void {
480
+ if (resizePointerId == null || event.pointerId !== resizePointerId) return
481
+ const next = applyLiveSize(resizeStartSize + deltaForPointer(event))
482
+ emit('resize', next)
483
+ }
484
+
485
+ function commitResize(next: number): void {
486
+ liveSizePx.value = next
487
+ emit('resize', next)
488
+ if (isHorizontal.value) emit('update:width', next)
489
+ else emit('update:height', next)
490
+ }
491
+
492
+ function onResizePointerUp(event: PointerEvent): void {
493
+ if (resizePointerId == null || event.pointerId !== resizePointerId) return
494
+ const next = applyLiveSize(resizeStartSize + deltaForPointer(event))
495
+ stopResize()
496
+ commitResize(next)
497
+ }
498
+
499
+ function onResizeKeydown(event: KeyboardEvent): void {
500
+ if (!enableResizable.value) return
501
+ const step = event.shiftKey ? 48 : 16
502
+ let delta = 0
503
+ if (props.side === 'right') {
504
+ if (event.key === 'ArrowLeft') delta = step
505
+ else if (event.key === 'ArrowRight') delta = -step
506
+ } else if (props.side === 'left') {
507
+ if (event.key === 'ArrowRight') delta = step
508
+ else if (event.key === 'ArrowLeft') delta = -step
509
+ } else if (props.side === 'top') {
510
+ if (event.key === 'ArrowDown') delta = step
511
+ else if (event.key === 'ArrowUp') delta = -step
512
+ } else if (props.side === 'bottom') {
513
+ if (event.key === 'ArrowUp') delta = step
514
+ else if (event.key === 'ArrowDown') delta = -step
515
+ }
516
+ if (!delta) return
517
+ event.preventDefault()
518
+ commitResize(applyLiveSize(currentPanelPx() + delta))
519
+ }
520
+
319
521
  defineExpose({
320
522
  /** 请求关闭(走 beforeClose) */
321
523
  close: (reason: RsDrawerCloseReason = 'programmatic') => requestClose(reason),
@@ -339,6 +541,7 @@ defineExpose({
339
541
  :style="overlayStyle"
340
542
  />
341
543
  <DialogContent
544
+ ref="contentRef"
342
545
  class="rs-drawer__content rs-motion-reduce"
343
546
  :class="contentClass"
344
547
  :style="contentStyle"
@@ -347,6 +550,16 @@ defineExpose({
347
550
  @focus-outside="onFocusOutside"
348
551
  @escape-key-down="onEscapeKeyDown"
349
552
  >
553
+ <button
554
+ v-if="enableResizable"
555
+ type="button"
556
+ class="rs-drawer__resize"
557
+ :aria-label="t('drawer.resize')"
558
+ :aria-orientation="isHorizontal ? 'vertical' : 'horizontal'"
559
+ :aria-valuenow="liveSizePx != null ? Math.round(liveSizePx) : undefined"
560
+ @pointerdown="onResizePointerDown"
561
+ @keydown="onResizeKeydown"
562
+ />
350
563
  <header v-if="title || description || showClose || $slots.header" class="rs-drawer__header">
351
564
  <slot name="header">
352
565
  <div class="rs-drawer__heading">
@@ -426,6 +639,67 @@ defineExpose({
426
639
  outline: none;
427
640
  will-change: transform;
428
641
  }
642
+ .rs-drawer__resize {
643
+ position: absolute;
644
+ z-index: 3;
645
+ padding: 0;
646
+ border: 0;
647
+ background: transparent;
648
+ appearance: none;
649
+ }
650
+ .rs-drawer__resize:focus-visible {
651
+ outline: 2px solid var(--rs-primary);
652
+ outline-offset: -2px;
653
+ }
654
+ .rs-drawer__content--right > .rs-drawer__resize,
655
+ .rs-drawer__content--left > .rs-drawer__resize {
656
+ top: 0;
657
+ bottom: 0;
658
+ width: 6px;
659
+ cursor: ew-resize;
660
+ }
661
+ .rs-drawer__content--right > .rs-drawer__resize {
662
+ left: 0;
663
+ }
664
+ .rs-drawer__content--left > .rs-drawer__resize {
665
+ right: 0;
666
+ }
667
+ .rs-drawer__content--top > .rs-drawer__resize,
668
+ .rs-drawer__content--bottom > .rs-drawer__resize {
669
+ left: 0;
670
+ right: 0;
671
+ height: 6px;
672
+ cursor: ns-resize;
673
+ }
674
+ .rs-drawer__content--top > .rs-drawer__resize {
675
+ bottom: 0;
676
+ }
677
+ .rs-drawer__content--bottom > .rs-drawer__resize {
678
+ top: 0;
679
+ }
680
+ .rs-drawer__content--right > .rs-drawer__resize:hover,
681
+ .rs-drawer__content--right > .rs-drawer__resize:focus-visible,
682
+ .rs-drawer__content--resizing.rs-drawer__content--right > .rs-drawer__resize {
683
+ background: linear-gradient(to right, var(--rs-primary) 0, var(--rs-primary) 2px, transparent 2px);
684
+ }
685
+ .rs-drawer__content--left > .rs-drawer__resize:hover,
686
+ .rs-drawer__content--left > .rs-drawer__resize:focus-visible,
687
+ .rs-drawer__content--resizing.rs-drawer__content--left > .rs-drawer__resize {
688
+ background: linear-gradient(to left, var(--rs-primary) 0, var(--rs-primary) 2px, transparent 2px);
689
+ }
690
+ .rs-drawer__content--top > .rs-drawer__resize:hover,
691
+ .rs-drawer__content--top > .rs-drawer__resize:focus-visible,
692
+ .rs-drawer__content--resizing.rs-drawer__content--top > .rs-drawer__resize {
693
+ background: linear-gradient(to top, var(--rs-primary) 0, var(--rs-primary) 2px, transparent 2px);
694
+ }
695
+ .rs-drawer__content--bottom > .rs-drawer__resize:hover,
696
+ .rs-drawer__content--bottom > .rs-drawer__resize:focus-visible,
697
+ .rs-drawer__content--resizing.rs-drawer__content--bottom > .rs-drawer__resize {
698
+ background: linear-gradient(to bottom, var(--rs-primary) 0, var(--rs-primary) 2px, transparent 2px);
699
+ }
700
+ .rs-drawer__content--resizing iframe {
701
+ pointer-events: none;
702
+ }
429
703
  .rs-drawer__content--contained {
430
704
  position: absolute;
431
705
  }
@@ -433,18 +707,23 @@ defineExpose({
433
707
  .rs-drawer__content--left {
434
708
  top: 0;
435
709
  bottom: 0;
710
+ height: 100%;
711
+ max-height: none;
436
712
  width: min(100vw, var(--rs-drawer-panel-size, 28rem));
713
+ transform: none;
437
714
  }
438
715
  .rs-drawer__content--contained:is(.rs-drawer__content--right, .rs-drawer__content--left) {
439
716
  width: min(100%, var(--rs-drawer-panel-size, 28rem));
440
717
  }
441
718
  .rs-drawer__content--right {
719
+ left: auto;
442
720
  right: 0;
443
721
  border-right: 0;
444
722
  border-radius: var(--rs-radius) 0 0 var(--rs-radius);
445
723
  }
446
724
  .rs-drawer__content--left {
447
725
  left: 0;
726
+ right: auto;
448
727
  border-left: 0;
449
728
  border-radius: 0 var(--rs-radius) var(--rs-radius) 0;
450
729
  }
@@ -29,6 +29,11 @@ const SIZE_INLINE: Record<Exclude<RsDrawerSize, 'full'>, string> = {
29
29
  lg: '36rem',
30
30
  }
31
31
 
32
+ /** 左右抽屉默认可拖最小宽度 */
33
+ export const RS_DRAWER_MIN_SIZE_PX = 256
34
+ /** 相对视口的默认可拖上限比例 */
35
+ export const RS_DRAWER_MAX_VIEWPORT_RATIO = 0.9
36
+
32
37
  /** 将 size 预设解析为 CSS 长度(full 由样式类处理) */
33
38
  export function resolveRsDrawerSizeCss(size: RsDrawerSize): string | undefined {
34
39
  if (size === 'full') return undefined
@@ -46,6 +51,37 @@ export function resolveRsDrawerDimensionCss(value: RsDrawerDimension | undefined
46
51
  return trimmed || undefined
47
52
  }
48
53
 
54
+ /**
55
+ * 将抽屉尺寸表达式解析为像素,供拖拽缩放夹紧。
56
+ * 无法解析时返回 fallbackPx。
57
+ */
58
+ export function resolveRsDrawerSizePx(
59
+ value: RsDrawerDimension | undefined,
60
+ fallbackPx: number,
61
+ rootFontPx: number,
62
+ viewportPx: number,
63
+ ): number {
64
+ if (value == null) return fallbackPx
65
+ if (typeof value === 'number') {
66
+ return Number.isFinite(value) && value > 0 ? value : fallbackPx
67
+ }
68
+ const trimmed = String(value).trim()
69
+ const n = Number.parseFloat(trimmed)
70
+ if (!Number.isFinite(n)) return fallbackPx
71
+ if (trimmed.endsWith('rem')) return n * rootFontPx
72
+ if (trimmed.endsWith('%') || trimmed.endsWith('vw') || trimmed.endsWith('vh')) {
73
+ return (n / 100) * viewportPx
74
+ }
75
+ return n
76
+ }
77
+
78
+ /** 将拖拽得到的尺寸夹在 min/max 之间并取整。 */
79
+ export function clampRsDrawerSize(px: number, minPx: number, maxPx: number): number {
80
+ const lo = Math.min(minPx, maxPx)
81
+ const hi = Math.max(minPx, maxPx)
82
+ return Math.round(Math.min(hi, Math.max(lo, px)))
83
+ }
84
+
49
85
  export function runRsDrawerBeforeClose(
50
86
  beforeClose: RsDrawerBeforeClose | undefined,
51
87
  reason: RsDrawerCloseReason,
@@ -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 { RsSelectOptions } from '../select-utils'
9
+ import type { RsSelectModelValue, RsSelectOptions } from '../select-utils'
10
10
  import type { RsTableColumnEditorOptionsResolved, RsTableCellValueType } from '../table-utils'
11
11
  import {
12
12
  applyFocusMode,
@@ -223,9 +223,17 @@ function onKeydown(event: KeyboardEvent): void {
223
223
  }
224
224
  }
225
225
 
226
- function onSelectUpdate(value: string | string[]): void {
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)
227
235
  const empty =
228
- value == null || value === '' || (Array.isArray(value) && value.length === 0)
236
+ tokens === '' || (Array.isArray(tokens) && tokens.length === 0)
229
237
  // 表格内下拉默认不可清除:仅选择;显式 clearable 时才允许清空
230
238
  if (empty) {
231
239
  if (!props.editorOptions?.clearable) return
@@ -235,12 +243,12 @@ function onSelectUpdate(value: string | string[]): void {
235
243
  return
236
244
  }
237
245
  if (isMultipleSelect.value) {
238
- const list = Array.isArray(value) ? value.map(String) : [String(value)]
246
+ const list = Array.isArray(tokens) ? tokens : [tokens]
239
247
  model.value = joinMultiSelectDraft(list)
240
248
  // 多选不在每次勾选时提交,等下拉关闭
241
249
  return
242
250
  }
243
- model.value = Array.isArray(value) ? (value[0] ?? '') : String(value ?? '')
251
+ model.value = Array.isArray(tokens) ? (tokens[0] ?? '') : tokens
244
252
  if (shouldCommitOnChange()) emit('commit')
245
253
  }
246
254
 
@@ -113,7 +113,9 @@ export function useRsTableCore<T extends RsTableRowData>(options: UseRsTableCore
113
113
  emit: options.emit,
114
114
  })
115
115
 
116
- const resolvedSize = computed(() => resolveTableSize(options.compact(), options.size()))
116
+ const resolvedSize = computed(() =>
117
+ resolveTableSize(options.compact() ?? false, options.size() ?? 'md'),
118
+ )
117
119
 
118
120
  const engine = useRsTableEngine<T>({
119
121
  data: () => options.data(),
@@ -35,8 +35,16 @@ export function useRsTableHeadless<T extends RsTableRowData>(
35
35
  ) {
36
36
  const getColumns = readRefOrGetter(options.columns)
37
37
  const getData = readRefOrGetter(options.data)
38
- const getRowKey = () =>
39
- typeof options.rowKey === 'function' ? options.rowKey() : options.rowKey
38
+ const getRowKey = (): RsTableRowKey<T> | undefined => {
39
+ const key = options.rowKey
40
+ if (key == null) return undefined
41
+ if (typeof key === 'string') return key
42
+ // 无参函数视为配置 getter;带 row 参数的视为字段访问器
43
+ if (typeof key === 'function' && key.length === 0) {
44
+ return (key as () => RsTableRowKey<T> | undefined)()
45
+ }
46
+ return key as RsTableRowKey<T>
47
+ }
40
48
  const getFeatures = () =>
41
49
  typeof options.features === 'function' ? options.features() : options.features
42
50
 
@@ -1,4 +1,5 @@
1
1
  import type { Component } from 'vue'
2
+ import * as LucideVue from '@lucide/vue'
2
3
  import { ApiIcon } from './custom/api'
3
4
  import { FtpIcon } from './custom/ftp'
4
5
  import { MongodbIcon } from './custom/mongodb'
@@ -32,36 +33,45 @@ const customIconMap = new Map<string, Component>([
32
33
  ['sqlserver', SqlserverIcon],
33
34
  ])
34
35
 
35
- /** Vite 构建时收集全部 Lucide 图标,运行时按名称 O(1) 查找 */
36
- const iconModules = import.meta.glob<{ default: Component }>(
37
- '../../node_modules/@lucide/vue/dist/esm/icons/*.mjs',
38
- { eager: true },
39
- )
36
+ const lucideExports = LucideVue as Record<string, unknown>
37
+ const SKIP_LUCIDE_EXPORTS = new Set(['default', 'createLucideIcon'])
40
38
 
41
- const iconMap = new Map<string, Component>(customIconMap)
42
- const iconPathPattern = /[/\\]icons[/\\]([^/\\]+)\.mjs$/
39
+ function isVueComponent(value: unknown): value is Component {
40
+ return typeof value === 'function' || (typeof value === 'object' && value !== null)
41
+ }
42
+
43
+ /** RsIcon 使用 kebab-case(如 trash-2),@lucide/vue 导出名是 PascalCase(Trash2) */
44
+ function kebabToPascal(name: string): string {
45
+ return name
46
+ .split('-')
47
+ .filter(Boolean)
48
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
49
+ .join('')
50
+ }
43
51
 
44
- /** Lucide 更名兼容:旧 kebab 新图标文件名 */
52
+ function getLucideComponent(name: string): Component | undefined {
53
+ const pascal = kebabToPascal(name)
54
+ if (SKIP_LUCIDE_EXPORTS.has(pascal)) return undefined
55
+ const value = lucideExports[pascal]
56
+ return isVueComponent(value) ? value : undefined
57
+ }
58
+
59
+ /** Lucide 更名兼容:旧 kebab 名 → 当前导出名对应的 kebab */
45
60
  const iconAliases = new Map<string, string>([
46
61
  ['code-2', 'code-xml'],
47
- // Lucide v0.5xx:circle-help / help-circle → circle-question-mark
48
62
  ['circle-help', 'circle-question-mark'],
49
63
  ['help-circle', 'circle-question-mark'],
50
64
  ])
51
65
 
52
- for (const [path, mod] of Object.entries(iconModules)) {
53
- const match = iconPathPattern.exec(path)
54
- if (match) iconMap.set(match[1], mod.default)
55
- }
56
-
57
- const iconCache = new Map<string, Component>()
58
-
59
66
  function resolveIconName(name: string): string {
60
67
  return iconAliases.get(name) ?? name
61
68
  }
62
69
 
70
+ const iconCache = new Map<string, Component>()
71
+
63
72
  export function isRsIconName(name: string): boolean {
64
- return iconMap.has(resolveIconName(name))
73
+ const resolvedName = resolveIconName(name)
74
+ return customIconMap.has(resolvedName) || getLucideComponent(resolvedName) !== undefined
65
75
  }
66
76
 
67
77
  export function resolveLucideIcon(name: string): Component | undefined {
@@ -69,10 +79,14 @@ export function resolveLucideIcon(name: string): Component | undefined {
69
79
  const cached = iconCache.get(resolvedName)
70
80
  if (cached) return cached
71
81
 
72
- const icon = iconMap.get(resolvedName)
82
+ const icon = customIconMap.get(resolvedName) ?? getLucideComponent(resolvedName)
73
83
  if (icon) iconCache.set(resolvedName, icon)
74
84
  return icon
75
85
  }
76
86
 
77
- /** Lucide 库中可用图标数量 */
78
- export const lucideIconCount = iconMap.size
87
+ /** 可解析的图标数量(自定义 + Lucide 命名导出) */
88
+ export const lucideIconCount =
89
+ customIconMap.size +
90
+ Object.keys(lucideExports).filter(
91
+ (key) => !SKIP_LUCIDE_EXPORTS.has(key) && isVueComponent(lucideExports[key]),
92
+ ).length
package/src/index.ts CHANGED
@@ -103,9 +103,13 @@ export type {
103
103
  RsDrawerSize,
104
104
  } from './components/drawer-utils'
105
105
  export {
106
+ clampRsDrawerSize,
106
107
  resolveDrawerOverlayStyle,
107
108
  resolveRsDrawerDimensionCss,
108
109
  resolveRsDrawerSizeCss,
110
+ resolveRsDrawerSizePx,
111
+ RS_DRAWER_MAX_VIEWPORT_RATIO,
112
+ RS_DRAWER_MIN_SIZE_PX,
109
113
  runRsDrawerBeforeClose,
110
114
  } from './components/drawer-utils'
111
115
  export { default as RsForm } from './components/RsForm.vue'
@@ -138,6 +138,7 @@ export const zhCN: RsLocaleMessages = {
138
138
  'tree.drag': '拖拽排序',
139
139
  'split.label': '面板分割',
140
140
  'split.resize': '拖动调整面板尺寸',
141
+ 'drawer.resize': '拖动调整抽屉尺寸',
141
142
  'terminal.copy': '复制',
142
143
  'terminal.paste': '粘贴',
143
144
  'terminal.selectAll': '全选',
@@ -336,6 +337,7 @@ export const enUS: RsLocaleMessages = {
336
337
  'tree.drag': 'Drag to reorder',
337
338
  'split.label': 'Split panes',
338
339
  'split.resize': 'Drag to resize panes',
340
+ 'drawer.resize': 'Drag to resize the drawer',
339
341
  'terminal.copy': 'Copy',
340
342
  'terminal.paste': 'Paste',
341
343
  'terminal.selectAll': 'Select All',