@neatui/vue 4.8.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/MIGRATION-5.0.md +66 -0
  2. package/README.md +3 -0
  3. package/package.json +16 -8
  4. package/src/components/basic/IFollowView.vue +1 -1
  5. package/src/components/basic/LayerView/Layer.vue +0 -2
  6. package/src/components/display/Avatar.vue +124 -1
  7. package/src/components/display/Tree.vue +10 -4
  8. package/src/components/display/TreeView.vue +12 -5
  9. package/src/components/display/index.ts +2 -1
  10. package/src/components/form/Cascader.vue +1 -1
  11. package/src/components/form/DateRangeView.vue +6 -6
  12. package/src/components/form/Gallery.vue +50 -0
  13. package/src/components/form/Input.vue +5 -2
  14. package/src/components/form/PageSelect.vue +3 -2
  15. package/src/components/form/Radio.vue +27 -0
  16. package/src/components/form/RichTextInput.vue +99 -0
  17. package/src/components/form/SearchMoreSelect.vue +3 -2
  18. package/src/components/form/Select.vue +4 -4
  19. package/src/components/form/SelectPicker.vue +3 -2
  20. package/src/components/form/Textarea.vue +169 -57
  21. package/src/components/form/TimePicker.vue +43 -7
  22. package/src/components/form/Upload.vue +1 -1
  23. package/src/components/form/index.ts +7 -0
  24. package/src/components/loader/FormLoader/FormLoader.vue +908 -349
  25. package/src/components/loader/FormLoader/FormRender.vue +409 -0
  26. package/src/components/loader/TableLoader/TableLoader.vue +338 -32
  27. package/src/components/loader/TableLoader/virtual.ts +27 -0
  28. package/src/components/loader/index.ts +2 -0
  29. package/src/index.ts +2 -0
  30. package/.prettierrc +0 -12
  31. package/rollup.config.js +0 -75
  32. package/tsconfig.json +0 -23
@@ -1,14 +1,30 @@
1
1
  <template>
2
- <div class="full" ui-scroll=":x :y">
2
+ <div
3
+ ref="scrollRef"
4
+ class="full"
5
+ ui-scroll=":x :y"
6
+ :scroll-side-l="scrollSide.l"
7
+ :scroll-side-r="scrollSide.r"
8
+ :style="virtualEnabled ? { overflowAnchor: 'none' } : undefined"
9
+ @scroll.passive="onScroll"
10
+ >
3
11
  <!-- 桌面端 -->
4
- <table ui-hide="<pad" :ui-table="theme" ref="tableRef">
12
+ <table v-if="showTable" :ui-hide="layout === 'auto' ? '<pad' : undefined" :ui-table="theme" ref="tableRef">
5
13
  <thead class="table-head-fixed">
6
14
  <tr>
7
- <th v-if="selected" min-width col-fixed="1">
15
+ <th v-if="selected" :ref="(el: any) => bindFixedCell(el, '__select')" min-width col-fixed="1" :col-fixed-side="fixedSide('__select')" :style="fixedStyle('__select')">
8
16
  <label class="mb-ss" ui-form="@a type:checkbox"><input type="checkbox" v-model="all" /><span></span></label>
9
17
  </th>
10
18
  <template v-for="(col, idx) in state.table" :key="idx">
11
- <th v-if="!col.__close" v-bind="colSizeAttrs(col)" :col-fixed="col.fixed" :col-fixed-side="col.fixed || 'l'" :data-field="col.field">
19
+ <th
20
+ v-if="!col.__close"
21
+ :ref="col.fixed ? (el: any) => bindFixedCell(el, col.field) : undefined"
22
+ v-bind="colSizeAttrs(col)"
23
+ :col-fixed="col.fixed"
24
+ :col-fixed-side="fixedSide(col.field)"
25
+ :style="fixedStyle(col.field)"
26
+ :data-field="col.field"
27
+ >
12
28
  <slot :name="`thead-${col.field}`" :col="col" :idx="idx">
13
29
  <div :class="!slots.operate && idx === state.__last ? 'ar' : ''" v-bind="col.thead">
14
30
  <span class="ux-none" ui-flex="row rm" v-if="col.order === 0 || col.order === 1 || col.order === 2" @click="fSetOrder(col)">
@@ -43,7 +59,14 @@
43
59
  </slot>
44
60
  </th>
45
61
  </template>
