@coreui/data-grid 0.1.0-rc.0 → 0.2.0-rc.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 (137) hide show
  1. package/README.md +5 -4
  2. package/dist/css/data-grid.css +149 -31
  3. package/dist/css/data-grid.css.map +1 -1
  4. package/dist/css/data-grid.min.css +2 -2
  5. package/dist/css/data-grid.min.css.map +1 -1
  6. package/dist/js/clipboard.cjs +56 -0
  7. package/dist/js/clipboard.cjs.map +1 -0
  8. package/dist/js/clipboard.mjs +54 -0
  9. package/dist/js/clipboard.mjs.map +1 -0
  10. package/dist/js/csv.cjs +36 -59
  11. package/dist/js/csv.cjs.map +1 -1
  12. package/dist/js/csv.mjs +35 -55
  13. package/dist/js/csv.mjs.map +1 -1
  14. package/dist/js/data-grid.esm.js +14078 -9332
  15. package/dist/js/data-grid.esm.js.map +1 -1
  16. package/dist/js/data-grid.esm.min.js +6 -6
  17. package/dist/js/data-grid.esm.min.js.map +1 -1
  18. package/dist/js/data-grid.js +14377 -9634
  19. package/dist/js/data-grid.js.map +1 -1
  20. package/dist/js/data-grid.lite.esm.js +10476 -0
  21. package/dist/js/data-grid.lite.esm.js.map +1 -0
  22. package/dist/js/data-grid.lite.esm.min.js +8 -0
  23. package/dist/js/data-grid.lite.esm.min.js.map +1 -0
  24. package/dist/js/data-grid.lite.js +10489 -0
  25. package/dist/js/data-grid.lite.js.map +1 -0
  26. package/dist/js/data-grid.lite.min.js +8 -0
  27. package/dist/js/data-grid.lite.min.js.map +1 -0
  28. package/dist/js/data-grid.lite.mjs +10476 -0
  29. package/dist/js/data-grid.lite.mjs.map +1 -0
  30. package/dist/js/data-grid.min.js +6 -6
  31. package/dist/js/data-grid.min.js.map +1 -1
  32. package/dist/js/data-grid.mjs +14078 -9332
  33. package/dist/js/data-grid.mjs.map +1 -1
  34. package/dist/js/drag.cjs +459 -0
  35. package/dist/js/drag.cjs.map +1 -0
  36. package/dist/js/drag.mjs +456 -0
  37. package/dist/js/drag.mjs.map +1 -0
  38. package/dist/js/export-table-CNAKStzj.mjs +26 -0
  39. package/dist/js/export-table-CNAKStzj.mjs.map +1 -0
  40. package/dist/js/export-table-doIwvE5U.cjs +55 -0
  41. package/dist/js/export-table-doIwvE5U.cjs.map +1 -0
  42. package/dist/js/filters.cjs +89 -132
  43. package/dist/js/filters.cjs.map +1 -1
  44. package/dist/js/filters.mjs +88 -128
  45. package/dist/js/filters.mjs.map +1 -1
  46. package/dist/js/labels.cjs +65 -70
  47. package/dist/js/labels.cjs.map +1 -1
  48. package/dist/js/labels.mjs +64 -66
  49. package/dist/js/labels.mjs.map +1 -1
  50. package/dist/js/print.cjs +66 -0
  51. package/dist/js/print.cjs.map +1 -0
  52. package/dist/js/print.mjs +64 -0
  53. package/dist/js/print.mjs.map +1 -0
  54. package/dist/js/validate.cjs +43 -0
  55. package/dist/js/validate.cjs.map +1 -0
  56. package/dist/js/validate.mjs +42 -0
  57. package/dist/js/validate.mjs.map +1 -0
  58. package/dist/js/xlsx.cjs +240 -0
  59. package/dist/js/xlsx.cjs.map +1 -0
  60. package/dist/js/xlsx.mjs +238 -0
  61. package/dist/js/xlsx.mjs.map +1 -0
  62. package/dist/types/base-component.d.ts +28 -18
  63. package/dist/types/data-grid.d.ts +26 -13
  64. package/dist/types/dom/data.d.ts +15 -0
  65. package/dist/types/dom/event-handler.d.ts +21 -10
  66. package/dist/types/dom/manipulator.d.ts +15 -8
  67. package/dist/types/engine/data-source.d.ts +1 -1
  68. package/dist/types/engine/features-lite.d.ts +25 -0
  69. package/dist/types/engine/features.d.ts +35 -0
  70. package/dist/types/engine/table.d.ts +3 -3
  71. package/dist/types/engine/validate.d.mts +1 -0
  72. package/dist/types/engine/validate.d.ts +20 -0
  73. package/dist/types/engine/virtualizer.d.ts +2 -0
  74. package/dist/types/icons.d.ts +6 -4
  75. package/dist/types/index-lite.d.mts +1 -0
  76. package/dist/types/index-lite.d.ts +19 -0
  77. package/dist/types/index.d.ts +11 -2
  78. package/dist/types/types.d.ts +53 -31
  79. package/dist/types/ui/body.d.ts +6 -3
  80. package/dist/types/ui/header.d.ts +3 -2
  81. package/dist/types/ui/loading.d.ts +1 -1
  82. package/dist/types/ui/pinning.d.ts +1 -1
  83. package/dist/types/ui/toolbar.d.ts +10 -2
  84. package/dist/types/util/clipboard.d.mts +1 -0
  85. package/dist/types/util/clipboard.d.ts +29 -0
  86. package/dist/types/util/config.d.ts +27 -10
  87. package/dist/types/util/csv.d.ts +5 -5
  88. package/dist/types/util/drag.d.mts +1 -0
  89. package/dist/types/util/drag.d.ts +48 -0
  90. package/dist/types/util/export-table.d.ts +47 -0
  91. package/dist/types/util/index.d.ts +66 -10
  92. package/dist/types/util/print.d.mts +1 -0
  93. package/dist/types/util/print.d.ts +32 -0
  94. package/dist/types/util/sanitizer.d.ts +15 -11
  95. package/dist/types/util/xlsx.d.mts +1 -0
  96. package/dist/types/util/xlsx.d.ts +17 -0
  97. package/dist/types/util/zip.d.ts +11 -0
  98. package/package.json +88 -10
  99. package/scss/data-grid.scss +204 -16
  100. package/src/{base-component.js → base-component.ts} +21 -17
  101. package/src/data-grid.ts +507 -200
  102. package/src/dom/{data.js → data.ts} +13 -10
  103. package/src/dom/event-handler.ts +380 -0
  104. package/src/dom/{manipulator.js → manipulator.ts} +11 -11
  105. package/src/engine/data-source.ts +7 -3
  106. package/src/engine/features-lite.ts +47 -0
  107. package/src/engine/features.ts +68 -0
  108. package/src/engine/table.ts +80 -47
  109. package/src/engine/validate.ts +64 -0
  110. package/src/engine/virtualizer.ts +17 -1
  111. package/src/icons.ts +11 -5
  112. package/src/index-lite.ts +85 -0
  113. package/src/index.ts +30 -2
  114. package/src/labels.ts +6 -4
  115. package/src/types.ts +87 -32
  116. package/src/ui/body.ts +73 -17
  117. package/src/ui/header.ts +51 -26
  118. package/src/ui/loading.ts +2 -2
  119. package/src/ui/pinning.ts +16 -16
  120. package/src/ui/toolbar.ts +27 -3
  121. package/src/util/clipboard.ts +112 -0
  122. package/src/util/{config.js → config.ts} +25 -11
  123. package/src/util/csv.ts +11 -30
  124. package/src/util/drag.ts +798 -0
  125. package/src/util/export-table.ts +86 -0
  126. package/src/util/{index.js → index.ts} +55 -35
  127. package/src/util/print.ts +125 -0
  128. package/src/util/{sanitizer.js → sanitizer.ts} +17 -13
  129. package/src/util/xlsx.ts +183 -0
  130. package/src/util/zip.ts +170 -0
  131. package/src/base-component.d.ts +0 -19
  132. package/src/dom/event-handler.d.ts +0 -11
  133. package/src/dom/event-handler.js +0 -331
  134. package/src/dom/manipulator.d.ts +0 -9
  135. package/src/util/config.d.ts +0 -11
  136. package/src/util/index.d.ts +0 -10
  137. package/src/util/sanitizer.d.ts +0 -11
