aTableSortable.html 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. {{define "component/sortableTableTrigger"}}
  2. <a-icon type="drag" class="sortable-icon"
  3. role="button" tabindex="0"
  4. :aria-label="ariaLabel"
  5. @pointerdown="onPointerDown"
  6. @keydown="onKeyDown" />
  7. {{end}}
  8. {{define "component/aTableSortable"}}
  9. <script>
  10. /**
  11. * Sortable a-table — drag-to-reorder rows using Pointer Events.
  12. *
  13. * Why a rewrite:
  14. * - Old impl set `draggable: true` on every row, which (a) broke text
  15. * selection inside cells, (b) let HTML5 start a drag from anywhere on
  16. * the row even when the state machine wasn't primed, producing
  17. * "phantom drags" that didn't reorder anything.
  18. * - HTML5 drag has no touch support on most mobile browsers and no
  19. * keyboard fallback at all.
  20. * - The drag-image hack cloned the entire table — slow on big lists.
  21. *
  22. * New design:
  23. * - Only the explicit drag handle initiates a drag, via Pointer Events
  24. * (one API for mouse + touch + pen). Rows are not draggable.
  25. * - During drag, `data-source` is reordered live: the source row visually
  26. * slides into the target slot and other rows shift around it. The live
  27. * reorder IS the visual feedback — no separate floating preview.
  28. * - On commit, emits `onsort(sourceIndex, targetIndex)` — same event name
  29. * and signature as before, so existing call sites stay unchanged.
  30. * - Keyboard support: the handle is focusable; ArrowUp / ArrowDown move
  31. * the row by one; Escape cancels a pointer-drag in progress.
  32. */
  33. const ROW_CLASS = 'sortable-row';
  34. Vue.component('a-table-sortable', {
  35. data() {
  36. return {
  37. // null when idle. While dragging:
  38. // { sourceIndex, targetIndex, pointerId, sourceKey }
  39. drag: null,
  40. };
  41. },
  42. props: {
  43. 'data-source': { type: undefined, required: false },
  44. 'customRow': { type: undefined, required: false },
  45. 'row-key': { type: undefined, required: false },
  46. },
  47. inheritAttrs: false,
  48. provide() {
  49. const sortable = {};
  50. // Methods exposed to the trigger child via inject. Defined as getters
  51. // so `this` binds to the component instance, not the plain object.
  52. Object.defineProperty(sortable, 'startDrag', {
  53. enumerable: true,
  54. get: () => this.startDrag,
  55. });
  56. Object.defineProperty(sortable, 'moveByKeyboard', {
  57. enumerable: true,
  58. get: () => this.moveByKeyboard,
  59. });
  60. return { sortable };
  61. },
  62. beforeDestroy() {
  63. this.detachPointerListeners();
  64. },
  65. methods: {
  66. isDragging() { return this.drag !== null; },
  67. // Resolve the row key for a record. Used to identify the source row
  68. // even after data-source is reordered live during drag.
  69. keyOf(record, fallback) {
  70. const rk = this.rowKey;
  71. if (typeof rk === 'function') return rk(record);
  72. if (typeof rk === 'string') return record && record[rk];
  73. return fallback;
  74. },
  75. startDrag(e, sourceIndex) {
  76. // Primary button only (mouse left / first touch).
  77. if (e.button != null && e.button !== 0) return;
  78. e.preventDefault();
  79. const record = this.dataSource && this.dataSource[sourceIndex];
  80. this.drag = {
  81. sourceIndex,
  82. targetIndex: sourceIndex,
  83. pointerId: e.pointerId,
  84. sourceKey: this.keyOf(record, sourceIndex),
  85. };
  86. // Capture the pointer so move/up keep firing even if the cursor leaves
  87. // the icon. Try/catch because some older browsers throw on capture.
  88. if (e.target && typeof e.target.setPointerCapture === 'function' && e.pointerId != null) {
  89. try { e.target.setPointerCapture(e.pointerId); } catch (_) {}
  90. }
  91. this.attachPointerListeners();
  92. },
  93. attachPointerListeners() {
  94. this._onMove = (ev) => this.onPointerMove(ev);
  95. this._onUp = (ev) => this.onPointerUp(ev);
  96. this._onCancel = (ev) => this.cancelDrag(ev);
  97. document.addEventListener('pointermove', this._onMove, true);
  98. document.addEventListener('pointerup', this._onUp, true);
  99. document.addEventListener('pointercancel', this._onCancel, true);
  100. document.addEventListener('keydown', this._onCancel, true);
  101. },
  102. detachPointerListeners() {
  103. if (!this._onMove) return;
  104. document.removeEventListener('pointermove', this._onMove, true);
  105. document.removeEventListener('pointerup', this._onUp, true);
  106. document.removeEventListener('pointercancel', this._onCancel, true);
  107. document.removeEventListener('keydown', this._onCancel, true);
  108. this._onMove = this._onUp = this._onCancel = null;
  109. },
  110. onPointerMove(e) {
  111. if (!this.drag) return;
  112. if (this.drag.pointerId != null && e.pointerId !== this.drag.pointerId) return;
  113. // Hit-test: find which row the pointer Y is inside (or closest to).
  114. const rows = this.$el.querySelectorAll('tr.' + ROW_CLASS);
  115. if (!rows.length) return;
  116. const y = e.clientY;
  117. const firstRect = rows[0].getBoundingClientRect();
  118. const lastRect = rows[rows.length - 1].getBoundingClientRect();
  119. let target = this.drag.targetIndex;
  120. if (y < firstRect.top) {
  121. target = 0;
  122. } else if (y > lastRect.bottom) {
  123. target = rows.length - 1;
  124. } else {
  125. for (let i = 0; i < rows.length; i++) {
  126. const rect = rows[i].getBoundingClientRect();
  127. if (y >= rect.top && y <= rect.bottom) {
  128. target = i;
  129. break;
  130. }
  131. }
  132. }
  133. if (target !== this.drag.targetIndex) {
  134. this.drag = Object.assign({}, this.drag, { targetIndex: target });
  135. }
  136. },
  137. onPointerUp(e) {
  138. if (!this.drag) return;
  139. if (this.drag.pointerId != null && e.pointerId !== this.drag.pointerId) return;
  140. this.commitDrag();
  141. },
  142. commitDrag() {
  143. const d = this.drag;
  144. this.detachPointerListeners();
  145. this.drag = null;
  146. if (d && d.sourceIndex !== d.targetIndex) {
  147. this.$emit('onsort', d.sourceIndex, d.targetIndex);
  148. }
  149. },
  150. cancelDrag(e) {
  151. // Triggered by pointercancel and keydown handlers. For keydown, only
  152. // act on Escape; otherwise let the event flow to other listeners.
  153. if (e && e.type === 'keydown' && e.key !== 'Escape') return;
  154. this.detachPointerListeners();
  155. this.drag = null;
  156. },
  157. // Keyboard reorder: commit immediately by emitting onsort. No "preview"
  158. // state needed since the move is one row up or down.
  159. moveByKeyboard(direction, sourceIndex) {
  160. const target = sourceIndex + direction;
  161. if (target < 0 || target >= (this.dataSource || []).length) return;
  162. this.$emit('onsort', sourceIndex, target);
  163. },
  164. customRowRender(record, index) {
  165. const parent = (typeof this.customRow === 'function')
  166. ? (this.customRow(record, index) || {})
  167. : {};
  168. const d = this.drag;
  169. const isSource = d && this.keyOf(record, index) === d.sourceKey;
  170. return Object.assign({}, parent, {
  171. // CRITICAL: no `draggable: true`. Drag is initiated only by the
  172. // handle icon. Leaves text-selection on cells working normally.
  173. attrs: Object.assign({}, parent.attrs || {}),
  174. class: Object.assign({}, parent.class || {}, {
  175. [ROW_CLASS]: true,
  176. 'sortable-source-row': !!isSource,
  177. }),
  178. });
  179. },
  180. },
  181. computed: {
  182. // Render-data: dataSource with the source row spliced into targetIndex.
  183. // When idle or when target equals source, returns the original list
  184. // unchanged so Ant Design's table treats this as a stable reference.
  185. records() {
  186. const d = this.drag;
  187. if (!d || d.sourceIndex === d.targetIndex) return this.dataSource;
  188. const list = (this.dataSource || []).slice();
  189. const [item] = list.splice(d.sourceIndex, 1);
  190. list.splice(d.targetIndex, 0, item);
  191. return list;
  192. },
  193. },
  194. render(h) {
  195. return h('a-table', {
  196. class: { 'sortable-table': true, 'sortable-table-dragging': this.isDragging() },
  197. props: Object.assign({}, this.$attrs, {
  198. 'data-source': this.records,
  199. 'row-key': this.rowKey,
  200. customRow: (record, index) => this.customRowRender(record, index),
  201. locale: {
  202. filterConfirm: `{{ i18n "confirm" }}`,
  203. filterReset: `{{ i18n "reset" }}`,
  204. emptyText: `{{ i18n "noData" }}`,
  205. },
  206. }),
  207. on: this.$listeners,
  208. scopedSlots: this.$scopedSlots,
  209. }, this.$slots.default);
  210. },
  211. });
  212. Vue.component('a-table-sort-trigger', {
  213. template: `{{template "component/sortableTableTrigger" .}}`,
  214. props: {
  215. 'item-index': { type: undefined, required: false },
  216. },
  217. inject: ['sortable'],
  218. computed: {
  219. ariaLabel() {
  220. // Localised label is overkill for an internal a11y string; English is
  221. // fine here and matches screen-reader expectations across locales.
  222. return 'Drag to reorder row ' + (((this.itemIndex == null ? 0 : this.itemIndex) + 1));
  223. },
  224. },
  225. methods: {
  226. onPointerDown(e) {
  227. if (this.sortable && this.sortable.startDrag) {
  228. this.sortable.startDrag(e, this.itemIndex);
  229. }
  230. },
  231. onKeyDown(e) {
  232. if (!this.sortable || !this.sortable.moveByKeyboard) return;
  233. if (e.key === 'ArrowUp') {
  234. e.preventDefault();
  235. this.sortable.moveByKeyboard(-1, this.itemIndex);
  236. } else if (e.key === 'ArrowDown') {
  237. e.preventDefault();
  238. this.sortable.moveByKeyboard(+1, this.itemIndex);
  239. }
  240. },
  241. },
  242. });
  243. </script>
  244. <style>
  245. /* Drag handle — focusable, keyboard-accessible, touch-friendly hit area.
  246. `touch-action: none` is critical: it tells the browser not to interpret
  247. touch on the icon as a scroll/zoom gesture, so pointermove fires for
  248. drag-tracking. Without it, mobile browsers eat the pointer events. */
  249. .sortable-icon {
  250. display: inline-flex;
  251. align-items: center;
  252. justify-content: center;
  253. cursor: grab;
  254. padding: 6px;
  255. border-radius: 6px;
  256. color: rgba(255, 255, 255, 0.5);
  257. transition: background-color 0.15s ease, color 0.15s ease;
  258. user-select: none;
  259. touch-action: none;
  260. }
  261. .sortable-icon:hover {
  262. color: rgba(255, 255, 255, 0.85);
  263. background: rgba(255, 255, 255, 0.06);
  264. }
  265. .sortable-icon:active { cursor: grabbing; }
  266. .sortable-icon:focus-visible {
  267. outline: 2px solid #008771;
  268. outline-offset: 2px;
  269. }
  270. .light .sortable-icon { color: rgba(0, 0, 0, 0.45); }
  271. .light .sortable-icon:hover {
  272. color: rgba(0, 0, 0, 0.85);
  273. background: rgba(0, 0, 0, 0.05);
  274. }
  275. /* While dragging: the source row gets a soft green wash so the user can
  276. track which row is being moved. Other rows transition smoothly as the
  277. data-source is reordered. */
  278. .sortable-table-dragging .sortable-source-row > td {
  279. background: rgba(0, 135, 113, 0.10) !important;
  280. transition: background-color 0.18s ease;
  281. }
  282. .sortable-table-dragging .sortable-source-row .routing-index,
  283. .sortable-table-dragging .sortable-source-row .outbound-index {
  284. opacity: 0.45;
  285. }
  286. .sortable-table-dragging .sortable-row > td {
  287. transition: background-color 0.18s ease;
  288. }
  289. /* Disable text selection across the whole table while a drag is in
  290. progress — selection during drag is never useful and looks broken. */
  291. .sortable-table-dragging,
  292. .sortable-table-dragging * {
  293. user-select: none;
  294. }
  295. </style>
  296. {{end}}