46
- <th v-if="slots.operate" col-fixed="r" class="fs-sl nowrap ar">
62
+ <th
63
+ v-if="slots.operate"
64
+ :ref="(el: any) => bindFixedCell(el, '__operate')"
65
+ col-fixed="r"
66
+ :col-fixed-side="fixedSide('__operate')"
67
+ :style="fixedStyle('__operate')"
68
+ class="fs-sl nowrap ar"
69
+ >
47
70
  <div ui-flex="row rm">
48
71
  <span>{{ words['operate'] || '操作' }}</span>
49
72
  <IFollowView v-if="tools" class="lh-xs" tipBoxClass="n-no">
@@ -74,8 +97,17 @@
74
97
  </thead>
75
98
  <tbody :ui-tbody-load="state.load">
76
99
  <template v-if="lists.length">
77
- <tr v-for="(item, idx) in lists" :key="idx" v-bind="rows.attrs" v-on="event(rows.event, item, 'td')">
78
- <td v-if="selected" col-fixed="1" min-width>
100
+ <tr v-if="virtualSlice.padTop" class="table-virtual-pad" aria-hidden="true">
101
+ <td :colspan="columnCount" :style="virtualPadStyle(virtualSlice.padTop)"></td>
102
+ </tr>
103
+ <tr
104
+ v-for="{ item, idx } in virtualSlice.rows"
105
+ :key="rowKeyOf(item, idx)"
106
+ :ref="virtualEnabled ? (el: any) => bindVirtualRow(el, item) : undefined"
107
+ v-bind="rows.attrs"
108
+ v-on="event(rows.event, item, 'td')"
109
+ >
110
+ <td v-if="selected" col-fixed="1" min-width :col-fixed-side="fixedSide('__select')" :style="fixedStyle('__select')">
79
111
  <label class="mb-ss" ui-form="@a type:checkbox"><input type="checkbox" v-model="item.__selected" /><span></span></label>
80
112
  </td>
81
113
  <template v-for="(col, cid) in state.table" :key="cid">
@@ -84,23 +116,29 @@
84
116
  :class="`${!slots.operate && cid === state.__last ? 'ar' : ''}`"
85
117
  v-bind="colSizeAttrs(col)"
86
118
  :col-fixed="col.fixed"
87
- :col-fixed-side="col.fixed || 'l'"
119
+ :col-fixed-side="fixedSide(col.field)"
120
+ :style="fixedStyle(col.field)"
88
121
  :data-field="col.field"
89
122
  >
90
123
  <slot :name="col.field" :col="col" :item="item" :idx="idx"><TableRender :node="col" :data="item" /></slot>
91
124
  </td>
92
125
  </template>
93
- <td v-if="slots.operate" col-fixed="r" class="ar"><slot name="operate" :item="item" :idx="idx"></slot></td>
126
+ <td v-if="slots.operate" col-fixed="r" class="ar" :col-fixed-side="fixedSide('__operate')" :style="fixedStyle('__operate')">
127
+ <slot name="operate" :item="item" :idx="idx"></slot>
128
+ </td>
129
+ </tr>
130
+ <tr v-if="virtualSlice.padBottom" class="table-virtual-pad" aria-hidden="true">
131
+ <td :colspan="columnCount" :style="virtualPadStyle(virtualSlice.padBottom)"></td>
94
132
  </tr>
95
133
  </template>
96
134
  </tbody>
97
135
  </table>
98
136
  <!-- 移动端 -->
99
- <div ui-hide=">mob">
137
+ <div v-if="showList" :ui-hide="layout === 'auto' ? '>mob' : undefined">
100
138
  <ul>
101
139
  <li class="bg-fore r-sm my-sl nx-ms ny-sl" ui-flex="row xm">
102
140
  <div>
103
- <label class="mb-ss" ui-form="@a type:checkbox"><input type="checkbox" v-model="all" /><span>全选</span></label>
141
+ <label v-if="selected" class="mb-ss" ui-form="@a type:checkbox"><input type="checkbox" v-model="all" /><span>全选</span></label>
104
142
  </div>
105
143
  <div>
106
144
  <IFollowView>
@@ -120,7 +158,7 @@
120
158
  </IFollowView>
121
159
  </div>
122
160
  </li>
123
- <li v-for="(item, idx) in lists" :key="idx" class="bg-fore r-sm my-sl n-ms">
161
+ <li v-for="(item, idx) in lists" :key="rowKeyOf(item, idx)" class="bg-fore r-sm my-sl n-ms">
124
162
  <table>