@@ -0,0 +1,798 @@
1
+ /**
2
+ * --------------------------------------------------------------------------
3
+ * CoreUI PRO data-grid util/drag.ts
4
+ * License (https://coreui.io/pro/license/)
5
+ * --------------------------------------------------------------------------
6
+ */
7
+
8
+ // The one drag implementation behind every reorder in the grid: column drag
9
+ // (axis 'x', over the header) and row drag (axis 'y', over the body). It is
10
+ // headless in the sense that matters — it never reads the table, only what the
11
+ // consumer resolves for it — and pointer-based rather than HTML5 drag events,
12
+ // which cannot render a drop indicator, cannot auto-scroll, and behave
13
+ // differently on touch.
14
+ //
15
+ // The invariant that keeps it cheap: pointermove writes to the DOM directly.
16
+ // Nothing here goes through component state, change detection or a signal, so
17
+ // a drag costs no re-renders in any of the four editions.
18
+
19
+ const CLASS_NAME_GHOST = 'data-grid-drag-ghost'
20
+ const CLASS_NAME_INDICATOR = 'data-grid-drop-indicator'
21
+ const CLASS_NAME_DRAGGING = 'data-grid-dragging'
22
+ const CLASS_NAME_DRAG_ACTIVE = 'data-grid-drag-active'
23
+ const CLASS_NAME_PREVIEW = 'data-grid-drag-preview'
24
+ const CLASS_NAME_GHOST_SETTLE = 'data-grid-drag-ghost-settle'
25
+
26
+ // A press has to travel this far before it becomes a drag — otherwise a click
27
+ // on a sortable header would never sort.
28
+ const DRAG_THRESHOLD = 4
29
+ // How far past a zone boundary the pointer must travel before the slot flips.
30
+ // Swaps fire on ENTRY into a neighbour's zone, and without this band a hand
31
+ // tremor exactly on the boundary would re-animate back and forth.
32
+ const DRAG_HYSTERESIS = 3
33
+ // How close to the scroll element's edge the pointer starts auto-scrolling,
34
+ // and the fastest it scrolls when pinned to that edge.
35
+ const EDGE_SIZE = 48
36
+ const EDGE_MAX_SPEED = 18
37
+ // The ghost trails the pointer by this much, in px.
38
+ const GHOST_OFFSET = 12
39
+ // Removal fallback for the settling ghost — a context where the transition
40
+ // never runs (reduced motion, a hidden tab) must not leak the element.
41
+ const GHOST_SETTLE_FALLBACK_MS = 400
42
+
43
+ export type DragAxis = 'x' | 'y'
44
+
45
+ export interface DragHit {
46
+ id: string
47
+ element: HTMLElement
48
+ }
49
+
50
+ export interface DragDrop {
51
+ id: string
52
+ // The source lands after the target rather than before it — which side of
53
+ // the target's midpoint the pointer was released on.
54
+ after: boolean
55
+ }
56
+
57
+ export interface DragControllerOptions {
58
+ axis: DragAxis
59
+ root: HTMLElement
60
+ // Delegation selector for the element a drag may start from.
61
+ handle: string
62
+ // Called with the handle and the element actually pressed. Returning null
63
+ // vetoes the drag: a press on a resize handle, a column the author marked
64
+ // immovable, a grid whose reordering is off.
65
+ resolveSource: (handle: HTMLElement, target: HTMLElement) => DragHit | null
66
+ items: DragItems
67
+ // Vetoes a slot: the source itself, or an item the reorder may not cross
68
+ // (a column in another pinned region).
69
+ canDrop: (sourceId: string, targetId: string) => boolean
70
+ getScrollElement: () => HTMLElement | null
71
+ // The ghost's caption. Defaults to the source's collapsed text, which is the
72
+ // label for a header cell but every cell run together for a row.
73
+ ghostLabel?: (source: DragHit) => string
74
+ // The live preview: the items between the source and the drop slot slide
75
+ // aside and the source slides into the gap, so the grid shows the layout the
76
+ // drop will produce instead of only pointing at it. Transforms only, so
77
+ // nothing reflows and the real order still changes once, on drop. Replaces
78
+ // the drop-indicator line, which only renders when this is off.
79
+ preview?: boolean
80
+ onDrop: (sourceId: string, drop: DragDrop) => void
81
+ }
82
+
83
+ // The reorderable items, in display order along the drag's axis. This is both
84
+ // what the pointer is hit-tested against and what the live preview slides.
85
+ export interface DragItems {
86
+ getOrderedIds: () => string[]
87
+ // Every element that travels with that id: a column's header and body cells,
88
+ // or simply the row.
89
+ getElements: (id: string) => HTMLElement[]
90
+ }
91
+
92
+ export interface DragController {
93
+ dispose: () => void
94
+ }
95
+
96
+ const collapse = (text: string): string => text.replace(/\s+/g, ' ').trim()
97
+
98
+ // Which slot the source lands in, expressed in the CURRENT order — the same
99
+ // arithmetic the drop itself does, so the preview cannot disagree with it.
100
+ const destinationIndex = (from: number, target: number, after: boolean): number =>
101
+ (target > from ? (after ? target : target - 1) : (after ? target + 1 : target))
102
+
103
+ const swallowClick = (event: Event) => {
104
+ event.preventDefault()
105
+ event.stopPropagation()
106
+ }
107
+
108
+ const disarmSwallowClick = () => {
109
+ document.removeEventListener('click', swallowClick, { capture: true })
110
+ }
111
+
112
+ // Server-side rendering reaches here through Angular, whose ngAfterViewInit and
113
+ // ngOnDestroy both run during prerender. There is no pointer to track without a
114
+ // document, so hand back an inert controller instead of touching one.
115
+ const INERT_CONTROLLER: DragController = { dispose() {} }
116
+
117
+ export const createDragController = (options: DragControllerOptions): DragController => {
118
+ if (typeof document === 'undefined') {
119
+ return INERT_CONTROLLER
120
+ }
121
+
122
+ const { axis, root } = options
123
+ const horizontal = axis === 'x'
124
+
125
+ let sourceHit: DragHit | null = null
126
+ let dragging = false
127
+ let origin = { x: 0, y: 0 }
128
+ let pointer = { x: 0, y: 0 }
129
+ let drop: DragDrop | null = null
130
+ let ghost: HTMLElement | null = null
131
+ let indicator: HTMLElement | null = null
132
+ let scrollFrame = 0
133
+ let pointerId = -1
134
+ let previewKey = ''
135
+ // The zone that produced the current drop, padded by the hysteresis band.
136
+ let sticky: { lo: number; hi: number } | null = null
137
+ // The transform currently applied to each item — the preview diffs against
138
+ // it, and a layout captured mid-preview (a row revealed by auto-scroll) is
139
+ // corrected back through it.
140
+ const previewDeltas = new Map<string, number>()
141
+ // Every id that still carries the transition class. Mid-drag un-shifts keep
142
+ // the class so the slide BACK animates too; it only comes off at the end.
143
+ const previewClassed = new Set<string>()
144
+ // Each item's start in the scroll container's CONTENT coordinates — a space
145
+ // no transform and no scroll can move. Captured at drag start, when nothing
146
+ // is transformed yet, so the values are exact.
147
+ const contentStarts = new Map<string, number>()
148
+ const contentSizes = new Map<string, number>()
149
+
150
+ const stopAutoScroll = () => {
151
+ if (scrollFrame) {
152
+ cancelAnimationFrame(scrollFrame)
153
+ scrollFrame = 0
154
+ }
155
+ }
156
+
157
+ const autoScroll = () => {
158
+ scrollFrame = 0
159
+ const scrollElement = options.getScrollElement()
160
+ if (!scrollElement || !dragging) {
161
+ return
162
+ }
163
+
164
+ const rect = scrollElement.getBoundingClientRect()
165
+ const position = horizontal ? pointer.x : pointer.y
166
+ const start = horizontal ? rect.left : rect.top
167
+ const end = horizontal ? rect.right : rect.bottom
168
+
169
+ // Speed ramps with how far into the edge zone the pointer sits, so the
170
+ // list creeps at the boundary and races when pinned to the edge.
171
+ let delta = 0
172
+ if (position < start + EDGE_SIZE) {
173
+ delta = -EDGE_MAX_SPEED * Math.min(1, (start + EDGE_SIZE - position) / EDGE_SIZE)
174
+ } else if (position > end - EDGE_SIZE) {
175
+ delta = EDGE_MAX_SPEED * Math.min(1, (position - (end - EDGE_SIZE)) / EDGE_SIZE)
176
+ }
177
+
178
+ if (delta !== 0) {
179
+ if (horizontal) {
180
+ scrollElement.scrollLeft += delta
181
+ } else {
182
+ scrollElement.scrollTop += delta
183
+ }
184
+
185
+ // Scrolling moved the content under a stationary pointer, and no
186
+ // pointermove will fire to say so.
187
+ updateTarget()
188
+ scrollFrame = requestAnimationFrame(autoScroll)
189
+ }
190
+ }
191
+
192
+ // The ghost trails the pointer instead of sitting under it, so the cell the
193
+ // hit test resolves stays visible.
194
+ const moveGhost = () => {
195
+ if (ghost) {
196
+ ghost.style.transform = `translate(${pointer.x + GHOST_OFFSET}px, ${pointer.y + GHOST_OFFSET}px)`
197
+ }
198
+ }
199
+
200
+ const hideIndicator = () => {
201
+ if (indicator) {
202
+ indicator.hidden = true
203
+ }
204
+ }
205
+
206
+ const showIndicator = (target: HTMLElement, after: boolean) => {
207
+ if (!indicator) {
208
+ return
209
+ }
210
+
211
+ const rect = target.getBoundingClientRect()
212
+ const rootRect = root.getBoundingClientRect()
213
+ indicator.hidden = false
214
+ if (horizontal) {
215
+ indicator.style.left = `${after ? rect.right : rect.left}px`
216
+ indicator.style.top = `${rootRect.top}px`
217
+ indicator.style.height = `${rootRect.height}px`
218
+ } else {
219
+ indicator.style.left = `${rootRect.left}px`
220
+ indicator.style.top = `${after ? rect.bottom : rect.top}px`
221
+ indicator.style.width = `${rootRect.width}px`
222
+ }
223
+ }
224
+
225
+ const startDrag = () => {
226
+ dragging = true
227
+ // Chrome focuses a button on mousedown (a press on a header lands on the
228
+ // sort button), and tearing a FOCUSED element out of the DOM — which the
229
+ // drop's header re-append does — makes the browser reset the scroller it
230
+ // lived in: releasing a drag with the viewport scrolled right snapped it
231
+ // back to the left. A drag has no use for focus; drop it the moment the
232
+ // press becomes one. Sub-threshold presses never get here, so click-to-
233
+ // sort keeps its focus behavior.
234
+ const active = document.activeElement
235
+ if (active instanceof HTMLElement && root.contains(active)) {
236
+ active.blur()
237
+ }
238
+
239
+ root.classList.add(CLASS_NAME_DRAG_ACTIVE)
240
+ sourceHit!.element.classList.add(CLASS_NAME_DRAGGING)
241
+
242
+ const rect = sourceHit!.element.getBoundingClientRect()
243
+ ghost = document.createElement('div')
244
+ ghost.className = CLASS_NAME_GHOST
245
+ ghost.setAttribute('aria-hidden', 'true')
246
+ ghost.style.width = `${rect.width}px`
247
+ ghost.style.height = `${rect.height}px`
248
+ ghost.textContent = options.ghostLabel ?
249
+ options.ghostLabel(sourceHit!) :
250
+ collapse(sourceHit!.element.textContent ?? '')
251
+
252
+ // With the live preview on, the sliding items ARE the drop indicator; a
253
+ // line has no stable place to stand, because it would be measured against
254
+ // cells that are themselves mid-transition. The line only exists for
255
+ // consumers that turned the preview off.
256
+ if (!options.preview) {
257
+ indicator = document.createElement('div')
258
+ indicator.className = `${CLASS_NAME_INDICATOR} ${CLASS_NAME_INDICATOR}-${axis}`
259
+ indicator.setAttribute('aria-hidden', 'true')
260
+ indicator.hidden = true
261
+ root.append(indicator)
262
+ }
263
+
264
+ root.append(ghost)
265
+ moveGhost()
266
+ captureLayout()
267
+ }
268
+
269
+ // Where the container's content coordinate 0 currently sits on screen. The
270
+ // one live measurement the hit test needs — everything else is arithmetic on
271
+ // the snapshot, so mid-transition rects can never feed back into the slot.
272
+ //
273
+ // This is the whole reason the hit test is neither document.elementFromPoint
274
+ // nor rect-minus-delta reconstruction: the preview MOVES the cells under the
275
+ // pointer, and a rect measured mid-transition contains only part of its
276
+ // delta, so both models handed back zones shifted by the unfinished
277
+ // remainder of the animation. Dragging with the direction of travel, that
278
+ // shifted zone swallowed the next item's first half and the slot flipped
279
+ // back and forth until the transition settled.
280
+ const contentOrigin = (): number => {
281
+ const scrollElement = options.getScrollElement()
282
+ if (!scrollElement) {
283
+ return 0
284
+ }
285
+
286
+ const rect = scrollElement.getBoundingClientRect()
287
+ return horizontal ? rect.left - scrollElement.scrollLeft : rect.top - scrollElement.scrollTop
288
+ }
289
+
290
+ const captureItem = (id: string, origin: number): boolean => {
291
+ const element = options.items.getElements(id)[0]
292
+ if (!element) {
293
+ return false
294
+ }
295
+
296
+ const rect = element.getBoundingClientRect()
297
+ // Corrected by the applied delta for items first seen mid-preview (a row
298
+ // revealed by auto-scroll); at drag start every delta is zero.
299
+ const start = (horizontal ? rect.left : rect.top) - (previewDeltas.get(id) ?? 0)
300
+ contentStarts.set(id, start - origin)
301
+ contentSizes.set(id, horizontal ? rect.width : rect.height)
302
+ return true
303
+ }
304
+
305
+ const captureLayout = () => {
306
+ contentStarts.clear()
307
+ contentSizes.clear()
308
+ const origin = contentOrigin()
309
+ for (const id of options.items.getOrderedIds()) {
310
+ captureItem(id, origin)
311
+ }
312
+ }
313
+
314
+ const updateTarget = () => {
315
+ const position = horizontal ? pointer.x : pointer.y
316
+ const origin = contentOrigin()
317
+
318
+ const zones: Array<{ id: string; start: number; size: number }> = []
319
+ for (const id of options.items.getOrderedIds()) {
320
+ if (!contentStarts.has(id) && !captureItem(id, origin)) {
321
+ continue
322
+ }
323
+
324
+ zones.push({ id, start: origin + contentStarts.get(id)!, size: contentSizes.get(id)! })
325
+ }
326
+
327
+ let hit: { id: string; after: boolean } | null = null
328
+ const lastZone = zones[zones.length - 1]
329
+ const fromIndex = zones.findIndex(zone => zone.id === sourceHit!.id)
330
+
331
+ if (lastZone && position > lastZone.start + lastZone.size) {
332
+ // Past the end there is no zone to be inside, but the intent could not
333
+ // be clearer: after the last item the source may land on. Same at the
334
+ // start edge below.
335
+ sticky = null
336
+ for (let index = zones.length - 1; index >= 0; index--) {
337
+ if (options.canDrop(sourceHit!.id, zones[index].id)) {
338
+ hit = { id: zones[index].id, after: true }
339
+ break
340
+ }
341
+ }
342
+ } else if (lastZone && position < zones[0].start) {
343
+ sticky = null
344
+ for (const zone of zones) {
345
+ if (options.canDrop(sourceHit!.id, zone.id)) {
346
+ hit = { id: zone.id, after: false }
347
+ break
348
+ }
349
+ }
350
+ } else if (drop && sticky && position >= sticky.lo - DRAG_HYSTERESIS && position <= sticky.hi + DRAG_HYSTERESIS) {
351
+ // Still inside (or barely past) the zone that produced the current
352
+ // drop — keep it, so boundary jitter cannot re-animate.
353
+ hit = drop
354
+ } else {
355
+ for (let index = 0; index < zones.length; index++) {
356
+ const zone = zones[index]
357
+ const previous = zones[index - 1]
358
+ const following = zones[index + 1]
359
+ // Zones TILE the axis: real tables have collapsed borders between the
360
+ // cells, and those few pixels belong to no rect. A pointer crossing
361
+ // such a gap used to hit nothing, which cleared the preview and
362
+ // re-applied it on the far side — replaying the slide right at the
363
+ // boundary. Each gap is split down its middle instead.
364
+ const lo = previous ? (previous.start + previous.size + zone.start) / 2 : zone.start
365
+ const hi = following ? (zone.start + zone.size + following.start) / 2 : zone.start + zone.size
366
+ if (position < lo || position > hi) {
367
+ continue
368
+ }
369
+
370
+ if (options.canDrop(sourceHit!.id, zone.id)) {
371
+ // Entering a neighbour's zone displaces it AT ONCE — the swap fires
372
+ // at the boundary, not at the neighbour's midpoint, which is the
373
+ // feel MUI and AG have and what a drag reads as: "I am past it, so
374
+ // it moves". The release then just commits whatever the preview
375
+ // shows.
376
+ hit = {
377
+ id: zone.id,
378
+ after: fromIndex === -1 ? position > zone.start + (zone.size / 2) : index > fromIndex
379
+ }
380
+ sticky = { lo, hi }
381
+ }
382
+
383
+ break
384
+ }
385
+ }
386
+
387
+ if (!hit) {
388
+ drop = null
389
+ sticky = null
390
+ hideIndicator()
391
+ applyPreview()
392
+ return
393
+ }
394
+
395
+ drop = hit
396
+ showIndicator(options.items.getElements(hit.id)[0], hit.after)
397
+ applyPreview()
398
+ }
399
+
400
+ const clearPreview = () => {
401
+ for (const id of previewClassed) {
402
+ for (const element of options.items.getElements(id)) {
403
+ element.style.transform = ''
404
+ element.classList.remove(CLASS_NAME_PREVIEW)
405
+ }
406
+ }
407
+
408
+ previewKey = ''
409
+ previewDeltas.clear()
410
+ previewClassed.clear()
411
+ }
412
+
413
+ const clearLayout = () => {
414
+ contentStarts.clear()
415
+ contentSizes.clear()
416
+ }
417
+
418
+ const applyPreview = () => {
419
+ if (!options.preview || !sourceHit) {
420
+ return
421
+ }
422
+
423
+ const ids = options.items.getOrderedIds()
424
+ const from = ids.indexOf(sourceHit.id)
425
+ const target = drop ? ids.indexOf(drop.id) : -1
426
+ const to = from === -1 || target === -1 ? from : destinationIndex(from, target, drop!.after)
427
+
428
+ // Keyed on the DESTINATION SLOT, not on the hit: two hits that resolve to
429
+ // the same slot (the clamp past the end vs the last zone, jitter inside
430
+ // the hysteresis band) must not rewrite anything. A clear-and-reapply of
431
+ // identical transforms next to a forced layout read restarts the running
432
+ // transition from zero — the mid-drag replay this file has already paid
433
+ // for once.
434
+ const key = String(to)
435
+ if (key === previewKey) {
436
+ return
437
+ }
438
+
439
+ previewKey = key
440
+
441
+ const next = new Map<string, number>()
442
+ if (from !== -1 && to !== from) {
443
+ // Sizes come from the drag-start snapshot: no DOM reads may happen in
444
+ // here, or they would flush layout between the transform writes below.
445
+ const sizeOf = (index: number): number => contentSizes.get(ids[index]) ?? 0
446
+ const forward = to > from
447
+ const first = forward ? from + 1 : to
448
+ const last = forward ? to : from - 1
449
+ const sourceSize = sizeOf(from)
450
+ let travelled = 0
451
+
452
+ for (let index = first; index <= last; index++) {
453
+ travelled += sizeOf(index)
454
+ next.set(ids[index], forward ? -sourceSize : sourceSize)
455
+ }
456
+
457
+ // The source moves by the combined size of everything it passed, so no
458
+ // gap opens anywhere and the axis keeps its total extent.
459
+ next.set(ids[from], forward ? travelled : -travelled)
460
+ }
461
+
462
+ // Only elements whose delta actually changed are touched; everything else
463
+ // keeps its running transition undisturbed. An un-shift keeps the
464
+ // transition class, so sliding back home animates exactly like sliding
465
+ // aside did — the class only comes off when the drag ends.
466
+ for (const id of previewDeltas.keys()) {
467
+ if (!next.has(id)) {
468
+ for (const element of options.items.getElements(id)) {
469
+ element.style.transform = ''
470
+ }
471
+ }
472
+ }
473
+
474
+ for (const [id, delta] of next) {
475
+ if (previewDeltas.get(id) === delta) {
476
+ continue
477
+ }
478
+
479
+ for (const element of options.items.getElements(id)) {
480
+ element.style.transform = horizontal ? `translateX(${delta}px)` : `translateY(${delta}px)`
481
+ element.classList.add(CLASS_NAME_PREVIEW)
482
+ }
483
+
484
+ previewClassed.add(id)
485
+ }
486
+
487
+ previewDeltas.clear()
488
+ for (const [id, delta] of next) {
489
+ previewDeltas.set(id, delta)
490
+ }
491
+ }
492
+
493
+ // The gesture's closing beat: instead of vanishing at the pointer, the
494
+ // ghost flies to where the item landed (or back home on cancel) and fades
495
+ // out there. Purely cosmetic — the commit below never waits for it.
496
+ const settleGhost = (x: number, y: number) => {
497
+ if (!ghost) {
498
+ return
499
+ }
500
+
501
+ const element = ghost
502
+ // Taken out of cleanup's hands; it removes itself when the flight ends.
503
+ ghost = null
504
+ element.classList.add(CLASS_NAME_GHOST_SETTLE)
505
+ element.style.transform = `translate(${x}px, ${y}px)`
506
+ element.style.opacity = '0'
507
+
508
+ let settled = false
509
+ const finish = () => {
510
+ if (!settled) {
511
+ settled = true
512
+ element.remove()
513
+ }
514
+ }
515
+
516
+ element.addEventListener('transitionend', finish)
517
+ setTimeout(finish, GHOST_SETTLE_FALLBACK_MS)
518
+ }
519
+
520
+ // Where the ghost should land: the source's current visual position for a
521
+ // commit (the preview already put it in its slot), its untransformed home
522
+ // for a cancel (the preview is about to slide everything back there).
523
+ const settleTarget = (committed: boolean): { x: number; y: number } | null => {
524
+ if (!sourceHit) {
525
+ return null
526
+ }
527
+
528
+ const rect = sourceHit.element.getBoundingClientRect()
529
+ if (committed) {
530
+ return { x: rect.left, y: rect.top }
531
+ }
532
+
533
+ const delta = previewDeltas.get(sourceHit.id) ?? 0
534
+ return horizontal ? { x: rect.left - delta, y: rect.top } : { x: rect.left, y: rect.top - delta }
535
+ }
536
+
537
+ const cleanup = () => {
538
+ clearPreview()
539
+ clearLayout()
540
+ stopAutoScroll()
541
+ root.classList.remove(CLASS_NAME_DRAG_ACTIVE)
542
+ sourceHit?.element.classList.remove(CLASS_NAME_DRAGGING)
543
+ ghost?.remove()
544
+ indicator?.remove()
545
+ ghost = null
546
+ indicator = null
547
+ sourceHit = null
548
+ dragging = false
549
+ drop = null
550
+ sticky = null
551
+ pointerId = -1
552
+ document.removeEventListener('pointermove', onPointerMove)
553
+ document.removeEventListener('pointerup', onPointerUp)
554
+ document.removeEventListener('pointercancel', cancel)
555
+ document.removeEventListener('keydown', onKeydown)
556
+ }
557
+
558
+ function cancel() {
559
+ if (dragging) {
560
+ const home = settleTarget(false)
561
+ if (home) {
562
+ settleGhost(home.x, home.y)
563
+ }
564
+ }
565
+
566
+ cleanup()
567
+ }
568
+
569
+ function onKeydown(event: KeyboardEvent) {
570
+ if (event.key === 'Escape') {
571
+ event.preventDefault()
572
+ cancel()
573
+ }
574
+ }
575
+
576
+ function onPointerMove(event: PointerEvent) {
577
+ if (event.pointerId !== pointerId || !sourceHit) {
578
+ return
579
+ }
580
+
581
+ pointer = { x: event.clientX, y: event.clientY }
582
+
583
+ if (!dragging) {
584
+ const travelled = Math.max(Math.abs(pointer.x - origin.x), Math.abs(pointer.y - origin.y))
585
+ if (travelled < DRAG_THRESHOLD) {
586
+ return
587
+ }
588
+
589
+ startDrag()
590
+ }
591
+
592
+ // Suppresses the text selection a pointer drag would otherwise paint
593
+ // across the header or the rows.
594
+ event.preventDefault()
595
+ moveGhost()
596
+ updateTarget()
597
+
598
+ if (!scrollFrame) {
599
+ scrollFrame = requestAnimationFrame(autoScroll)
600
+ }
601
+ }
602
+
603
+ function onPointerUp(event: PointerEvent) {
604
+ if (event.pointerId !== pointerId) {
605
+ return
606
+ }
607
+
608
+ const sourceId = sourceHit?.id
609
+ const source = sourceHit?.element
610
+ const pending = drop
611
+ const wasDragging = dragging
612
+ if (wasDragging) {
613
+ const committed = Boolean(pending && pending.id !== sourceId)
614
+ const landing = settleTarget(committed)
615
+ if (landing) {
616
+ settleGhost(landing.x, landing.y)
617
+ }
618
+ }
619
+
620
+ cleanup()
621
+
622
+ if (!wasDragging) {
623
+ return
624
+ }
625
+
626
+ // A drag that ends on the element it started from is still followed by a
627
+ // click there — which on a header cell would toggle the sort. Swallow
628
+ // exactly that one. Releasing anywhere else fires the click on the common
629
+ // ancestor instead (never the sort button), so arming there would only
630
+ // risk eating an unrelated click; the 0 ms disarm covers the rest.
631
+ if (source?.contains(event.target as Node)) {
632
+ document.addEventListener('click', swallowClick, { capture: true, once: true })
633
+ setTimeout(disarmSwallowClick, 0)
634
+ }
635
+
636
+ if (sourceId && pending && pending.id !== sourceId) {
637
+ options.onDrop(sourceId, pending)
638
+ }
639
+ }
640
+
641
+ const onPointerDown = (event: PointerEvent) => {
642
+ // Primary button / single touch only — a right-click or a second finger
643
+ // must not start a reorder.
644
+ if (!event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0) || sourceHit) {
645
+ return
646
+ }
647
+
648
+ const element = (event.target as HTMLElement | null)?.closest<HTMLElement>(options.handle)
649
+ if (!element) {
650
+ return
651
+ }
652
+
653
+ const hit = options.resolveSource(element, event.target as HTMLElement)
654
+ if (!hit) {
655
+ return
656
+ }
657
+
658
+ sourceHit = hit
659
+ pointerId = event.pointerId
660
+ origin = { x: event.clientX, y: event.clientY }
661
+ pointer = origin
662
+
663
+ document.addEventListener('pointermove', onPointerMove)
664
+ document.addEventListener('pointerup', onPointerUp)
665
+ document.addEventListener('pointercancel', cancel)
666
+ document.addEventListener('keydown', onKeydown)
667
+ }
668
+
669
+ root.addEventListener('pointerdown', onPointerDown)
670
+
671
+ return {
672
+ dispose() {
673
+ root.removeEventListener('pointerdown', onPointerDown)
674
+ disarmSwallowClick()
675
+ cleanup()
676
+ }
677
+ }
678
+ }
679
+
680
+ const SELECTOR_DRAGGABLE_COLUMN = 'th[data-coreui-draggable]'
681
+ const SELECTOR_RESIZER = '[data-coreui-resizer]'
682
+ // Spacer and empty rows have a single colSpan cell, so a positional lookup
683
+ // would land on the wrong thing.
684
+ const SELECTOR_PREVIEW_ROWS = 'thead tr, tbody tr:not(.data-grid-spacer):not(.data-grid-empty)'
685
+
686
+ // A column's cells are found by POSITION, not by a data attribute: the body
687
+ // only marks `data-coreui-column` when cellNavigation is on, while the header
688
+ // always does — so the header cell's index is the reliable way in, and it
689
+ // already accounts for the leading handle and select columns.
690
+ const columnItems = (root: HTMLElement): DragItems => ({
691
+ getOrderedIds: () =>
692
+ [...root.querySelectorAll<HTMLTableCellElement>('thead tr:first-child th[data-coreui-column]')]
693
+ .map(cell => cell.dataset.coreuiColumn ?? ''),
694
+ getElements(id) {
695
+ const header = root.querySelector<HTMLTableCellElement>(`thead th[data-coreui-column="${id}"]`)
696
+ if (!header) {
697
+ return []
698
+ }
699
+
700
+ const { cellIndex } = header
701
+ const elements: HTMLElement[] = []
702
+ for (const row of root.querySelectorAll<HTMLTableRowElement>(SELECTOR_PREVIEW_ROWS)) {
703
+ const cell = row.children[cellIndex] as HTMLElement | undefined
704
+ if (cell) {
705
+ elements.push(cell)
706
+ }
707
+ }
708
+
709
+ return elements
710
+ }
711
+ })
712
+
713
+ export interface ColumnDragOptions {
714
+ root: HTMLElement
715
+ getScrollElement: () => HTMLElement | null
716
+ // Vetoes a target: the source itself, or a column in another pinned region —
717
+ // reordering never crosses a pinning boundary.
718
+ canDrop: (sourceKey: string, targetKey: string) => boolean
719
+ onReorder: (sourceKey: string, targetKey: string, after: boolean) => void
720
+ }
721
+
722
+ // The column half of the wiring, shared so each edition's adapter is the four
723
+ // lines that resolve its own table rather than a copy of the drag setup.
724
+ export const createColumnDragController = (options: ColumnDragOptions): DragController =>
725
+ createDragController({
726
+ axis: 'x',
727
+ root: options.root,
728
+ handle: SELECTOR_DRAGGABLE_COLUMN,
729
+ getScrollElement: options.getScrollElement,
730
+ resolveSource(element, target) {
731
+ // A press on the resize handle is a resize, not a reorder.
732
+ if (target.closest(SELECTOR_RESIZER)) {
733
+ return null
734
+ }
735
+
736
+ const id = element.dataset.coreuiColumn
737
+ return id ? { id, element } : null
738
+ },
739
+ items: columnItems(options.root),
740
+ // Only a column the header marks as draggable is a slot, on top of the
741
+ // consumer's own veto — an immovable column is not somewhere to land.
742
+ canDrop: (sourceId, targetId) =>
743
+ sourceId !== targetId &&
744
+ Boolean(options.root.querySelector(`th[data-coreui-column="${targetId}"][data-coreui-draggable]`)) &&
745
+ options.canDrop(sourceId, targetId),
746
+ preview: true,
747
+ onDrop: (sourceKey, drop) => options.onReorder(sourceKey, drop.id, drop.after)
748
+ })
749
+
750
+ const SELECTOR_ROW_HANDLE = '[data-coreui-row-handle]'
751
+ const SELECTOR_ROW = 'tr[data-coreui-index]'
752
+
753
+ const rowItems = (root: HTMLElement): DragItems => ({
754
+ getOrderedIds: () =>
755
+ [...root.querySelectorAll<HTMLElement>(`tbody ${SELECTOR_ROW}`)].map(row => row.dataset.coreuiIndex ?? ''),
756
+ getElements(id) {
757
+ const row = root.querySelector<HTMLElement>(`tbody tr[data-coreui-index="${id}"]`)
758
+ return row ? [row] : []
759
+ }
760
+ })
761
+
762
+ export interface RowDragOptions {
763
+ root: HTMLElement
764
+ getScrollElement: () => HTMLElement | null
765
+ onReorder: (sourceIndex: number, targetIndex: number, after: boolean) => void
766
+ }
767
+
768
+ // The row half. Unlike columns, the whole row travels but only the grip cell
769
+ // starts the drag — a row is covered in interactive content (checkboxes,
770
+ // editors, custom cells) that a whole-row handle would swallow.
771
+ export const createRowDragController = (options: RowDragOptions): DragController =>
772
+ createDragController({
773
+ axis: 'y',
774
+ root: options.root,
775
+ handle: SELECTOR_ROW_HANDLE,
776
+ getScrollElement: options.getScrollElement,
777
+ resolveSource(handle) {
778
+ const row = handle.closest<HTMLElement>(SELECTOR_ROW)
779
+ const id = row?.dataset.coreuiIndex
780
+ return row && id ? { id, element: row } : null
781
+ },
782
+ items: rowItems(options.root),
783
+ canDrop: (sourceId, targetId) => sourceId !== targetId,
784
+ // The row's own text is every cell run together; the first data cell reads
785
+ // as the row's name in practice.
786
+ // The row's own text is every cell run together, so the ghost carries the
787
+ // first DATA cell instead — found by skipping the leading handle and
788
+ // select cells rather than by data-coreui-column, which the body only
789
+ // carries when cellNavigation is on (without it the ghost came up empty).
790
+ ghostLabel: source =>
791
+ collapse(
792
+ source.element.querySelector(
793
+ 'td:not(.data-grid-row-handle-cell):not(.data-grid-select-cell)'
794
+ )?.textContent ?? ''
795
+ ),
796
+ preview: true,
797
+ onDrop: (sourceId, drop) => options.onReorder(Number(sourceId), Number(drop.id), drop.after)
798
+ })