webcake-ui-kit 1.1.27 → 1.1.29

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.
@@ -1,22 +1,40 @@
1
1
  <template>
2
2
  <div
3
- :class="['ui-table', bordered && 'ui-table--bordered', hasVerticalScroll && 'ui-table--scroll-y']"
3
+ :class="[
4
+ 'ui-table',
5
+ bordered && 'ui-table--bordered',
6
+ isFixedLayout && 'ui-table--scroll-y',
7
+ resizeState && 'ui-table--resizing'
8
+ ]"
4
9
  :style="containerStyle"
5
10
  >
6
11
  <table class="ui-table__table" :style="tableStyle">
7
12
  <colgroup>
13
+ <col v-if="hasDrag" class="ui-table__col ui-table__col--drag" />
8
14
  <col v-if="hasSelection" class="ui-table__col ui-table__col--selection" />
9
15
  <col v-for="col in normalizedColumns" :key="col._key" :style="col._style" />
10
16
  </colgroup>
11
- <thead class="ui-table__head">
17
+ <thead ref="tableHead" class="ui-table__head">
12
18
  <tr
13
19
  :class="['ui-table__row', 'ui-table__row--head', scrollbarWidth > 0 && 'ui-table__row--has-scrollbar']"
14
- :style="{ height: toCssSize(headerHeight) }"
20
+ :style="[{ height: toCssSize(headerHeight) }, rowScrollStyle]"
15
21
  >
22
+ <th
23
+ v-if="hasDrag"
24
+ :class="['ui-table__cell', 'ui-table__th', 'ui-table__cell--drag', 'ui-table__cell--fixed-left']"
25
+ :style="[headCellStyle(0), { left: '0px' }]"
26
+ aria-hidden="true"
27
+ />
16
28
  <th
17
29
  v-if="hasSelection"
18
- class="ui-table__cell ui-table__th ui-table__cell--selection"
19
- :style="headCellStyle(0)"
30
+ :class="[
31
+ 'ui-table__cell',
32
+ 'ui-table__th',
33
+ 'ui-table__cell--selection',
34
+ 'ui-table__cell--fixed-left',
35
+ !prefixStickyLeft && 'ui-table__cell--fixed-left-last'
36
+ ]"
37
+ :style="[headCellStyle(hasDrag ? 1 : 0), { left: selectionStickyOffset + 'px' }]"
20
38
  >
21
39
  <WkCheckbox :checked="allSelected" :disabled="!rows.length" @change="onToggleAll" />
22
40
  </th>
@@ -26,40 +44,55 @@
26
44
  :class="[
27
45
  'ui-table__cell',
28
46
  'ui-table__th',
47
+ 'ui-table__th-flex',
29
48
  `ui-table__cell--${col._align}`,
30
- col._isSortable && 'ui-table__th--sortable'
49
+ col._isSortable && 'ui-table__th--sortable',
50
+ col._fixed && `ui-table__cell--fixed-${col._fixed}`,
51
+ col._isLastFixedLeft && 'ui-table__cell--fixed-left-last',
52
+ col._isFirstFixedRight && 'ui-table__cell--fixed-right-first'
53
+ ]"
54
+ :style="[
55
+ headCellStyle((hasDrag ? 1 : 0) + (hasSelection ? 1 : 0) + i, col),
56
+ col._fixed === 'left' ? { [col._fixed]: col._stickyOffset + 'px' } : {},
57
+ col._fixed === 'right' ? { [col._fixed]: col._stickyOffset + 'px' } : {}
31
58
  ]"
32
- :style="headCellStyle(hasSelection ? i + 1 : i, col)"
33
59
  :tabindex="col._isSortable ? 0 : undefined"
34
60
  :aria-sort="col._ariaSort"
35
61
  @click="onHeaderClick(col)"
36
62
  @keydown.enter.prevent="onHeaderClick(col)"
37
63
  @keydown.space.prevent="onHeaderClick(col)"
38
- v-bind="customHeaderAttrs(col, i)"
39
- v-on="customHeaderListeners(col, i)"
64
+ v-bind="col._headerAttrs"
65
+ v-on="col._headerListeners"
40
66
  >