125
163
  <tbody>
126
164
  <template v-for="(col, idx) in state.table" :key="idx">
@@ -146,12 +184,12 @@
146
184
  </div>
147
185
  </template>
148
186
  <script setup lang="ts">
149
- import { computed, onMounted, reactive, watch, ref, nextTick, onUnmounted, useSlots } from 'vue';
187
+ import { computed, nextTick, onMounted, reactive, watch, ref, onUnmounted, useSlots, getCurrentInstance } from 'vue';
150
188
  import { useRoute, useRouter } from 'vue-router';
151
189
  import IDraggable from '../../basic/IDraggable.vue';
152
190
  import { IFollowView, Icon } from '../../basic';
153
- import { itable } from '@fekit/itable';
154
191
  import TableRender from './TableRender.vue';
192
+ import { firstRowEndingAfter, virtualOffsets, virtualRange } from './virtual';
155
193
  import { isFunction, isObject, hasOwnProp, deepcopy, env } from '@fekit/utils';
156
194
 
157
195
  const route = useRoute();
@@ -166,7 +204,7 @@
166
204
  label: string | (() => string);
167
205
  field: string | (() => string);
168
206
  width?: 'min' | 'max' | string | (() => string);
169
- fixed?: string | (() => string);
207
+ fixed?: string | number | boolean | (() => string);
170
208
  paths?: string | (() => string);
171
209
  thead?: Record<string, any> | ((data: any) => Record<string, any>);
172
210
  tbody?: Record<string, any> | ((data: any) => Record<string, any>);
@@ -185,7 +223,7 @@
185
223
  __order?: 0 | 1 | 2 | number;
186
224
  __sort?: number;
187
225
  close?: boolean | number | 'always';
188
- fixed?: string | number;
226
+ fixed?: string | number | boolean;
189
227
  thead?: any;
190
228
  field: any;
191
229
  label: any;
@@ -202,6 +240,11 @@
202
240
  event?: { [key: string]: any };
203
241
  };
204
242
  allSelected?: boolean;
243
+ layout?: 'auto' | 'table' | 'list';
244
+ /** 行的稳定标识:字段名或函数,默认用下标 */
245
+ rowKey?: string | ((item: any, idx: number) => string | number);
246
+ /** 虚拟滚动(仅表格形态):只渲染可视区附近的行,需要容器限定高度 */
247
+ virtual?: boolean | { estimate?: number; overscan?: number };
205
248
  }
206
249
  // 外部入参
207
250
  const props: any = withDefaults(defineProps<Props>(), {
@@ -213,6 +256,32 @@
213
256
  rows: () => ({ attrs: {}, event: {} }),
214
257
  theme: '@b sz:l',
215
258
  allSelected: false,
259
+ layout: 'auto',
260
+ virtual: false,
261
+ });
262
+
263
+ /**
264
+ * 桌面表格与移动列表只渲染一套:两套都渲染再用 CSS 隐藏,
265
+ * 隐藏的那套照样要为每行每列创建组件和 DOM,大表开销翻倍。
266
+ * auto 模式下,服务端渲染和客户端激活时仍两套都出(交给 ui-hide),保证结构一致;
267
+ * 挂载后按与 ui-hide 相同的断点(<800px 为移动端)只保留一套。
268
+ */
269
+ const mobileQuery = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(max-width: 799px)') : null;
270
+ // 激活(hydration)时组件的 vnode 已指向服务端 DOM,这时不能提前裁掉任何一套
271
+ const hydrating = !!getCurrentInstance()?.vnode.el;
272
+ const isMobile = ref<boolean | null>(mobileQuery && !hydrating ? mobileQuery.matches : null);
273
+ const onMobileChange = (event: MediaQueryListEvent) => {
274
+ isMobile.value = event.matches;
275
+ };
276
+ const showTable = computed(() => props.layout === 'table' || (props.layout !== 'list' && isMobile.value !== true));
277
+ const showList = computed(() => props.layout === 'list' || (props.layout !== 'table' && isMobile.value !== false));
278
+ onMounted(() => {
279
+ if (!mobileQuery) return;
280
+ isMobile.value = mobileQuery.matches;
281
+ mobileQuery.addEventListener?.('change', onMobileChange);
282
+ });
283
+ onUnmounted(() => {
284
+ mobileQuery?.removeEventListener?.('change', onMobileChange);
216
285
  });
