| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302 |
- {{define "component/sortableTableTrigger"}}
- <a-icon type="drag" class="sortable-icon"
- role="button" tabindex="0"
- :aria-label="ariaLabel"
- @pointerdown="onPointerDown"
- @keydown="onKeyDown" />
- {{end}}
- {{define "component/aTableSortable"}}
- <script>
- /**
- * Sortable a-table — drag-to-reorder rows using Pointer Events.
- *
- * Why a rewrite:
- * - Old impl set `draggable: true` on every row, which (a) broke text
- * selection inside cells, (b) let HTML5 start a drag from anywhere on
- * the row even when the state machine wasn't primed, producing
- * "phantom drags" that didn't reorder anything.
- * - HTML5 drag has no touch support on most mobile browsers and no
- * keyboard fallback at all.
- * - The drag-image hack cloned the entire table — slow on big lists.
- *
- * New design:
- * - Only the explicit drag handle initiates a drag, via Pointer Events
- * (one API for mouse + touch + pen). Rows are not draggable.
- * - During drag, `data-source` is reordered live: the source row visually
- * slides into the target slot and other rows shift around it. The live
- * reorder IS the visual feedback — no separate floating preview.
- * - On commit, emits `onsort(sourceIndex, targetIndex)` — same event name
- * and signature as before, so existing call sites stay unchanged.
- * - Keyboard support: the handle is focusable; ArrowUp / ArrowDown move
- * the row by one; Escape cancels a pointer-drag in progress.
- */
- const ROW_CLASS = 'sortable-row';
- Vue.component('a-table-sortable', {
- data() {
- return {
- // null when idle. While dragging:
- // { sourceIndex, targetIndex, pointerId, sourceKey }
- drag: null,
- };
- },
- props: {
- 'data-source': { type: undefined, required: false },
- 'customRow': { type: undefined, required: false },
- 'row-key': { type: undefined, required: false },
- },
- inheritAttrs: false,
- provide() {
- const sortable = {};
- // Methods exposed to the trigger child via inject. Defined as getters
- // so `this` binds to the component instance, not the plain object.
- Object.defineProperty(sortable, 'startDrag', {
- enumerable: true,
- get: () => this.startDrag,
- });
- Object.defineProperty(sortable, 'moveByKeyboard', {
- enumerable: true,
- get: () => this.moveByKeyboard,
- });
- return { sortable };
- },
- beforeDestroy() {
- this.detachPointerListeners();
- },
- methods: {
- isDragging() { return this.drag !== null; },
- // Resolve the row key for a record. Used to identify the source row
- // even after data-source is reordered live during drag.
- keyOf(record, fallback) {
- const rk = this.rowKey;
- if (typeof rk === 'function') return rk(record);
- if (typeof rk === 'string') return record && record[rk];
- return fallback;
- },
- startDrag(e, sourceIndex) {
- // Primary button only (mouse left / first touch).
- if (e.button != null && e.button !== 0) return;
- e.preventDefault();
- const record = this.dataSource && this.dataSource[sourceIndex];
- this.drag = {
- sourceIndex,
- targetIndex: sourceIndex,
- pointerId: e.pointerId,
- sourceKey: this.keyOf(record, sourceIndex),
- };
- // Capture the pointer so move/up keep firing even if the cursor leaves
- // the icon. Try/catch because some older browsers throw on capture.
- if (e.target && typeof e.target.setPointerCapture === 'function' && e.pointerId != null) {
- try { e.target.setPointerCapture(e.pointerId); } catch (_) {}
- }
- this.attachPointerListeners();
- },
- attachPointerListeners() {
- this._onMove = (ev) => this.onPointerMove(ev);
- this._onUp = (ev) => this.onPointerUp(ev);
- this._onCancel = (ev) => this.cancelDrag(ev);
- document.addEventListener('pointermove', this._onMove, true);
- document.addEventListener('pointerup', this._onUp, true);
- document.addEventListener('pointercancel', this._onCancel, true);
- document.addEventListener('keydown', this._onCancel, true);
- },
- detachPointerListeners() {
- if (!this._onMove) return;
- document.removeEventListener('pointermove', this._onMove, true);
- document.removeEventListener('pointerup', this._onUp, true);
- document.removeEventListener('pointercancel', this._onCancel, true);
- document.removeEventListener('keydown', this._onCancel, true);
- this._onMove = this._onUp = this._onCancel = null;
- },
- onPointerMove(e) {
- if (!this.drag) return;
- if (this.drag.pointerId != null && e.pointerId !== this.drag.pointerId) return;
- // Hit-test: find which row the pointer Y is inside (or closest to).
- const rows = this.$el.querySelectorAll('tr.' + ROW_CLASS);
- if (!rows.length) return;
- const y = e.clientY;
- const firstRect = rows[0].getBoundingClientRect();
- const lastRect = rows[rows.length - 1].getBoundingClientRect();
- let target = this.drag.targetIndex;
- if (y < firstRect.top) {
- target = 0;
- } else if (y > lastRect.bottom) {
- target = rows.length - 1;
- } else {
- for (let i = 0; i < rows.length; i++) {
- const rect = rows[i].getBoundingClientRect();
- if (y >= rect.top && y <= rect.bottom) {
- target = i;
- break;
- }
- }
- }
- if (target !== this.drag.targetIndex) {
- this.drag = Object.assign({}, this.drag, { targetIndex: target });
- }
- },
- onPointerUp(e) {
- if (!this.drag) return;
- if (this.drag.pointerId != null && e.pointerId !== this.drag.pointerId) return;
- this.commitDrag();
- },
- commitDrag() {
- const d = this.drag;
- this.detachPointerListeners();
- this.drag = null;
- if (d && d.sourceIndex !== d.targetIndex) {
- this.$emit('onsort', d.sourceIndex, d.targetIndex);
- }
- },
- cancelDrag(e) {
- // Triggered by pointercancel and keydown handlers. For keydown, only
- // act on Escape; otherwise let the event flow to other listeners.
- if (e && e.type === 'keydown' && e.key !== 'Escape') return;
- this.detachPointerListeners();
- this.drag = null;
- },
- // Keyboard reorder: commit immediately by emitting onsort. No "preview"
- // state needed since the move is one row up or down.
- moveByKeyboard(direction, sourceIndex) {
- const target = sourceIndex + direction;
- if (target < 0 || target >= (this.dataSource || []).length) return;
- this.$emit('onsort', sourceIndex, target);
- },
- customRowRender(record, index) {
- const parent = (typeof this.customRow === 'function')
- ? (this.customRow(record, index) || {})
- : {};
- const d = this.drag;
- const isSource = d && this.keyOf(record, index) === d.sourceKey;
- return Object.assign({}, parent, {
- // CRITICAL: no `draggable: true`. Drag is initiated only by the
- // handle icon. Leaves text-selection on cells working normally.
- attrs: Object.assign({}, parent.attrs || {}),
- class: Object.assign({}, parent.class || {}, {
- [ROW_CLASS]: true,
- 'sortable-source-row': !!isSource,
- }),
- });
- },
- },
- computed: {
- // Render-data: dataSource with the source row spliced into targetIndex.
- // When idle or when target equals source, returns the original list
- // unchanged so Ant Design's table treats this as a stable reference.
- records() {
- const d = this.drag;
- if (!d || d.sourceIndex === d.targetIndex) return this.dataSource;
- const list = (this.dataSource || []).slice();
- const [item] = list.splice(d.sourceIndex, 1);
- list.splice(d.targetIndex, 0, item);
- return list;
- },
- },
- render(h) {
- return h('a-table', {
- class: { 'sortable-table': true, 'sortable-table-dragging': this.isDragging() },
- props: Object.assign({}, this.$attrs, {
- 'data-source': this.records,
- 'row-key': this.rowKey,
- customRow: (record, index) => this.customRowRender(record, index),
- locale: {
- filterConfirm: `{{ i18n "confirm" }}`,
- filterReset: `{{ i18n "reset" }}`,
- emptyText: `{{ i18n "noData" }}`,
- },
- }),
- on: this.$listeners,
- scopedSlots: this.$scopedSlots,
- }, this.$slots.default);
- },
- });
- Vue.component('a-table-sort-trigger', {
- template: `{{template "component/sortableTableTrigger" .}}`,
- props: {
- 'item-index': { type: undefined, required: false },
- },
- inject: ['sortable'],
- computed: {
- ariaLabel() {
- // Localised label is overkill for an internal a11y string; English is
- // fine here and matches screen-reader expectations across locales.
- return 'Drag to reorder row ' + (((this.itemIndex == null ? 0 : this.itemIndex) + 1));
- },
- },
- methods: {
- onPointerDown(e) {
- if (this.sortable && this.sortable.startDrag) {
- this.sortable.startDrag(e, this.itemIndex);
- }
- },
- onKeyDown(e) {
- if (!this.sortable || !this.sortable.moveByKeyboard) return;
- if (e.key === 'ArrowUp') {
- e.preventDefault();
- this.sortable.moveByKeyboard(-1, this.itemIndex);
- } else if (e.key === 'ArrowDown') {
- e.preventDefault();
- this.sortable.moveByKeyboard(+1, this.itemIndex);
- }
- },
- },
- });
- </script>
- <style>
- /* Drag handle — focusable, keyboard-accessible, touch-friendly hit area.
- `touch-action: none` is critical: it tells the browser not to interpret
- touch on the icon as a scroll/zoom gesture, so pointermove fires for
- drag-tracking. Without it, mobile browsers eat the pointer events. */
- .sortable-icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- cursor: grab;
- padding: 6px;
- border-radius: 6px;
- color: rgba(255, 255, 255, 0.5);
- transition: background-color 0.15s ease, color 0.15s ease;
- user-select: none;
- touch-action: none;
- }
- .sortable-icon:hover {
- color: rgba(255, 255, 255, 0.85);
- background: rgba(255, 255, 255, 0.06);
- }
- .sortable-icon:active { cursor: grabbing; }
- .sortable-icon:focus-visible {
- outline: 2px solid #008771;
- outline-offset: 2px;
- }
- .light .sortable-icon { color: rgba(0, 0, 0, 0.45); }
- .light .sortable-icon:hover {
- color: rgba(0, 0, 0, 0.85);
- background: rgba(0, 0, 0, 0.05);
- }
- /* While dragging: the source row gets a soft green wash so the user can
- track which row is being moved. Other rows transition smoothly as the
- data-source is reordered. */
- .sortable-table-dragging .sortable-source-row > td {
- background: rgba(0, 135, 113, 0.10) !important;
- transition: background-color 0.18s ease;
- }
- .sortable-table-dragging .sortable-source-row .routing-index,
- .sortable-table-dragging .sortable-source-row .outbound-index {
- opacity: 0.45;
- }
- .sortable-table-dragging .sortable-row > td {
- transition: background-color 0.18s ease;
- }
- /* Disable text selection across the whole table while a drag is in
- progress — selection during drag is never useful and looks broken. */
- .sortable-table-dragging,
- .sortable-table-dragging * {
- user-select: none;
- }
- </style>
- {{end}}
|