41
- <span class="ui-table__th-inner">
42
- <slot name="headerCell" :column="col">{{ col.title }}</slot>
43
- <span
44
- v-if="col._isSortable"
45
- :class="['ui-table__sort', col._sortState ? 'ui-table__sort--active' : 'ui-table__sort--inactive']"
46
- aria-hidden="true"
47
- >
48
- <WkiArrowUp v-if="col._sortState === 'ascend'" :size="16" />
49
- <WkiArrowDown v-else-if="col._sortState === 'descend'" :size="16" />
50
- <WkiArrowDownUp v-else :size="16" />
67
+ <div class="ui-table__th-inner-wrap">
68
+ <span class="ui-table__th-inner">
69
+ <slot name="headerCell" :column="col">{{ col.title }}</slot>
70
+ <span
71
+ v-if="col._isSortable"
72
+ :class="['ui-table__sort', col._sortState ? 'ui-table__sort--active' : 'ui-table__sort--inactive']"
73
+ aria-hidden="true"
74
+ >
75
+ <WkiArrowUp v-if="col._sortState === 'ascend'" :size="16" />
76
+ <WkiArrowDown v-else-if="col._sortState === 'descend'" :size="16" />
77
+ <WkiArrowDownUp v-else :size="16" />
78
+ </span>
51
79
  </span>
52
- </span>
80
+ <div
81
+ v-if="col._resizable"
82
+ :class="['ui-table__resize-handle', col._fixed === 'right' && 'ui-table__resize-handle--left']"
83
+ @pointerdown.stop.prevent="onResizeStart($event, col, i)"
84
+ />
85
+ </div>
53
86
  </th>
54
87
  <th
55
88
  v-if="scrollbarWidth > 0"
56
89
  class="ui-table__th--scrollbar"
57
- :style="{ width: scrollbarWidth + 'px' }"
90
+ :style="{ width: scrollbarWidth + 'px', position: 'sticky', right: '0px' }"
58
91
  aria-hidden="true"
59
92
  />
60
93
  </tr>
61
94
  </thead>
62
- <tbody class="ui-table__body" :style="bodyStyle" @scroll="onBodyScroll">
95
+ <tbody ref="tableBody" class="ui-table__body" :style="bodyStyle" @scroll="onBodyScroll" @dragleave="onDragLeave">
63
96
  <tr v-if="!rows.length" class="ui-table__row ui-table__row--empty">
64
97
  <td class="ui-table__cell ui-table__empty-cell" :colspan="totalColumns">
65
98
  <slot name="emptyText">
@@ -73,24 +106,51 @@
73
106
  <td class="ui-table__spacer-cell" :colspan="totalColumns" />
74
107
  </tr>
75
108
  <tr
76
- v-for="(record, i) in renderRows"
77
- :key="getRowKey(record, rowIndex(i))"
109
+ v-for="row in mappedRenderRows"
110
+ :key="row._key"
111
+ :ref="'row-' + row._key"
78
112
  :class="[
79
113
  'ui-table__row',
80
114
  'ui-table__row--body',
81
- isSelected(record, rowIndex(i)) && 'ui-table__row--selected'
115
+ row._selected && 'ui-table__row--selected',
116
+ hasDrag && dragState && row._index === dragState.fromIndex && 'ui-table__row--dragging',
117
+ hasDrag &&
118
+ row._index === dragOverIndex &&
119
+ dragState &&
120
+ row._index !== dragState.fromIndex &&
121
+ 'ui-table__row--drag-over'
82
122
  ]"
83
- :style="{ height: toCssSize(rowHeight) }"
84
- v-bind="customRowAttrs(record, rowIndex(i))"
85
- v-on="customRowListeners(record, rowIndex(i))"
86
- @click="hasSelection ? onRowClick(record, rowIndex(i), $event) : undefined"
123
+ :style="[{ height: toCssSize(rowHeight) }, rowScrollStyle]"
124
+ v-bind="row._attrs"
125
+ v-on="row._listeners"
126
+ @click="hasSelection ? onRowClick(row._record, row._index, $event) : undefined"
127
+ @dragover.prevent="onDragOver($event, row._index)"
128
+ @drop.prevent="onDrop($event, row._index)"
87
129
  >
130
+ <td
131
+ v-if="hasDrag"
132
+ :class="['ui-table__cell', 'ui-table__td', 'ui-table__cell--drag', 'ui-table__cell--fixed-left']"
133
+ :style="[{ left: '0px' }]"
134
+ draggable="true"
135
+ @dragstart.stop="onDragStart($event, row)"
136
+ @dragend="onDragEnd"
137
+ @click.stop
138
+ >
139
+ <WkiGripVertical :size="16" />
140
+ </td>
88
141
  <td
89
142
  v-if="hasSelection"
