TableSortable.vue 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. <script>
  2. // Use defineComponent so we can keep the parent + child components in
  3. // the same file with the provide() <-> inject relationship intact.
  4. import { defineComponent, h, computed, ref, resolveComponent, inject } from 'vue';
  5. import { DragOutlined } from '@ant-design/icons-vue';
  6. const ROW_CLASS = 'sortable-row';
  7. // Sortable a-table — drag-to-reorder rows using Pointer Events.
  8. //
  9. // Why a custom component:
  10. // - Old impl set draggable: true on every row, which broke text selection
  11. // in cells and let HTML5 start drags from anywhere on the row. This
  12. // version only initiates drag from an explicit handle, via Pointer
  13. // Events (one API for mouse + touch + pen).
  14. // - During drag, data-source is reordered live; the source row visually
  15. // slides into the target slot. The live reorder IS the visual feedback.
  16. // - On commit, emits onsort(sourceIndex, targetIndex) — same signature as
  17. // before so existing call sites stay unchanged.
  18. // - Keyboard support: ArrowUp/ArrowDown move the focused handle's row by
  19. // one; Escape cancels an in-flight drag.
  20. export const TableSortableTrigger = defineComponent({
  21. name: 'TableSortableTrigger',
  22. props: {
  23. itemIndex: { type: Number, required: true },
  24. },
  25. setup(props) {
  26. const sortable = inject('sortable', null);
  27. const ariaLabel = computed(() => `Drag to reorder row ${(props.itemIndex ?? 0) + 1}`);
  28. function onPointerDown(e) {
  29. sortable?.startDrag?.(e, props.itemIndex);
  30. }
  31. function onKeyDown(e) {
  32. const move = sortable?.moveByKeyboard;
  33. if (!move) return;
  34. if (e.key === 'ArrowUp') {
  35. e.preventDefault();
  36. move(-1, props.itemIndex);
  37. } else if (e.key === 'ArrowDown') {
  38. e.preventDefault();
  39. move(+1, props.itemIndex);
  40. }
  41. }
  42. return () => h(DragOutlined, {
  43. class: 'sortable-icon',
  44. role: 'button',
  45. tabindex: 0,
  46. 'aria-label': ariaLabel.value,
  47. onPointerdown: onPointerDown,
  48. onKeydown: onKeyDown,
  49. });
  50. },
  51. });
  52. export default defineComponent({
  53. name: 'TableSortable',
  54. inheritAttrs: false,
  55. props: {
  56. dataSource: { type: Array, default: () => [] },
  57. customRow: { type: Function, default: null },
  58. rowKey: { type: [String, Function], default: null },
  59. locale: {
  60. type: Object,
  61. default: () => ({ filterConfirm: 'OK', filterReset: 'Reset', emptyText: 'No data' }),
  62. },
  63. },
  64. emits: ['onsort'],
  65. setup(props, { emit, slots, attrs, expose }) {
  66. // null when idle; while dragging:
  67. // { sourceIndex, targetIndex, pointerId, sourceKey }
  68. const drag = ref(null);
  69. const rootRef = ref(null);
  70. const isDragging = computed(() => drag.value !== null);
  71. // Resolve the row key for a record. Used to identify the source row
  72. // even after data-source is reordered live during drag.
  73. function keyOf(record, fallback) {
  74. const rk = props.rowKey;
  75. if (typeof rk === 'function') return rk(record);
  76. if (typeof rk === 'string') return record?.[rk];
  77. return fallback;
  78. }
  79. function attachListeners() {
  80. document.addEventListener('pointermove', onPointerMove, true);
  81. document.addEventListener('pointerup', onPointerUp, true);
  82. document.addEventListener('pointercancel', cancelDrag, true);
  83. document.addEventListener('keydown', cancelDrag, true);
  84. }
  85. function detachListeners() {
  86. document.removeEventListener('pointermove', onPointerMove, true);
  87. document.removeEventListener('pointerup', onPointerUp, true);
  88. document.removeEventListener('pointercancel', cancelDrag, true);
  89. document.removeEventListener('keydown', cancelDrag, true);
  90. }
  91. function startDrag(e, sourceIndex) {
  92. // Primary button only (mouse left / first touch).
  93. if (e.button != null && e.button !== 0) return;
  94. e.preventDefault();
  95. const record = props.dataSource?.[sourceIndex];
  96. drag.value = {
  97. sourceIndex,
  98. targetIndex: sourceIndex,
  99. pointerId: e.pointerId,
  100. sourceKey: keyOf(record, sourceIndex),
  101. };
  102. // Capture the pointer so move/up keep firing even if the cursor
  103. // leaves the icon. Try/catch — some older browsers throw on capture.
  104. if (e.target?.setPointerCapture && e.pointerId != null) {
  105. try { e.target.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
  106. }
  107. attachListeners();
  108. }
  109. function onPointerMove(e) {
  110. const d = drag.value;
  111. if (!d) return;
  112. if (d.pointerId != null && e.pointerId !== d.pointerId) return;
  113. const root = rootRef.value;
  114. if (!root) return;
  115. const rows = root.querySelectorAll(`tr.${ROW_CLASS}`);
  116. if (!rows.length) return;
  117. const y = e.clientY;
  118. const firstRect = rows[0].getBoundingClientRect();
  119. const lastRect = rows[rows.length - 1].getBoundingClientRect();
  120. let target = d.targetIndex;
  121. if (y < firstRect.top) {
  122. target = 0;
  123. } else if (y > lastRect.bottom) {
  124. target = rows.length - 1;
  125. } else {
  126. for (let i = 0; i < rows.length; i++) {
  127. const rect = rows[i].getBoundingClientRect();
  128. if (y >= rect.top && y <= rect.bottom) {
  129. target = i;
  130. break;
  131. }
  132. }
  133. }
  134. if (target !== d.targetIndex) {
  135. drag.value = { ...d, targetIndex: target };
  136. }
  137. }
  138. function onPointerUp(e) {
  139. const d = drag.value;
  140. if (!d) return;
  141. if (d.pointerId != null && e.pointerId !== d.pointerId) return;
  142. detachListeners();
  143. const captured = d;
  144. drag.value = null;
  145. if (captured.sourceIndex !== captured.targetIndex) {
  146. emit('onsort', captured.sourceIndex, captured.targetIndex);
  147. }
  148. }
  149. function cancelDrag(e) {
  150. // Triggered by pointercancel and keydown. For keydown only act on
  151. // Escape; otherwise let the event propagate.
  152. if (e?.type === 'keydown' && e.key !== 'Escape') return;
  153. detachListeners();
  154. drag.value = null;
  155. }
  156. function moveByKeyboard(direction, sourceIndex) {
  157. const target = sourceIndex + direction;
  158. if (target < 0 || target >= (props.dataSource?.length ?? 0)) return;
  159. emit('onsort', sourceIndex, target);
  160. }
  161. function customRowRender(record, index) {
  162. const parent = typeof props.customRow === 'function' ? props.customRow(record, index) || {} : {};
  163. const d = drag.value;
  164. const isSource = d && keyOf(record, index) === d.sourceKey;
  165. // Vue 3 customRow shape: a flat object of attrs/listeners/class —
  166. // no nested props/on like Vue 2.
  167. return {
  168. ...parent,
  169. class: { [ROW_CLASS]: true, 'sortable-source-row': !!isSource, ...(parent.class || {}) },
  170. };
  171. }
  172. // Render-data: dataSource with the source row spliced into targetIndex.
  173. // When idle the original list is returned unchanged so a-table can
  174. // diff against a stable reference.
  175. const records = computed(() => {
  176. const d = drag.value;
  177. const src = props.dataSource ?? [];
  178. if (!d || d.sourceIndex === d.targetIndex) return src;
  179. const list = src.slice();
  180. const [item] = list.splice(d.sourceIndex, 1);
  181. list.splice(d.targetIndex, 0, item);
  182. return list;
  183. });
  184. expose({ startDrag, moveByKeyboard });
  185. return {
  186. rootRef, drag, isDragging, records, slots, attrs,
  187. startDrag, moveByKeyboard, customRowRender,
  188. };
  189. },
  190. // provide() needs to live at the options level so child components in
  191. // the rendered subtree resolve the same instance methods.
  192. provide() {
  193. return {
  194. sortable: {
  195. startDrag: (...a) => this.startDrag(...a),
  196. moveByKeyboard: (...a) => this.moveByKeyboard(...a),
  197. },
  198. };
  199. },
  200. beforeUnmount() {
  201. document.removeEventListener('pointermove', this.onPointerMove, true);
  202. document.removeEventListener('pointerup', this.onPointerUp, true);
  203. document.removeEventListener('pointercancel', this.cancelDrag, true);
  204. document.removeEventListener('keydown', this.cancelDrag, true);
  205. },
  206. render() {
  207. // Forward every passed slot to a-table by reusing the slot fn
  208. // directly. Vue 3 slots are scoped by default so no $scopedSlots dance.
  209. const tableSlots = {};
  210. for (const name of Object.keys(this.slots)) {
  211. tableSlots[name] = this.slots[name];
  212. }
  213. // Resolved at runtime so the user's app.use(Antd) registration wins;
  214. // avoids importing Table directly here.
  215. const ATable = resolveComponent('a-table');
  216. return h(
  217. 'div',
  218. { ref: 'rootRef' },
  219. [h(
  220. ATable,
  221. {
  222. ...this.attrs,
  223. 'data-source': this.records,
  224. 'row-key': this.rowKey,
  225. customRow: this.customRowRender,
  226. locale: this.locale,
  227. class: ['sortable-table', { 'sortable-table-dragging': this.isDragging }],
  228. },
  229. tableSlots,
  230. )],
  231. );
  232. },
  233. });
  234. </script>
  235. <style>
  236. .sortable-icon {
  237. display: inline-flex;
  238. align-items: center;
  239. justify-content: center;
  240. cursor: grab;
  241. padding: 6px;
  242. border-radius: 6px;
  243. color: rgba(255, 255, 255, 0.5);
  244. transition: background-color 0.15s ease, color 0.15s ease;
  245. user-select: none;
  246. touch-action: none;
  247. }
  248. .sortable-icon:hover {
  249. color: rgba(255, 255, 255, 0.85);
  250. background: rgba(255, 255, 255, 0.06);
  251. }
  252. .sortable-icon:active { cursor: grabbing; }
  253. .sortable-icon:focus-visible {
  254. outline: 2px solid #008771;
  255. outline-offset: 2px;
  256. }
  257. .light .sortable-icon { color: rgba(0, 0, 0, 0.45); }
  258. .light .sortable-icon:hover {
  259. color: rgba(0, 0, 0, 0.85);
  260. background: rgba(0, 0, 0, 0.05);
  261. }
  262. .sortable-table-dragging .sortable-source-row > td {
  263. background: rgba(0, 135, 113, 0.10) !important;
  264. transition: background-color 0.18s ease;
  265. }
  266. .sortable-table-dragging .sortable-source-row .routing-index,
  267. .sortable-table-dragging .sortable-source-row .outbound-index {
  268. opacity: 0.45;
  269. }
  270. .sortable-table-dragging .sortable-row > td {
  271. transition: background-color 0.18s ease;
  272. }
  273. .sortable-table-dragging,
  274. .sortable-table-dragging * {
  275. user-select: none;
  276. }
  277. </style>