217
286
 
218
287
  // 内部数据
@@ -233,7 +302,7 @@
233
302
  const name = computed(() => 'confs' + route.path?.replace(/\//g, '_')) || '';
234
303
 
235
304
  const colSizeAttrs = (col: TableColumn) => {
236
- const raw = isFunction(col.width) ? col.width() : col.width;
305
+ const raw = typeof col.width === 'function' ? col.width() : col.width;
237
306
  const width = raw == null ? '' : String(raw).trim();
238
307
  const attrs: Record<string, any> = {};
239
308
  if (width === 'min' || (!width && col.fixed)) attrs['min-width'] = '';
@@ -242,23 +311,27 @@
242
311
  return attrs;
243
312
  };
244
313
 
245
- // 全选状态
314
+ // 全选状态。未开启多选时不读也不写 __selected,避免往业务数据里塞字段、对每行建依赖
246
315
  const all = computed({
247
316
  get() {
248
- return props.lists.every((item: any) => item.__selected === true);
317
+ if (!props.selected) return false;
318
+ return props.lists.length > 0 && props.lists.every((item: any) => item.__selected === true);
249
319
  },
250
320
  set(value: boolean) {
321
+ if (!props.selected) return;
251
322
  props.lists.forEach((item: any) => {
252
323
  item.__selected = value;
253
324
  });
254
325
  },
255
326
  });
256
327
 
257
- // 向内同步
328
+ // 向内同步:只在与当前状态不一致时改写。
329
+ // 挂载时 allSelected=false 不能把预选的行清掉;
330
+ // v-model:allSelected 下取消单行会回传 false,也不能借此清空其余行
258
331
  watch(
259
332
  () => props.allSelected,
260
333
  (v) => {
261
- all.value = v;
334
+ if (props.selected && v !== all.value) all.value = v;
262
335
  },
263
336
  { immediate: true },
264
337
  );
@@ -278,6 +351,9 @@
278
351
  state.__last = 0;
279
352
  state.table = deepcopy(props.table || [])
280
353
  ?.map((col: TableColumn, idx: number) => {
354
+ // fixed: true 等同左固定(否则排序时不会被归到左侧)
355
+ if (col.fixed === true) col.fixed = 'l';
356
+
281
357
  // 标题处理
282
358
  if (isFunction(col.label)) {
283
359
  col.label = col.label(props.lists);
@@ -356,24 +432,254 @@
356
432
  );
357
433
 
358
434
  const tableRef: any = ref(null);
359
- onMounted(() => {
360
- nextTick(() => {
361
- if (tableRef.value) {
362
- itable.listen(tableRef.value);
363
- }
435
+ const scrollRef = ref<HTMLElement | null>(null);
436
+
437
+ /**
438
+ * 固定列:sticky 由 CSS 负责,这里只算多个固定列叠放时的 left / right 偏移,
439
+ * 并只给紧挨滚动区的那一列(左侧最后一列、右侧第一列)标 col-fixed-side 显示阴影。
440
+ * 列宽取自 ResizeObserver 条目,不调用 getBoundingClientRect,不强制重排。
441
+ * 原先由 @fekit/itable 完成:全局单例(多个表格互相覆盖)、按列位置而非 fixed 值判断左右,
442
+ * 并在回调里逐列 getBoundingClientRect。
443
+ */
444
+ const fixedWidths = reactive<Record<string, number>>({});
445
+ const fixedCells = new Map<string, Element>();
446
+ const fixedKeyOf = new WeakMap<Element, string>();
447
+ let fixedObserver: ResizeObserver | undefined;
448
+ const bindFixedCell = (el: Element | null, key: string) => {
449
+ const previous = fixedCells.get(key);
450
+ if (previous === el) return;
451
+ if (previous) {
452
+ fixedObserver?.unobserve(previous);
453
+ fixedCells.delete(key);
454
+ }
455
+ if (!el || typeof ResizeObserver === 'undefined') return;
456
+ fixedObserver ||= new ResizeObserver((entries) => {
457
+ entries.forEach((entry) => {
458
+ const cellKey = fixedKeyOf.get(entry.target);
459
+ if (!cellKey) return;
460
+ const box = entry.borderBoxSize?.[0];
461
+ const width = box ? box.inlineSize : (entry.target as HTMLElement).offsetWidth;
462
+ if (Math.abs((fixedWidths[cellKey] || 0) - width) > 0.5) fixedWidths[cellKey] = width;
463
+ });
464
+ updateScrollSide();
465
+ });
466
+ fixedKeyOf.set(el, key);
467
+ fixedCells.set(key, el);
468
+ fixedObserver.observe(el);
469
+ };
470
+ const fixedLayout = computed(() => {
471
+ const cells: Array<{ key: string; side: 'l' | 'r' | '' }> = [];
472
+ if (props.selected) cells.push({ key: '__select', side: 'l' });
473
+ state.table.forEach((col) => {
474
+ if (col.__close) return;
475
+ cells.push({ key: col.field, side: col.fixed ? (col.fixed === 'r' ? 'r' : 'l') : '' });
364
476
  });
477
+ if (slots.operate) cells.push({ key: '__operate', side: 'r' });
478
+ const offsets: Record<string, { left?: string; right?: string }> = {};
479
+ const sides: Record<string, 'l' | 'r'> = {};
480
+ let left = 0;
481
+ let lastLeft = '';
482
+ cells.forEach(({ key, side }) => {
483
+ if (side !== 'l') return;
484
+ offsets[key] = { left: `${left}px` };
485
+ left += fixedWidths[key] || 0;
486
+ lastLeft = key;
487
+ });
488
+ let right = 0;
489
+ let firstRight = '';
490
+ [...cells].reverse().forEach(({ key, side }) => {
491
+ if (side !== 'r') return;
492
+ offsets[key] = { right: `${right}px` };
493
+ right += fixedWidths[key] || 0;
494
+ firstRight = key;
495
+ });
496
+ if (lastLeft) sides[lastLeft] = 'l';
497
+ if (firstRight) sides[firstRight] = 'r';
498
+ return { offsets, sides };
365
499
  });
500
+ const fixedStyle = (key: string) => fixedLayout.value.offsets[key];
501
+ const fixedSide = (key: string) => fixedLayout.value.sides[key];
502
+
503
+ // 滚到最左 / 最右时隐藏对应一侧固定列的阴影(样式见 [scroll-side-l / r])
504
+ const scrollSide = reactive({ l: '1', r: '1' });
505
+ let sideFrame = 0;
506
+ const updateScrollSide = () => {
507
+ const el = scrollRef.value;
508
+ if (!el) return;
509
+ const overflowing = el.scrollWidth > el.clientWidth;
510
+ const l = !overflowing || el.scrollLeft <= 0 ? '1' : '0';
511
+ const r = !overflowing || el.scrollLeft + el.clientWidth >= el.scrollWidth - 1 ? '1' : '0';
512
+ if (scrollSide.l !== l) scrollSide.l = l;
513
+ if (scrollSide.r !== r) scrollSide.r = r;
514
+ };
515
+ const onScroll = () => {
516
+ onVirtualScroll();
517
+ if (sideFrame) return;
518
+ sideFrame = requestAnimationFrame(() => {
519
+ sideFrame = 0;
520
+ updateScrollSide();
521
+ });
522
+ };
523
+ // 容器或表格尺寸变化(窗口缩放、列显隐、数据变化)都可能改变是否出现横向滚动
524
+ let sideObserver: ResizeObserver | undefined;
366
525
  watch(
367
- () => state.table,
368
- () => {
369
- nextTick(() => {
370
- itable.listen(tableRef.value);
526
+ [scrollRef, tableRef],
527
+ ([area, table]) => {
528
+ sideObserver?.disconnect();
529
+ if (typeof ResizeObserver === 'undefined' || !area) return;
530
+ sideObserver ||= new ResizeObserver(updateScrollSide);
531
+ sideObserver.observe(area);
532
+ if (table) sideObserver.observe(table);
533
+ },
534
+ { flush: 'post', immediate: true },
535
+ );
536
+ onUnmounted(() => {
537
+ if (sideFrame) cancelAnimationFrame(sideFrame);
538
+ fixedObserver?.disconnect();
539
+ sideObserver?.disconnect();
540
+ fixedCells.clear();
541
+ });
542
+
543
+ // 行标识
544
+ const rowKeyOf = (item: any, idx: number) => {
545
+ const key = props.rowKey;
546
+ if (isFunction(key)) return key(item, idx);
547
+ if (key && item && typeof item === 'object') return item[key] ?? idx;
548
+ return idx;
549
+ };
550
+
551
+ /**
552
+ * 虚拟滚动:上下用占位行撑出滚动高度,只渲染可视区附近的行。
553
+ * 行高各不相同(单元格可能换行、放表单控件),按行对象缓存实测高度,
554
+ * 没测过的按已测平均值估算;行对象不变则删行、排序后缓存仍有效。
555
+ */
556
+ const virtualEnabled = computed(() => !!props.virtual);
557
+ const virtualEstimate = computed(() => (isObject(props.virtual) && props.virtual.estimate) || 48);
558
+ const virtualOverscan = computed(() => (isObject(props.virtual) && props.virtual.overscan) || 600);
559
+ const scrollTop = ref(0);
560
+ // 拿到容器真实高度前先按一屏估算
561
+ const viewportHeight = ref(800);
562
+ const rowHeights = new WeakMap<object, number>();
563
+ const rowHeightsVersion = ref(0);
564
+ let measuredTotal = 0;
565
+ let measuredCount = 0;
566
+ const estimatedRowHeight = () => (measuredCount ? measuredTotal / measuredCount : virtualEstimate.value);
567
+
568
+ const columnCount = computed(
569
+ () => (props.selected ? 1 : 0) + state.table.filter((col) => !col.__close).length + (slots.operate ? 1 : 0),
570
+ );
571
+ const virtualPadStyle = (height: number) => ({ height: `${height}px`, padding: 0, border: 0, background: 'transparent' });
572
+
573
+ const virtualOffsetsAll = computed(() => {
574
+ void rowHeightsVersion.value;
575
+ const estimate = estimatedRowHeight();
576
+ return virtualOffsets(props.lists.map((item: any) => (item && typeof item === 'object' && rowHeights.get(item)) || estimate));
577
+ });
578
+ const virtualSlice = computed(() => {
579
+ if (!virtualEnabled.value) {
580
+ return { rows: props.lists.map((item: any, idx: number) => ({ item, idx })), padTop: 0, padBottom: 0 };
581
+ }
582
+ const offsets = virtualOffsetsAll.value;
583
+ const { start, end } = virtualRange(offsets, scrollTop.value, viewportHeight.value, virtualOverscan.value);
584
+ const rows = [];
585
+ for (let idx = start; idx < end; idx += 1) rows.push({ item: props.lists[idx], idx });
586
+ return { rows, padTop: offsets[start] || 0, padBottom: Math.max(0, offsets[offsets.length - 1] - (offsets[end] || 0)) };
587
+ });
588
+
589
+ let scrollFrame = 0;
590
+ const onVirtualScroll = () => {
591
+ if (!virtualEnabled.value || scrollFrame) return;
592
+ scrollFrame = requestAnimationFrame(() => {
593
+ scrollFrame = 0;
594
+ if (scrollRef.value) scrollTop.value = scrollRef.value.scrollTop;
595
+ });
596
+ };
597
+
598
+ // 行高取自 ResizeObserver 条目(布局之后回调,不强制重排)
599
+ const rowItems = new WeakMap<Element, object>();
600
+ let rowObserver: ResizeObserver | undefined;
601
+ const onRowResize = (entries: ResizeObserverEntry[]) => {
602
+ if (!virtualEnabled.value) return;
603
+ const before = virtualOffsetsAll.value;
604
+ let changed = false;
605
+ for (const entry of entries) {
606
+ const item = rowItems.get(entry.target);
607
+ if (!item) continue;
608
+ const box = entry.borderBoxSize?.[0];
609
+ const height = box ? box.blockSize : entry.contentRect.height;
610
+ if (!height) continue;
611
+ const previous = rowHeights.get(item);
612
+ if (previous !== undefined && Math.abs(previous - height) < 0.5) continue;
613
+ if (previous === undefined) measuredCount += 1;
614
+ measuredTotal += height - (previous ?? 0);
615
+ rowHeights.set(item, height);
616
+ changed = true;
617
+ }
618
+ if (!changed) return;
619
+ rowHeightsVersion.value += 1;
620
+ // 实测值和平均估算都会让上方偏移变化,按视口顶部那一行锚定补偿滚动位置,避免正在看的内容跳动
621
+ const el = scrollRef.value;
622
+ if (!el || el.scrollTop <= 0) return;
623
+ const anchor = firstRowEndingAfter(before, el.scrollTop);
624
+ const shift = virtualOffsetsAll.value[anchor] - before[anchor];
625
+ if (Math.abs(shift) < 0.5) return;
626
+ // 等占位行按新高度渲染后再改 scrollTop,否则可能被旧的滚动高度截断
627
+ nextTick(() => {
628
+ el.scrollTop += shift;
629
+ scrollTop.value = el.scrollTop;
630
+ });
631
+ };
632
+ // 按元素记录行对象:未设 rowKey 时同一个 <tr> 会被复用给别的行,按行对象反查会漏掉仍在显示的元素
633
+ const observedRows = new Set<Element>();
634
+ let pruneQueued = false;
635
+ const pruneRows = () => {
636
+ pruneQueued = false;
637
+ // 关掉虚拟滚动时 <tr> 还在文档里但不再绑定,也要一并释放
638
+ for (const row of observedRows) {
639
+ if (row.isConnected && virtualEnabled.value) continue;
640
+ rowObserver?.unobserve(row);
641
+ rowItems.delete(row);
642
+ observedRows.delete(row);
643
+ }
644
+ };
645
+ const bindVirtualRow = (el: Element | null, item: any) => {
646
+ // 卸载时 ref 回调拿到 null,元素此刻还在文档里,等更新完再清理
647
+ if (!el) {
648
+ if (!pruneQueued) {
649
+ pruneQueued = true;
650
+ nextTick(pruneRows);
651
+ }
652
+ return;
653
+ }
654
+ if (!item || typeof item !== 'object' || rowItems.get(el) === item || typeof ResizeObserver === 'undefined') return;
655
+ rowObserver ||= new ResizeObserver(onRowResize);
656
+ rowItems.set(el, item);
657
+ // 重新 observe 会立即回调一次,换了行对象的元素也能测到新行的高度
658
+ rowObserver.unobserve(el);
659
+ rowObserver.observe(el);
660
+ observedRows.add(el);
661
+ };
662
+
663
+ let viewportObserver: ResizeObserver | undefined;
664
+ watch(
665
+ [virtualEnabled, scrollRef],
666
+ ([enabled, el]) => {
667
+ viewportObserver?.disconnect();
668
+ viewportObserver = undefined;
669
+ if (!enabled || !el || typeof ResizeObserver === 'undefined') return;
670
+ scrollTop.value = el.scrollTop;
671
+ viewportObserver = new ResizeObserver(([entry]) => {
672
+ if (entry?.contentRect.height) viewportHeight.value = entry.contentRect.height;
371
673
  });
674
+ viewportObserver.observe(el);
372
675
  },
373
- { deep: true },
676
+ { flush: 'post', immediate: true },
374
677
  );
375
678
  onUnmounted(() => {
376
- itable.remove();
679
+ if (scrollFrame) cancelAnimationFrame(scrollFrame);
680
+ viewportObserver?.disconnect();
681
+ rowObserver?.disconnect();
682
+ observedRows.clear();
377
683
  });
378
684
 
379
685
  // 设置字段升降序
@@ -0,0 +1,27 @@
1
+ /** 每行顶部的累计偏移,末尾多一项为总高度 */
2
+ export const virtualOffsets = (heights: ArrayLike<number>) => {
3
+ const offsets = new Float64Array(heights.length + 1);
4
+ for (let i = 0; i < heights.length; i += 1) offsets[i + 1] = offsets[i] + heights[i];
5
+ return offsets;
6
+ };
7
+
8
+ /** 第一个底边越过 y 的行 */
9
+ export const firstRowEndingAfter = (offsets: Float64Array, y: number) => {
10
+ let low = 0;
11
+ let high = offsets.length - 1;
12
+ while (low < high) {
13
+ const mid = (low + high) >> 1;
14
+ if (offsets[mid + 1] > y) high = mid;
15
+ else low = mid + 1;
16
+ }
17
+ return low;
18
+ };
19
+
20
+ /** 可视区(上下各留 overscan 像素)内的行区间 [start, end) */
21
+ export const virtualRange = (offsets: Float64Array, scrollTop: number, viewport: number, overscan: number) => {
22
+ const count = offsets.length - 1;
23
+ if (count <= 0) return { start: 0, end: 0 };
24
+ const start = Math.min(count - 1, firstRowEndingAfter(offsets, Math.max(0, scrollTop - overscan)));
25
+ const end = Math.max(start + 1, Math.min(count, firstRowEndingAfter(offsets, scrollTop + viewport + overscan) + 1));
26
+ return { start, end };
27
+ };
@@ -1,4 +1,6 @@
1
1
  import FormLoader from './FormLoader/FormLoader.vue';
2
+ export { registerField } from './FormLoader/FormRender.vue';
3
+ export type { FormValue, FormItem, FormArray, FormRule, FormContext, FormArrayContext, FormLinkage, FormError, FormValidateResult, FormLoaderExpose, FieldEntry } from './FormLoader/FormRender.vue';
2
4
  import LimitLoader from './LimitLoader';
3
5
  export * from './TableLoader/index';
4
6
  import TableLoader from './TableLoader/TableLoader.vue';
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ /// <reference path="./shims-vue.d.ts" />
2
+
1
3
  // 数据
2
4
  export * from './store/frame';
3
5
  export * from './utils/theme';
package/.prettierrc DELETED
@@ -1,12 +0,0 @@
1
- {
2
- "$schema": "https://json.schemastore.org/prettierrc",
3
- "semi": true,
4
- "arrowParens": "always",
5
- "trailingComma": "all",
6
- "singleQuote": true,
7
- "printWidth": 180,
8
- "tabWidth": 2,
9
- "bracketSameLine": false,
10
- "vueIndentScriptAndStyle": true,
11
- "htmlWhitespaceSensitivity": "ignore"
12
- }
package/rollup.config.js DELETED
@@ -1,75 +0,0 @@
1
- const resolve = require('@rollup/plugin-node-resolve');
2
- const commonjs = require('@rollup/plugin-commonjs');
3
- const vue = require('rollup-plugin-vue');
4
- const postcss = require('rollup-plugin-postcss');
5
- const typescript = require('@rollup/plugin-typescript');
6
- const ts = require('typescript');
7
- const path = require('path');
8
- const { pathToFileURL } = require('url');
9
- const { terser } = require('rollup-plugin-terser');
10
- const scss = require('postcss-scss');
11
-
12
- const vueTypeScript = () => ({
13
- name: 'vue-typescript',
14
- transform(code, id) {
15
- if (!/\.vue\?.*type=script.*lang\.ts$/.test(id)) return null;
16
-
17
- const result = ts.transpileModule(code, {
18
- fileName: id,
19
- compilerOptions: {
20
- target: ts.ScriptTarget.ESNext,
21
- module: ts.ModuleKind.ESNext,
22
- sourceMap: true
23
- }
24
- });
25
-
26
- return {
27
- code: result.outputText,
28
- map: result.sourceMapText ? JSON.parse(result.sourceMapText) : null
29
- };
30
- }
31
- });
32
-
33
- module.exports = {
34
- input: 'src/components/loader/index.ts',
35
- output: {
36
- file: 'dist/bundle.js',
37
- format: 'es',
38
- inlineDynamicImports: true,
39
- sourcemap: true
40
- },
41
- plugins: [
42
- resolve({
43
- extensions: ['.js', '.ts', '.vue']
44
- }),
45
- commonjs(),
46
- vue({
47
- css: false,
48
- compileTemplate: true,
49
- preprocessStyles: true,
50
- preprocessOptions: {
51
- scss: {
52
- importers: [
53
- {
54
- findFileUrl(url) {
55
- if (url !== '@neatui/css') return null;
56
- return pathToFileURL(path.resolve(__dirname, '../css/neatui.scss'));
57
- }
58
- }
59
- ]
60
- }
61
- }
62
- }),
63
- vueTypeScript(),
64
- postcss({
65
- extensions: ['.css', '.scss'],
66
- extract: true,
67
- minimize: true,
68
- syntax: scss
69
- }),
70
- typescript({
71
- tsconfig: './tsconfig.json'
72
- }),
73
- terser()
74
- ]
75
- };
package/tsconfig.json DELETED
@@ -1,23 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "esnext",
4
- "module": "esnext",
5
- "strict": true,
6
- "jsx": "preserve",
7
- "moduleResolution": "node",
8
- "experimentalDecorators": true,
9
- "skipLibCheck": true,
10
- "esModuleInterop": true,
11
- "allowSyntheticDefaultImports": true,
12
- "forceConsistentCasingInFileNames": true,
13
- "useDefineForClassFields": true,
14
- "sourceMap": true,
15
- "baseUrl": ".",
16
- "paths": {
17
- "@/*": ["src/*"]
18
- },
19
- "lib": ["esnext", "dom", "dom.iterable", "scripthost"]
20
- },
21
- "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/shims-vue.d.ts"],
22
- "exclude": ["node_modules"]
23
- }