90
- class="ui-table__cell ui-table__td ui-table__cell--selection"
91
- @click.stop="onSelectionCellClick(record, rowIndex(i), $event)"
143
+ :class="[
144
+ 'ui-table__cell',
145
+ 'ui-table__td',
146
+ 'ui-table__cell--selection',
147
+ 'ui-table__cell--fixed-left',
148
+ !prefixStickyLeft && 'ui-table__cell--fixed-left-last'
149
+ ]"
150
+ :style="[{ left: selectionStickyOffset + 'px' }]"
151
+ @click.stop="onSelectionCellClick(row._record, row._index, $event)"
92
152
  >
93
- <WkCheckbox :checked="isSelected(record, rowIndex(i))" />
153
+ <WkCheckbox :checked="row._selected" />
94
154
  </td>
95
155
  <td
96
156
  v-for="col in normalizedColumns"
@@ -99,13 +159,26 @@
99
159
  'ui-table__cell',
100
160
  'ui-table__td',
101
161
  `ui-table__cell--${col._align}`,
102
- col.ellipsis && 'ui-table__cell--ellipsis'
162
+ col.ellipsis && 'ui-table__cell--ellipsis',
163
+ col._fixed && `ui-table__cell--fixed-${col._fixed}`,
164
+ col._isLastFixedLeft && 'ui-table__cell--fixed-left-last',
165
+ col._isFirstFixedRight && 'ui-table__cell--fixed-right-first'
166
+ ]"
167
+ :style="[
168
+ col._style,
169
+ col._fixed
170
+ ? { [col._fixed]: col._stickyOffset - (col._fixed === 'right' ? scrollbarWidth : 0) + 'px' }
171
+ : {}
103
172
  ]"
104
- :style="col._style"
105
173
  >
106
- <slot name="bodyCell" :column="col" :record="record" :text="cellText(col, record)" :index="rowIndex(i)">{{
107
- cellText(col, record)
108
- }}</slot>
174
+ <slot
175
+ name="bodyCell"
176
+ :column="col"
177
+ :record="row._record"
178
+ :text="cellText(col, row._record)"
179
+ :index="row._index"
180
+ >{{ cellText(col, row._record) }}</slot
181
+ >
109
182
  </td>
110
183
  </tr>
111
184
  <!-- Bottom spacer reserves the height of the rows below the window. -->
@@ -126,9 +199,7 @@
126
199
  <script>
127
200
  import WkCheckbox from '../checkbox/Checkbox.vue'
128
201
  import WkEmpty from '../empty/Empty.vue'
129
- import WkiArrowUp from '../../icons/ArrowUp.vue'
130
- import WkiArrowDown from '../../icons/ArrowDown.vue'
131
- import WkiArrowDownUp from '../../icons/ArrowDownUp.vue'
202
+ import { WkiGripVertical, WkiArrowDown, WkiArrowDownUp, WkiArrowUp } from '../../icons'
132
203
  import { toCssSize } from '../../utils/common'
133
204
  import { computeVirtualWindow } from '../../utils/virtualScroll'
134
205
 
@@ -140,9 +211,11 @@ function defaultCompare(a, b) {
140
211
  return String(a).localeCompare(String(b))
141
212
  }
142
213
 
