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