webcake-ui-kit 1.1.28 → 1.1.30

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