214
+ const EMPTY_BINDINGS = Object.freeze({ attrs: {}, on: {} })
215
+
143
216
  export default {
144
217
  name: 'Table',
145
- components: { WkCheckbox, WkEmpty, WkiArrowUp, WkiArrowDown, WkiArrowDownUp },
218
+ components: { WkCheckbox, WkEmpty, WkiGripVertical, WkiArrowDown, WkiArrowDownUp, WkiArrowUp },
146
219
  props: {
147
220
  // Column descriptors: { title, dataIndex, key?, width?, align?, sorter?, ellipsis? }
148
221
  columns: { type: Array, default: () => [] },
@@ -154,6 +227,7 @@ export default {
154
227
  bordered: { type: Boolean, default: false },
155
228
  // Render a leading checkbox column for row selection.
156
229
  rowSelection: { type: Boolean, default: false },
230
+ isDrag: { type: Boolean, default: false },
157
231
  // Controlled selection — array of selected row keys.
158
232
  selectedRowKeys: { type: Array, default: () => [] },
159
233
  // Placeholder shown when dataSource is empty.
@@ -173,7 +247,7 @@ export default {
173
247
  // Extra rows rendered above and below the viewport to cushion fast scrolling.
174
248
  overscan: { type: Number, default: 8 }
175
249
  },
176
- emits: ['change', 'sort-change', 'update:selectedRowKeys', 'selection-change'],
250
+ emits: ['change', 'sort-change', 'update:selectedRowKeys', 'selection-change', 'drag-record'],
177
251
  data() {
178
252
  return {
179
253
  sortField: null,
@@ -185,7 +259,11 @@ export default {
185
259
  scrollTop: 0,
186
260
  containerHeight: 0,
187
261
  rafId: null,
188
- toCssSize
262
+ toCssSize,
263
+ resizeState: null,
264
+ columnWidths: {},
265
+ dragState: null,
266
+ dragOverIndex: null
189
267
  }
190
268
  },
191
269
  computed: {
@@ -193,11 +271,31 @@ export default {
193
271
  return new Set(this.internalSelected)
194
272
  },
195
273
  normalizedColumns() {
196
- return this.columns.map(col => {
274
+ const prefixDomCount = (this.hasDrag ? 1 : 0) + (this.hasSelection ? 1 : 0)
275
+
276
+ const domWidth = domIdx => {
277
+ const dataIdx = domIdx - prefixDomCount
278
+ if (dataIdx >= 0 && dataIdx < this.columns.length) {
279
+ const col = this.columns[dataIdx]
280
+ const key = col.key != null ? col.key : col.dataIndex
281
+ // resize override > DOM measurement > width prop
282
+ if (key != null && this.columnWidths[key] != null) return this.columnWidths[key]
283
+ if (this.colWidths[domIdx] != null) return this.colWidths[domIdx]
284
+ const w = col.width
285
+ return w != null ? (typeof w === 'number' ? w : parseFloat(w) || 0) : 0
286
+ }
287
+ // prefix columns (drag, selection) — DOM only, no prop fallback
288
+ return this.colWidths[domIdx] != null ? this.colWidths[domIdx] : 0
289
+ }
290
+
291
+ const cols = this.columns.map((col, i) => {
197
292
  const _key = col.key != null ? col.key : col.dataIndex
198
293
  const _align = ['left', 'right', 'center'].includes(col.align) ? col.align : 'left'
294
+ const overrideW = this.columnWidths[_key]
199
295
  let _style = {}
200
- if (col.width != null) {
296
+ if (overrideW != null) {
297
+ _style = { width: `${overrideW}px` }
298
+ } else if (col.width != null) {
201
299
  _style = { width: typeof col.width === 'number' ? `${col.width}px` : col.width }
202
300
  }
203
301
  const _isSortable = !!col.sorter
@@ -212,6 +310,10 @@ export default {
212
310
  if (_sortState === 'ascend') _ariaSort = 'ascending'
213
311
  else if (_sortState === 'descend') _ariaSort = 'descending'
214
312
 
313
+ // Pre-compute header bindings once per column instead of calling
314
+ // customHeaderAttrs / customHeaderListeners every render.
315
+ const _headerBindings = this.customHeader ? this.splitBindings(this.customHeader(col, i)) : EMPTY_BINDINGS
316
+ const _fixed = col.fixed === 'left' ? 'left' : col.fixed === 'right' ? 'right' : null
215
317
  return {
216
318
  ...col,
217
319
  _key,
@@ -220,15 +322,62 @@ export default {
220
322
  _isSortable,
221
323
  _field,
222
324
  _sortState,
223
- _ariaSort
325
+ _ariaSort,
326
+ _headerAttrs: _headerBindings.attrs,
327
+ _headerListeners: _headerBindings.on,
328
+ _fixed,
329
+ _isLastFixedLeft: false,
330
+ _isLastFixedRight: false,
331
+ _resizable: col.resizable != null ? col.resizable : false
224
332
  }
225
333
  })
334
+
335
+ let leftAccum = 0
336
+ for (let i = 0; i < prefixDomCount; i++) leftAccum += domWidth(i)
337
+ for (let i = 0; i < cols.length; i++) {
338
+ cols[i]._stickyOffset = leftAccum
339
+ leftAccum += domWidth(prefixDomCount + i)
340
+ }
341
+
342
+ let rightAccum = this.scrollbarWidth
343
+ for (let i = cols.length - 1; i >= 0; i--) {
344
+ if (cols[i]._fixed === 'right') {
345
+ cols[i]._stickyOffset = rightAccum
346
+ rightAccum += domWidth(prefixDomCount + i)
347
+ }
348
+ }
349
+
350
+ let lastLeftIdx,
351
+ firstRightIdx = -1
352
+ for (let i = 0; i < cols.length; i++) {
353
+ if (cols[i]._fixed === 'left') lastLeftIdx = i
354
+ if (cols[i]._fixed === 'right' && firstRightIdx === -1) firstRightIdx = i
355
+ }
356
+
357
+ if (lastLeftIdx >= 0) cols[lastLeftIdx]._isLastFixedLeft = true
358
+ if (firstRightIdx >= 0) cols[firstRightIdx]._isFirstFixedRight = true
359
+
360
+ return cols
226
361
  },
227
362
  hasSelection() {
228
363
  return !!this.rowSelection
229
364
  },
365
+ hasDrag() {
366
+ return !!this.isDrag
367
+ },
368
+ // True when any data column is fixed-left — prefix columns must also become sticky.
369
+ prefixStickyLeft() {
370
+ return this.normalizedColumns.some(c => c._fixed === 'left')
371
+ },
372
+ hasStickyRight() {
373
+ return this.normalizedColumns.some(c => c._fixed === 'right')
374
+ },
375
+ // Pixel offset of the selection column when sticky (sits after the drag column).
376
+ selectionStickyOffset() {
377
+ return this.hasDrag ? (this.colWidths[0] != null ? this.colWidths[0] : 44) : 0
378
+ },
230
379
  totalColumns() {
231
- return this.columns.length + (this.hasSelection ? 1 : 0)
380
+ return this.columns.length + (this.hasDrag ? 1 : 0) + (this.hasSelection ? 1 : 0)
232
381
  },
233
382
  rows() {
234
383
  const data = this.dataSource.slice()
@@ -245,22 +394,33 @@ export default {
245
394
  if (!this.rows.length) return false
246
395
  return this.rows.every((record, index) => this.selectedKeysSet.has(this.getRowKey(record, index)))
247
396
  },
248
- hasVerticalScroll() {
249
- return this.height != null || !!(this.scroll && this.scroll.y != null)
250
- },
251
- containerStyle() {
397
+ isFixedLayout() {
252
398
  const scroll = this.scroll || {}
253
- return scroll.x != null ? { overflowX: 'auto' } : {}
399
+ return this.height != null || scroll.y != null || scroll.x != null
254
400
  },
255
- bodyStyle() {
256
- const scroll = this.scroll || {}
257
- if (this.height != null) return { height: toCssSize(this.height) }
258
- if (scroll.y != null) return { maxHeight: toCssSize(scroll.y) }
401
+ containerStyle() {
259
402
  return {}
260
403
  },
261
404
  tableStyle() {
405
+ return {}
406
+ },
407
+ rowScrollStyle() {
262
408
  const scroll = this.scroll || {}
263
- return scroll.x != null ? { minWidth: toCssSize(scroll.x) } : {}
409
+ return scroll.x != null && this.isFixedLayout ? { minWidth: toCssSize(scroll.x) } : {}
410
+ },
411
+ bodyStyle() {
412
+ const scroll = this.scroll || {}
413
+ const style = {}
414
+ if (this.height != null) {
415
+ style.height = toCssSize(this.height)
416
+ } else if (scroll.y != null) {
417
+ style.maxHeight = toCssSize(scroll.y)
418
+ }
419
+ if (this.isFixedLayout) {
420
+ style.overflowY = scroll.y != null || this.height != null ? 'auto' : 'visible'
421
+ style.overflowX = scroll.x != null ? 'auto' : 'hidden'
422
+ }
423
+ return style
264
424
  },
265
425
  // Numeric row height — virtualization needs a fixed px value. String sizes are
266
426
  // parsed (e.g. '40px' -> 40); anything unparseable falls back to the prop default.
@@ -271,7 +431,8 @@ export default {
271
431
  },
272
432
  // Virtualization is only meaningful inside a bounded, scrollable viewport.
273
433
  isVirtual() {
274
- return this.virtual && this.hasVerticalScroll && this.rows.length > 0
434
+ // Virtualization is only meaningful inside a bounded vertical viewport.
435
+ return this.virtual && (this.height != null || (this.scroll && this.scroll.y != null)) && this.rows.length > 0
275
436
  },
276
437
  virtualWindow() {
277
438
  return computeVirtualWindow({
@@ -283,16 +444,41 @@ export default {
283
444
  })
284
445
  },
285
446
  // Rows actually committed to the DOM: a slice when virtual, otherwise everything.
286
- renderRows() {
447
+ slicedRenderRows() {
287
448
  if (!this.isVirtual) return this.rows
288
449
  return this.rows.slice(this.virtualWindow.startIndex, this.virtualWindow.endIndex)
289
450
  },
451
+ // Pre-compute key, index, selected state, and customRow bindings for each row.
452
+ // This eliminates repeated method calls (getRowKey, rowIndex, isSelected,
453
+ // customRowAttrs, customRowListeners) inside the v-for template.
454
+ mappedRenderRows() {
455
+ const startIndex = this.isVirtual ? this.virtualWindow.startIndex : 0
456
+ const hasCustomRow = !!this.customRow
457
+ return this.slicedRenderRows.map((record, i) => {
458
+ const index = startIndex + i
459
+ const key = this.getRowKey(record, index)
460
+ const selected = this.selectedKeysSet.has(key)
461
+ const bindings = hasCustomRow ? this.splitBindings(this.customRow(record, index)) : EMPTY_BINDINGS
462
+ return {
463
+ _record: record,
464
+ _index: index,
465
+ _key: key,
466
+ _selected: selected,
467
+ _attrs: bindings.attrs,
468
+ _listeners: bindings.on
469
+ }
470
+ })
471
+ },
472
+ // Kept as aliases so template references (renderRows) don't break if used externally.
473
+ renderRows() {
474
+ return this.mappedRenderRows
475
+ },
290
476
  topSpacerHeight() {
291
477
  return this.isVirtual ? this.virtualWindow.offsetY : 0
292
478
  },
293
479
  bottomSpacerHeight() {
294
480
  if (!this.isVirtual) return 0
295
- const rendered = this.renderRows.length * this.rowHeightPx
481
+ const rendered = this.slicedRenderRows.length * this.rowHeightPx
296
482
  return Math.max(0, this.virtualWindow.totalHeight - this.virtualWindow.offsetY - rendered)
297
483
  }
298
484
  },
@@ -302,11 +488,7 @@ export default {
302
488
  }
303
489
  },
304
490
  mounted() {
305
- this.$nextTick(this.syncScrollbarGutter)
306
- window.addEventListener('resize', this.onResize)
307
- },
308
- updated() {
309
- this.$nextTick(this.syncScrollbarGutter)
491
+ this.initResizeObserver()
310
492
  },
311
493
  // Declare both teardown hooks: Vue 2.7 fires beforeDestroy, Vue 3.4 fires beforeUnmount.
312
494
  // The runtime that doesn't recognize a hook simply ignores it.
@@ -334,52 +516,56 @@ export default {
334
516
  }
335
517
  return { attrs, on }
336
518
  },
337
- customRowAttrs(record, index) {
338
- return this.customRow ? this.splitBindings(this.customRow(record, index)).attrs : {}
339
- },
340
- customRowListeners(record, index) {
341
- return this.customRow ? this.splitBindings(this.customRow(record, index)).on : {}
342
- },
343
- customHeaderAttrs(col, index) {
344
- return this.customHeader ? this.splitBindings(this.customHeader(col, index)).attrs : {}
345
- },
346
- customHeaderListeners(col, index) {
347
- return this.customHeader ? this.splitBindings(this.customHeader(col, index)).on : {}
348
- },
349
519
  teardown() {
350
- window.removeEventListener('resize', this.onResize)
520
+ if (this._resizeObserver) {
521
+ this._resizeObserver.disconnect()
522
+ this._resizeObserver = null
523
+ }
351
524
  if (this.rafId != null) {
352
525
  cancelAnimationFrame(this.rafId)
353
526
  this.rafId = null
354
527
  }
355
528
  },
356
- onResize() {
357
- this.syncScrollbarGutter()
529
+ // Use ResizeObserver instead of updated() + window resize to measure scrollbar
530
+ // gutter and container height. This avoids triggering a second render cycle
531
+ // every time Vue updates the DOM.
532
+ initResizeObserver() {
533
+ const body = this.$refs.tableBody
534
+ if (!body) return
535
+ this._resizeObserver = new ResizeObserver(() => {
536
+ this.syncScrollbarGutter()
537
+ })
538
+ this._resizeObserver.observe(body)
539
+ // Initial measurement.
540
+ this.$nextTick(this.syncScrollbarGutter)
358
541
  },
359
542
  onBodyScroll(e) {
360
543
  const top = e.target.scrollTop
544
+ const left = e.target.scrollLeft
361
545
  // Coalesce bursts of scroll events into a single state update per frame so we
362
546
  // re-slice at most once per repaint — keeps 10k+ rows smooth.
363
547
  if (this.rafId != null) return
364
548
  this.rafId = requestAnimationFrame(() => {
365
549
  this.rafId = null
366
550
  this.scrollTop = top
551
+ // Sync horizontal scroll to keep the header aligned with the body.
552
+ const head = this.$refs.tableHead
553
+ if (head) head.scrollLeft = left
367
554
  })
368
555
  },
369
- // Absolute index of a rendered row within the full (sorted) dataset.
370
- rowIndex(i) {
371
- return this.isVirtual ? this.virtualWindow.startIndex + i : i
372
- },
373
556
  syncScrollbarGutter() {
374
- if (!this.$el) return
375
- const body = this.$el.querySelector('.ui-table__body')
376
- this.scrollbarWidth = body && this.hasVerticalScroll ? body.offsetWidth - body.clientWidth : 0
377
- if (body && this.hasVerticalScroll) {
557
+ const body = this.$refs.tableBody
558
+ if (!body) return
559
+
560
+ const hasY = this.height != null || (this.scroll && this.scroll.y != null)
561
+ this.scrollbarWidth = hasY ? body.offsetWidth - body.clientWidth : 0
562
+
563
+ if (hasY) {
378
564
  this.containerHeight = body.clientHeight
379
565
  }
380
566
 
381
- if (this.hasVerticalScroll) {
382
- const firstRow = this.$el.querySelector('.ui-table__row--body')
567
+ if (this.isFixedLayout) {
568
+ const firstRow = body.querySelector('.ui-table__row--body')
383
569
  if (firstRow) {
384
570
  const cells = Array.from(firstRow.children)
385
571
  const widths = cells.map(c => c.getBoundingClientRect().width)
@@ -397,7 +583,7 @@ export default {
397
583
  }
398
584
  },
399
585
  headCellStyle(index, col = null) {
400
- if (this.hasVerticalScroll && this.colWidths[index] !== undefined) {
586
+ if (this.isFixedLayout && this.colWidths[index] !== undefined) {
401
587
  return { width: `${this.colWidths[index]}px` }
402
588
  }
403
589
  return col ? col._style : {}
@@ -477,6 +663,153 @@ export default {
477
663
  onToggleAll(checked) {
478
664
  this.internalSelected = checked ? this.rows.map((record, index) => this.getRowKey(record, index)) : []
479
665
  this.emitSelection()
666
+ },
667
+ onDragStart(e, row) {
668
+ this.dragState = { fromIndex: row._index, record: row._record }
669
+ e.dataTransfer.effectAllowed = 'move'
670
+
671
+ const tr = e.currentTarget.closest('tr')
672
+ const rect = tr.getBoundingClientRect()
673
+ const tableEl = this.$el.querySelector('.ui-table__table')
674
+
675
+ // Shallow-clone the full ancestor chain so scoped CSS selectors
676
+ // (e.g. .ui-table--scroll-y .ui-table__row[data-v-xxx]) still match.
677
+ const wrapper = this.$el.cloneNode(false)
678
+ wrapper.style.position = 'fixed'
679
+ wrapper.style.top = '-9999px'
680
+ wrapper.style.left = '-9999px'
681
+ wrapper.style.width = rect.width + 'px'
682
+ wrapper.style.pointerEvents = 'none'
683
+
684
+ const table = tableEl.cloneNode(false)
685
+ const colgroup = tableEl.querySelector('colgroup')
686
+ if (colgroup) table.appendChild(colgroup.cloneNode(true))
687
+
688
+ const tbody = this.$refs.tableBody.cloneNode(false)
689
+ tbody.style.overflow = 'visible'
690
+ tbody.style.maxHeight = 'none'
691
+ tbody.style.height = 'auto'
692
+
693
+ const clonedRow = tr.cloneNode(true)
694
+ clonedRow.classList.remove('ui-table__row--dragging')
695
+ clonedRow.classList.add('ui-table__row--drag-preview')
696
+
697
+ tbody.appendChild(clonedRow)
698
+ table.appendChild(tbody)
699
+ wrapper.appendChild(table)
700
+ document.body.appendChild(wrapper)
701
+
702
+ e.dataTransfer.setDragImage(wrapper, e.clientX - rect.left, e.clientY - rect.top)
703
+ requestAnimationFrame(() => document.body.removeChild(wrapper))
704
+ },
705
+ onDragOver(e, index) {
706
+ if (!this.dragState) return
707
+ this.dragOverIndex = index
708
+ },
709
+ onDragLeave(e) {
710
+ if (!e.currentTarget.contains(e.relatedTarget)) {
711
+ this.dragOverIndex = null
712
+ }
713
+ },
714
+ onDrop(e, toIndex) {
715
+ if (!this.dragState) return
716
+ const { fromIndex, record } = this.dragState
717
+ if (fromIndex === toIndex) {
718
+ this.dragState = null
719
+ this.dragOverIndex = null
720
+ return
721
+ }
722
+ // FLIP — snapshot "First" positions before the data changes.
723
+ const firstRects = this.snapshotRowRects()
724
+ this.$emit('drag-record', { record, fromIndex, toIndex }, e)
725
+ this.dragState = null
726
+ this.dragOverIndex = null
727
+ // After Vue re-renders with the new order, run the Invert+Play phase.
728
+ this.$nextTick(() => this.flipAnimateRows(firstRects))
729
+ },
730
+ onDragEnd() {
731
+ this.dragState = null
732
+ this.dragOverIndex = null
733
+ },
734
+ // FLIP helpers -------------------------------------------------------
735
+ snapshotRowRects() {
736
+ const rects = {}
737
+ this.mappedRenderRows.forEach(row => {
738
+ const refName = 'row-' + row._key
739
+ // Vue 2: $refs for v-for are arrays; Vue 3: also arrays
740
+ const els = this.$refs[refName]
741
+ const el = Array.isArray(els) ? els[0] : els
742
+ if (el) {
743
+ // In Vue 2, el is the DOM node. In Vue 3 with <tr>, el is also the DOM node.
744
+ const dom = el.$el || el
745
+ rects[row._key] = dom.getBoundingClientRect()
746
+ }
747
+ })
748
+ return rects
749
+ },
750
+ flipAnimateRows(firstRects) {
751
+ this.mappedRenderRows.forEach(row => {
752
+ const refName = 'row-' + row._key
753
+ const els = this.$refs[refName]
754
+ const el = Array.isArray(els) ? els[0] : els
755
+ if (!el) return
756
+ const dom = el.$el || el
757
+ const first = firstRects[row._key]
758
+ if (!first) return
759
+ const last = dom.getBoundingClientRect()
760
+ const deltaY = first.top - last.top
761
+ if (Math.abs(deltaY) < 1) return
762
+ // Invert: jump the row back to its old position.
763
+ dom.style.transition = 'none'
764
+ dom.style.transform = `translateY(${deltaY}px)`
765
+ // Play: animate from inverted position to final position.
766
+ requestAnimationFrame(() => {
767
+ dom.style.transition = 'transform 0.25s cubic-bezier(0.22, 1, 0.36, 1)'
768
+ dom.style.transform = ''
769
+ const onEnd = () => {
770
+ dom.style.transition = ''
771
+ dom.style.transform = ''
772
+ dom.removeEventListener('transitionend', onEnd)
773
+ }
774
+ dom.addEventListener('transitionend', onEnd, { once: true })
775
+ })
776
+ })
777
+ },
778
+ onResizeStart(e, col, colIndex) {
779
+ const th = e.currentTarget.closest('th')
780
+ this.resizeState = {
781
+ colKey: col._key,
782
+ domIdx: (this.hasDrag ? 1 : 0) + (this.hasSelection ? 1 : 0) + colIndex,
783
+ startX: e.clientX,
784
+ startWidth: th.getBoundingClientRect().width,
785
+ minWidth: typeof col.minWidth === 'number' ? col.minWidth : 40,
786
+ reverse: col._fixed === 'right'
787
+ }
788
+ e.currentTarget.setPointerCapture(e.pointerId)
789
+ document.addEventListener('pointermove', this.onResizeMove)
790
+ document.addEventListener('pointerup', this.onResizeEnd, { once: true })
791
+ },
792
+ onResizeMove(e) {
793
+ if (!this.resizeState) return
794
+ const { colKey, startX, startWidth, minWidth, reverse } = this.resizeState
795
+ const delta = reverse ? startX - e.clientX : e.clientX - startX
796
+ const newWidth = Math.max(minWidth, startWidth + delta)
797
+ // Spread into a new object so Vue detects the change (direct property
798
+ this.columnWidths = { ...this.columnWidths, [colKey]: newWidth }
799
+
800
+ // colWidths is stale during drag — patch the domIdx slot so sticky
801
+ // offsets for columns AFTER this one are also correct immediately.
802
+ const domIdx = this.resizeState.domIdx
803
+ const next = this.colWidths.slice()
804
+ next[domIdx] = newWidth
805
+ this.colWidths = next
806
+ // Re-measure so colWidths reflects the final DOM state.
807
+ this.$nextTick(this.syncScrollbarGutter)
808
+ },
809
+
810
+ onResizeEnd() {
811
+ document.removeEventListener('pointermove', this.onResizeMove)
812
+ this.resizeState = null
480
813
  }
481
814
  }
482
815
  }