@celestia-island/hikari 0.55.115 → 0.55.116
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.
- package/package.json +1 -1
- package/src/components/HkAdminTablePage.tsx +14 -2
- package/src/components/HkTable.drag.test.tsx +499 -0
- package/src/components/HkTable.scss +86 -0
- package/src/components/HkTable.tsx +203 -7
- package/src/i18n/locales/ar/components.json +2 -0
- package/src/i18n/locales/de/components.json +2 -0
- package/src/i18n/locales/en/components.json +2 -0
- package/src/i18n/locales/es/components.json +2 -0
- package/src/i18n/locales/fr/components.json +2 -0
- package/src/i18n/locales/ja/components.json +2 -0
- package/src/i18n/locales/ko/components.json +2 -0
- package/src/i18n/locales/pt/components.json +2 -0
- package/src/i18n/locales/ru/components.json +2 -0
- package/src/i18n/locales/zh-Hans/components.json +2 -0
- package/src/i18n/locales/zh-Hant/components.json +2 -0
package/package.json
CHANGED
|
@@ -50,8 +50,14 @@ export const HkAdminTablePage = defineComponent({
|
|
|
50
50
|
rowKey: { type: String, default: "id" },
|
|
51
51
|
emptyTitle: { type: String, default: "" },
|
|
52
52
|
emptyDescription: { type: String as PropType<string | undefined>, default: undefined },
|
|
53
|
+
/** Left grip column with drag-to-reorder (forwarded to HTable; the
|
|
54
|
+
* `reorder` event is forwarded verbatim — indices into `rows`). */
|
|
55
|
+
draggable: { type: Boolean, default: false },
|
|
53
56
|
},
|
|
54
|
-
|
|
57
|
+
emits: {
|
|
58
|
+
reorder: (_fromIndex: number, _toIndex: number) => true,
|
|
59
|
+
},
|
|
60
|
+
setup(props, { emit, slots }) {
|
|
55
61
|
|
|
56
62
|
return () => {
|
|
57
63
|
const { t } = useI18n();
|
|
@@ -96,7 +102,13 @@ export const HkAdminTablePage = defineComponent({
|
|
|
96
102
|
<div class="hk-admin-table-page-body">{slots.body()}</div>
|
|
97
103
|
) : (
|
|
98
104
|
<HCard padded={false}>
|
|
99
|
-
<HTable
|
|
105
|
+
<HTable
|
|
106
|
+
columns={props.columns}
|
|
107
|
+
rows={props.rows}
|
|
108
|
+
rowKey={props.rowKey}
|
|
109
|
+
draggable={props.draggable}
|
|
110
|
+
onReorder={(from: number, to: number) => emit("reorder", from, to)}
|
|
111
|
+
>
|
|
100
112
|
{slots}
|
|
101
113
|
</HTable>
|
|
102
114
|
</HCard>
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, h, nextTick, ref } from "vue";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import HTable from "./HkTable";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* HkTable drag-to-reorder contract:
|
|
11
|
+
* - no grip column unless `draggable`
|
|
12
|
+
* - a focused handle's ArrowUp/Down nudges the row one slot and emits
|
|
13
|
+
* `reorder(from, to)` as indices into `rows` (the consumer applies
|
|
14
|
+
* moveTo(rows, from, to) — insert-at-target)
|
|
15
|
+
* - a pointer drag past the neighbours' midpoints emits the same pair
|
|
16
|
+
* - the emit is INERT while a column sort is active (a derived arrangement
|
|
17
|
+
* cannot be reproduced by a two-index move) and the grips announce it
|
|
18
|
+
* - a cancelled gesture never emits
|
|
19
|
+
* - HkAdminTablePage forwards `draggable` + `reorder` (source contract)
|
|
20
|
+
*
|
|
21
|
+
* (Repo test convention: raw createApp + document queries, no
|
|
22
|
+
* @vue/test-utils; happy-dom ships no layout engine, so row geometry is
|
|
23
|
+
* pinned by hand — 40px rows stacked along y.)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
while (mounts.length) {
|
|
30
|
+
const { app, container } = mounts.pop()!;
|
|
31
|
+
app.unmount();
|
|
32
|
+
container.remove();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
interface RowsRef {
|
|
37
|
+
rows: ReturnType<typeof ref<Record<string, unknown>[]>>;
|
|
38
|
+
emitted: Array<[number, number]>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Mount a draggable table of `count` rows `{ name, n }`, pinning each
|
|
42
|
+
* body row to a 40px band (y = i*40) the way a real layout engine would.
|
|
43
|
+
* `sortable` mounts with sortable columns for the inert-while-sorted
|
|
44
|
+
* case. Returns the reactive rows, the emitted reorder pairs and the
|
|
45
|
+
* contract's moveTo applier. */
|
|
46
|
+
function mountTable(
|
|
47
|
+
count: number,
|
|
48
|
+
opts: { sortable?: boolean } = {},
|
|
49
|
+
): RowsRef & {
|
|
50
|
+
container: HTMLElement;
|
|
51
|
+
apply(from: number, to: number): Promise<void>;
|
|
52
|
+
setOrder(next: Record<string, unknown>[]): Promise<void>;
|
|
53
|
+
} {
|
|
54
|
+
const container = document.createElement("div");
|
|
55
|
+
document.body.appendChild(container);
|
|
56
|
+
const rows = ref<Record<string, unknown>[]>(
|
|
57
|
+
Array.from({ length: count }, (_, i) => ({ name: `Row-${i}`, n: count - i })),
|
|
58
|
+
);
|
|
59
|
+
const emitted: Array<[number, number]> = [];
|
|
60
|
+
const app = createApp({
|
|
61
|
+
render: () =>
|
|
62
|
+
h(HTable, {
|
|
63
|
+
columns: [
|
|
64
|
+
{ key: "name", title: "Name" },
|
|
65
|
+
{ key: "n", title: "N", sortable: opts.sortable === true },
|
|
66
|
+
],
|
|
67
|
+
rows: rows.value,
|
|
68
|
+
rowKey: "name",
|
|
69
|
+
draggable: true,
|
|
70
|
+
onReorder: (from: number, to: number) => emitted.push([from, to]),
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
mounts.push({ app, container });
|
|
74
|
+
app.mount(container);
|
|
75
|
+
const host = container;
|
|
76
|
+
// Pin geometry: the row `tr`s in display order.
|
|
77
|
+
const trs = host.querySelectorAll("tbody .hk-table-row");
|
|
78
|
+
trs.forEach((tr, i) => {
|
|
79
|
+
const start = i * 40;
|
|
80
|
+
const box = { left: 0, right: 300, top: start, bottom: start + 40, width: 300, height: 40, x: 0, y: start };
|
|
81
|
+
Object.defineProperty(tr, "getBoundingClientRect", { configurable: true, value: () => box as DOMRect });
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
rows,
|
|
85
|
+
emitted,
|
|
86
|
+
container: host,
|
|
87
|
+
/** Apply the contract's moveTo to the array and flush. */
|
|
88
|
+
async apply(from: number, to: number): Promise<void> {
|
|
89
|
+
const next = rows.value.slice();
|
|
90
|
+
const [moved] = next.splice(from, 1);
|
|
91
|
+
next.splice(to, 0, moved!);
|
|
92
|
+
rows.value = next;
|
|
93
|
+
await nextTick();
|
|
94
|
+
},
|
|
95
|
+
/** Replace the consumer's array outright (any reorder, including ones a
|
|
96
|
+
* single moveTo cannot express) and flush. */
|
|
97
|
+
async setOrder(next: Record<string, unknown>[]): Promise<void> {
|
|
98
|
+
rows.value = next;
|
|
99
|
+
await nextTick();
|
|
100
|
+
// Re-pin geometry: the keyed `tr`s move with the data, so display
|
|
101
|
+
// order changed under the same elements.
|
|
102
|
+
host.querySelectorAll("tbody .hk-table-row").forEach((tr, i) => {
|
|
103
|
+
const start = i * 40;
|
|
104
|
+
const box = { left: 0, right: 300, top: start, bottom: start + 40, width: 300, height: 40, x: 0, y: start };
|
|
105
|
+
Object.defineProperty(tr, "getBoundingClientRect", { configurable: true, value: () => box as DOMRect });
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function handles(host: HTMLElement): NodeListOf<HTMLButtonElement> {
|
|
112
|
+
return host.querySelectorAll<HTMLButtonElement>(".hk-table-drag-handle");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function pressKey(handle: HTMLButtonElement, key: string): Promise<void> {
|
|
116
|
+
handle.dispatchEvent(
|
|
117
|
+
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
|
118
|
+
);
|
|
119
|
+
await nextTick();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function pointer(type: string, x: number, y: number): PointerEvent {
|
|
123
|
+
return new PointerEvent(type, {
|
|
124
|
+
bubbles: true,
|
|
125
|
+
cancelable: true,
|
|
126
|
+
pointerId: 7,
|
|
127
|
+
button: 0,
|
|
128
|
+
// A move with no button held is a released pointer the page never saw
|
|
129
|
+
// the release of — the engine ignores it (its doc'd contract), so the
|
|
130
|
+
// gesture here must carry the pressed button until the final up.
|
|
131
|
+
buttons: type === "pointerup" ? 0 : 1,
|
|
132
|
+
clientX: x,
|
|
133
|
+
clientY: y,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
describe("HkTable drag-to-reorder", () => {
|
|
138
|
+
it("renders no grip column unless draggable", async () => {
|
|
139
|
+
const container = document.createElement("div");
|
|
140
|
+
document.body.appendChild(container);
|
|
141
|
+
const app = createApp({
|
|
142
|
+
render: () =>
|
|
143
|
+
h(HTable, {
|
|
144
|
+
columns: [{ key: "name", title: "Name" }],
|
|
145
|
+
rows: [{ name: "a" }],
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
mounts.push({ app, container });
|
|
149
|
+
app.mount(container);
|
|
150
|
+
expect(container.querySelectorAll(".hk-table-drag-handle").length).toBe(0);
|
|
151
|
+
expect(container.querySelectorAll("thead th").length).toBe(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("renders one grip per row with an accessible name from the first column", async () => {
|
|
155
|
+
const t = mountTable(3);
|
|
156
|
+
const grips = handles(t.container);
|
|
157
|
+
expect(grips.length).toBe(3);
|
|
158
|
+
expect(grips[0]?.getAttribute("aria-label")).toBe("Reorder Row-0");
|
|
159
|
+
expect(grips[0]?.getAttribute("aria-keyshortcuts")).toBe("ArrowUp ArrowDown");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("interpolates the row label literally (a $& label is not a replacement pattern)", async () => {
|
|
163
|
+
const container = document.createElement("div");
|
|
164
|
+
document.body.appendChild(container);
|
|
165
|
+
const app = createApp({
|
|
166
|
+
render: () =>
|
|
167
|
+
h(HTable, {
|
|
168
|
+
columns: [{ key: "name", title: "Name" }],
|
|
169
|
+
rows: [{ name: "Cash $& more" }],
|
|
170
|
+
rowKey: "name",
|
|
171
|
+
draggable: true,
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
mounts.push({ app, container });
|
|
175
|
+
app.mount(container);
|
|
176
|
+
// String.replace would expand `$&` into the matched text; the
|
|
177
|
+
// split/join interpolation must keep the label verbatim.
|
|
178
|
+
expect(handles(container)[0]?.getAttribute("aria-label")).toBe("Reorder Cash $& more");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("ArrowDown on a focused handle emits (from, from+1) into rows and the move lands", async () => {
|
|
182
|
+
const t = mountTable(3);
|
|
183
|
+
await pressKey(handles(t.container)[0]!, "ArrowDown");
|
|
184
|
+
expect(t.emitted).toEqual([[0, 1]]);
|
|
185
|
+
await t.apply(0, 1);
|
|
186
|
+
// The leading data cell (the grip cell carries .hk-table-cell too).
|
|
187
|
+
expect(
|
|
188
|
+
t.container.querySelector("tbody .hk-table-row td:not(.hk-table-drag-cell)")?.textContent,
|
|
189
|
+
).toBe("Row-1");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("ArrowUp on the first row consumes the key but emits nothing", async () => {
|
|
193
|
+
const t = mountTable(3);
|
|
194
|
+
await pressKey(handles(t.container)[0]!, "ArrowUp");
|
|
195
|
+
expect(t.emitted).toEqual([]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("ArrowDown on the last row consumes the key but emits nothing", async () => {
|
|
199
|
+
const t = mountTable(3);
|
|
200
|
+
await pressKey(handles(t.container)[2]!, "ArrowDown");
|
|
201
|
+
expect(t.emitted).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("a pointer drag past the neighbours' midpoints emits the drop pair", async () => {
|
|
205
|
+
const t = mountTable(3);
|
|
206
|
+
const grip = handles(t.container)[0]!;
|
|
207
|
+
grip.dispatchEvent(pointer("pointerdown", 18, 20));
|
|
208
|
+
// Past the threshold (6px) and past rows 1–2 midpoints (60/100): the
|
|
209
|
+
// drop slot resolves to the END of the strip → insert-at-target = 2.
|
|
210
|
+
window.dispatchEvent(pointer("pointermove", 18, 101));
|
|
211
|
+
window.dispatchEvent(pointer("pointerup", 18, 101));
|
|
212
|
+
await nextTick();
|
|
213
|
+
expect(t.emitted).toEqual([[0, 2]]);
|
|
214
|
+
// The gesture is over: no row keeps its lift.
|
|
215
|
+
expect(t.container.querySelectorAll('tbody [data-dragging]').length).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("a short press stays a click (no drag, no emit)", async () => {
|
|
219
|
+
const t = mountTable(3);
|
|
220
|
+
const grip = handles(t.container)[0]!;
|
|
221
|
+
grip.dispatchEvent(pointer("pointerdown", 18, 20));
|
|
222
|
+
window.dispatchEvent(pointer("pointermove", 18, 23));
|
|
223
|
+
window.dispatchEvent(pointer("pointerup", 18, 23));
|
|
224
|
+
await nextTick();
|
|
225
|
+
expect(t.emitted).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("a cancelled gesture never emits", async () => {
|
|
229
|
+
const t = mountTable(3);
|
|
230
|
+
const grip = handles(t.container)[0]!;
|
|
231
|
+
grip.dispatchEvent(pointer("pointerdown", 18, 20));
|
|
232
|
+
window.dispatchEvent(pointer("pointermove", 18, 101));
|
|
233
|
+
window.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, pointerId: 7 }));
|
|
234
|
+
await nextTick();
|
|
235
|
+
expect(t.emitted).toEqual([]);
|
|
236
|
+
expect(t.container.querySelectorAll('tbody [data-dragging]').length).toBe(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("paints the insertion cue on the row the pointer resolves to", async () => {
|
|
240
|
+
const t = mountTable(3);
|
|
241
|
+
const grip = handles(t.container)[0]!;
|
|
242
|
+
grip.dispatchEvent(pointer("pointerdown", 18, 20));
|
|
243
|
+
window.dispatchEvent(pointer("pointermove", 18, 70));
|
|
244
|
+
await nextTick();
|
|
245
|
+
// Pointer between rows 1 (mid 60) and 2 (mid 100): the walk stops at
|
|
246
|
+
// display row 2 (main=70 < mid 100) → slot 2, from=0 → insert-at-target
|
|
247
|
+
// = 1 (drop row 0 ON row 1); the cue rides display row 1's top edge.
|
|
248
|
+
const trs = t.container.querySelectorAll("tbody .hk-table-row");
|
|
249
|
+
expect(trs[1]?.getAttribute("data-drop")).toBe("before");
|
|
250
|
+
window.dispatchEvent(pointer("pointerup", 18, 70));
|
|
251
|
+
await nextTick();
|
|
252
|
+
expect(trs[1]?.getAttribute("data-drop")).toBeNull();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("is inert while a column sort is active — grips announce disabled and emits never fire", async () => {
|
|
256
|
+
const t = mountTable(3, { sortable: true });
|
|
257
|
+
// Activate the sort the way a user does: click the column header.
|
|
258
|
+
const header = t.container.querySelectorAll<HTMLElement>("thead th")[2]!;
|
|
259
|
+
header.click();
|
|
260
|
+
await nextTick();
|
|
261
|
+
const grips = handles(t.container);
|
|
262
|
+
// Sorted desc on n (rows were built n = count-i): display order is
|
|
263
|
+
// Row-0 first still — but the SORT state is what matters here.
|
|
264
|
+
expect(header.classList.contains("hk-table-header-sorted")).toBe(true);
|
|
265
|
+
for (const grip of grips) {
|
|
266
|
+
expect(grip.getAttribute("aria-disabled")).toBe("true");
|
|
267
|
+
}
|
|
268
|
+
await pressKey(grips[0]!, "ArrowDown");
|
|
269
|
+
expect(t.emitted).toEqual([]);
|
|
270
|
+
gripDragAttempt(t);
|
|
271
|
+
expect(t.emitted).toEqual([]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("clears the sort on a third header click, re-enabling the grips", async () => {
|
|
275
|
+
const t = mountTable(3, { sortable: true });
|
|
276
|
+
const header = t.container.querySelectorAll<HTMLElement>("thead th")[2]!;
|
|
277
|
+
header.click();
|
|
278
|
+
await nextTick();
|
|
279
|
+
expect(header.classList.contains("hk-table-header-sorted")).toBe(true);
|
|
280
|
+
header.click();
|
|
281
|
+
await nextTick();
|
|
282
|
+
// Still sorted (descending) — the grips stay inert.
|
|
283
|
+
expect(header.classList.contains("hk-table-header-sorted")).toBe(true);
|
|
284
|
+
expect(handles(t.container)[0]!.getAttribute("aria-disabled")).toBe("true");
|
|
285
|
+
|
|
286
|
+
header.click();
|
|
287
|
+
await nextTick();
|
|
288
|
+
// Third click: the consumer's own order is back, so the grips are live
|
|
289
|
+
// again and a nudge emits.
|
|
290
|
+
expect(header.classList.contains("hk-table-header-sorted")).toBe(false);
|
|
291
|
+
expect(handles(t.container)[0]!.getAttribute("aria-disabled")).toBeNull();
|
|
292
|
+
await pressKey(handles(t.container)[0]!, "ArrowDown");
|
|
293
|
+
expect(t.emitted).toEqual([[0, 1]]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("paints no insertion cue while the pointer still resolves to the dragged row", async () => {
|
|
297
|
+
const t = mountTable(3);
|
|
298
|
+
const grip = handles(t.container)[0]!;
|
|
299
|
+
grip.dispatchEvent(pointer("pointerdown", 18, 20));
|
|
300
|
+
// Past the threshold but still inside row 0's own band (mid 20): the
|
|
301
|
+
// resolved slot is the row's own — a cue there would read as a no-op
|
|
302
|
+
// target.
|
|
303
|
+
window.dispatchEvent(pointer("pointermove", 18, 26));
|
|
304
|
+
await nextTick();
|
|
305
|
+
expect(t.container.querySelectorAll("tbody [data-drop]").length).toBe(0);
|
|
306
|
+
window.dispatchEvent(pointer("pointerup", 18, 26));
|
|
307
|
+
await nextTick();
|
|
308
|
+
expect(t.emitted).toEqual([]);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
/** A pointer gesture against an inert table: press + travel + release. */
|
|
312
|
+
function gripDragAttempt(t: RowsRef & { container: HTMLElement }): void {
|
|
313
|
+
const grip = handles(t.container)[0]!;
|
|
314
|
+
grip.dispatchEvent(pointer("pointerdown", 18, 20));
|
|
315
|
+
window.dispatchEvent(pointer("pointermove", 18, 101));
|
|
316
|
+
window.dispatchEvent(pointer("pointerup", 18, 101));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
it("still emits after a MIDDLE row is removed (stale registry tail)", async () => {
|
|
320
|
+
const t = mountTable(3);
|
|
321
|
+
// Vue calls the removed row's function ref with null, and the index that
|
|
322
|
+
// callback captured is the one the row held in the PREVIOUS render — the
|
|
323
|
+
// slot the survivor just slid into. A ref registry therefore ends up
|
|
324
|
+
// with a live row missing (or a stale duplicate), the drop resolves past
|
|
325
|
+
// the end and the bounds check swallows the emit — silently, for every
|
|
326
|
+
// later drag. The strip is read from the DOM, so it cannot drift.
|
|
327
|
+
const original = t.rows.value ?? [];
|
|
328
|
+
await t.setOrder([original[0]!, original[2]!]);
|
|
329
|
+
|
|
330
|
+
const grip = handles(t.container)[0]!;
|
|
331
|
+
grip.dispatchEvent(pointer("pointerdown", 18, 20));
|
|
332
|
+
window.dispatchEvent(pointer("pointermove", 18, 70));
|
|
333
|
+
window.dispatchEvent(pointer("pointerup", 18, 70));
|
|
334
|
+
await nextTick();
|
|
335
|
+
expect(t.emitted).toEqual([[0, 1]]);
|
|
336
|
+
// The drop lands: Row-2 takes Row-0's place.
|
|
337
|
+
await t.apply(0, 1);
|
|
338
|
+
expect((t.rows.value ?? []).map((r) => r.name)).toEqual(["Row-2", "Row-0"]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("re-resolves the strip after a deletion that follows a completed drag", async () => {
|
|
342
|
+
const t = mountTable(3);
|
|
343
|
+
// Read the strip once (a real drag), then remove a middle row and drag
|
|
344
|
+
// again: a strip cached at first read would silently stop emitting, and
|
|
345
|
+
// only this ORDERING catches it — the plain removal case reads the
|
|
346
|
+
// strip for the first time after the deletion.
|
|
347
|
+
const grip0 = handles(t.container)[0]!;
|
|
348
|
+
grip0.dispatchEvent(pointer("pointerdown", 18, 20));
|
|
349
|
+
window.dispatchEvent(pointer("pointermove", 18, 60));
|
|
350
|
+
window.dispatchEvent(pointer("pointerup", 18, 60));
|
|
351
|
+
await nextTick();
|
|
352
|
+
expect(t.emitted).toEqual([[0, 1]]);
|
|
353
|
+
await t.apply(0, 1);
|
|
354
|
+
|
|
355
|
+
const original = t.rows.value ?? [];
|
|
356
|
+
await t.setOrder([original[0]!, original[2]!]);
|
|
357
|
+
const grip = handles(t.container)[0]!;
|
|
358
|
+
grip.dispatchEvent(pointer("pointerdown", 18, 20));
|
|
359
|
+
window.dispatchEvent(pointer("pointermove", 18, 70));
|
|
360
|
+
window.dispatchEvent(pointer("pointerup", 18, 70));
|
|
361
|
+
await nextTick();
|
|
362
|
+
expect(t.emitted).toEqual([[0, 1], [0, 1]]);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("ignores a nested table's rows when resolving the outer strip", async () => {
|
|
366
|
+
// A cell slot may render another HkTable; its rows are descendants of
|
|
367
|
+
// this host, so a bare descendant query would let an outer drag resolve
|
|
368
|
+
// onto them (R3 N1).
|
|
369
|
+
const t = mountTable(3);
|
|
370
|
+
const cell = t.container.querySelector("tbody .hk-table-row td:not(.hk-table-drag-cell)")!;
|
|
371
|
+
const nested = document.createElement("div");
|
|
372
|
+
cell.appendChild(nested);
|
|
373
|
+
const nestedApp = createApp({
|
|
374
|
+
render: () =>
|
|
375
|
+
h(HTable, {
|
|
376
|
+
columns: [{ key: "name", title: "Name" }],
|
|
377
|
+
rows: [{ name: "Inner-0" }, { name: "Inner-1" }],
|
|
378
|
+
rowKey: "name",
|
|
379
|
+
}),
|
|
380
|
+
});
|
|
381
|
+
mounts.push({ app: nestedApp, container: nested });
|
|
382
|
+
nestedApp.mount(nested);
|
|
383
|
+
|
|
384
|
+
const grip = handles(t.container)[0]!;
|
|
385
|
+
grip.dispatchEvent(pointer("pointerdown", 18, 20));
|
|
386
|
+
window.dispatchEvent(pointer("pointermove", 18, 101));
|
|
387
|
+
window.dispatchEvent(pointer("pointerup", 18, 101));
|
|
388
|
+
await nextTick();
|
|
389
|
+
// The strip is the OUTER table's three rows: dropping row 0 at the end
|
|
390
|
+
// is (0, 2) — not a slot among the five rows a descendant query sees.
|
|
391
|
+
expect(t.emitted).toEqual([[0, 2]]);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("maps the drop onto the NEW array after a consumer reorder (reverse case)", async () => {
|
|
395
|
+
const t = mountTable(3);
|
|
396
|
+
// The consumer replaces the order outright — [Row-0, Row-1, Row-2] →
|
|
397
|
+
// [Row-2, Row-1, Row-0]. A row-element registry that kept birth indices
|
|
398
|
+
// would resolve the drag below against the OLD positions and emit
|
|
399
|
+
// nothing; the contract is indices into the array as it is NOW.
|
|
400
|
+
const reversed = [...(t.rows.value ?? [])].reverse();
|
|
401
|
+
await t.setOrder(reversed);
|
|
402
|
+
const grip = handles(t.container)[0]!;
|
|
403
|
+
grip.dispatchEvent(pointer("pointerdown", 18, 20));
|
|
404
|
+
// Past row 1's midpoint (60) but not row 2's (100) → insert-at-target 1.
|
|
405
|
+
window.dispatchEvent(pointer("pointermove", 18, 70));
|
|
406
|
+
window.dispatchEvent(pointer("pointerup", 18, 70));
|
|
407
|
+
await nextTick();
|
|
408
|
+
expect(t.emitted).toEqual([[0, 1]]);
|
|
409
|
+
// And the prescribed move lands the reversal the user saw.
|
|
410
|
+
const [first] = reversed.splice(0, 1);
|
|
411
|
+
reversed.splice(1, 0, first!);
|
|
412
|
+
expect(reversed.map((r) => r.name)).toEqual(["Row-1", "Row-2", "Row-0"]);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("pulls the nearest vertically scrollable ancestor while the pointer rests near its edge", async () => {
|
|
416
|
+
const scroller = document.createElement("div");
|
|
417
|
+
document.body.appendChild(scroller);
|
|
418
|
+
// happy-dom has no layout: the ancestor must LOOK like a scroller
|
|
419
|
+
// (overflow style + metrics) for the resolver to pick it.
|
|
420
|
+
Object.defineProperty(scroller, "scrollHeight", { configurable: true, value: 1000 });
|
|
421
|
+
Object.defineProperty(scroller, "clientHeight", { configurable: true, value: 100 });
|
|
422
|
+
const scrollerBox = { left: 0, right: 300, top: 0, bottom: 100, width: 300, height: 100, x: 0, y: 0 };
|
|
423
|
+
Object.defineProperty(scroller, "getBoundingClientRect", {
|
|
424
|
+
configurable: true,
|
|
425
|
+
value: () => scrollerBox as DOMRect,
|
|
426
|
+
});
|
|
427
|
+
const realGCS = window.getComputedStyle.bind(window);
|
|
428
|
+
const spy = vi.spyOn(window, "getComputedStyle").mockImplementation((el: Element) => {
|
|
429
|
+
if (el === scroller) return { overflowY: "auto" } as CSSStyleDeclaration;
|
|
430
|
+
return realGCS(el as Element);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const container = document.createElement("div");
|
|
434
|
+
scroller.appendChild(container);
|
|
435
|
+
const rows = ref<Record<string, unknown>[]>([
|
|
436
|
+
{ name: "Row-0" },
|
|
437
|
+
{ name: "Row-1" },
|
|
438
|
+
{ name: "Row-2" },
|
|
439
|
+
]);
|
|
440
|
+
const emitted: Array<[number, number]> = [];
|
|
441
|
+
const app = createApp({
|
|
442
|
+
render: () =>
|
|
443
|
+
h(HTable, {
|
|
444
|
+
columns: [{ key: "name", title: "Name" }],
|
|
445
|
+
rows: rows.value,
|
|
446
|
+
rowKey: "name",
|
|
447
|
+
draggable: true,
|
|
448
|
+
onReorder: (from: number, to: number) => emitted.push([from, to]),
|
|
449
|
+
}),
|
|
450
|
+
});
|
|
451
|
+
mounts.push({ app, container });
|
|
452
|
+
app.mount(container);
|
|
453
|
+
container.querySelectorAll("tbody .hk-table-row").forEach((tr, i) => {
|
|
454
|
+
const start = i * 40;
|
|
455
|
+
const box = { left: 0, right: 300, top: start, bottom: start + 40, width: 300, height: 40, x: 0, y: start };
|
|
456
|
+
Object.defineProperty(tr, "getBoundingClientRect", { configurable: true, value: () => box as DOMRect });
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const grip = handles(container)[0]!;
|
|
460
|
+
grip.dispatchEvent(pointer("pointerdown", 18, 20));
|
|
461
|
+
// Inside the scroller's bottom edge zone (rect 0..100, zone 24) → the
|
|
462
|
+
// engine starts pulling one frame at a time.
|
|
463
|
+
window.dispatchEvent(pointer("pointermove", 18, 95));
|
|
464
|
+
await nextTick();
|
|
465
|
+
for (let i = 0; i < 4; i += 1) {
|
|
466
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
|
467
|
+
}
|
|
468
|
+
expect(scroller.scrollTop).toBeGreaterThan(0);
|
|
469
|
+
window.dispatchEvent(pointer("pointerup", 18, 95));
|
|
470
|
+
await nextTick();
|
|
471
|
+
spy.mockRestore();
|
|
472
|
+
scroller.remove();
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
describe("HkTable grip CSS (source contract)", () => {
|
|
477
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
478
|
+
const scss = readFileSync(join(here, "HkTable.scss"), "utf-8");
|
|
479
|
+
|
|
480
|
+
it("keeps the grip cells' padding rule at size-variant specificity", () => {
|
|
481
|
+
// The regression this pins: a bare `.hk-table-drag-cell` selector is
|
|
482
|
+
// 0,1,0 and silently loses to `.hk-table-sm .hk-table-cell` (0,2,0), so
|
|
483
|
+
// the grip cell grows a full cell's right padding at sm/lg.
|
|
484
|
+
expect(scss).toMatch(/\.hk-table \.hk-table-drag-cell\s*\{/);
|
|
485
|
+
expect(scss).toMatch(/\.hk-table \.hk-table-drag-col\s*\{/);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
describe("HkAdminTablePage drag passthrough (source contract)", () => {
|
|
490
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
491
|
+
const pageTsx = readFileSync(join(here, "HkAdminTablePage.tsx"), "utf-8");
|
|
492
|
+
|
|
493
|
+
it("forwards draggable and the reorder event to HTable verbatim", () => {
|
|
494
|
+
expect(pageTsx).toContain("draggable: { type: Boolean, default: false }");
|
|
495
|
+
expect(pageTsx).toMatch(/emits:\s*\{\s*reorder:/);
|
|
496
|
+
expect(pageTsx).toMatch(/draggable=\{props\.draggable\}/);
|
|
497
|
+
expect(pageTsx).toMatch(/onReorder=\{\(from: number, to: number\) => emit\("reorder", from, to\)\}/);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
@@ -129,12 +129,98 @@
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/* Grip cells: tighter than a content cell and flush to the row's trailing
|
|
133
|
+
edge. Specificity 0,2,0 — equal to the size variants' `.hk-table-sm
|
|
134
|
+
.hk-table-cell` rules above but later in source order, which is what makes
|
|
135
|
+
the trim hold at EVERY size (a bare `.hk-table-drag-cell` is 0,1,0, so the
|
|
136
|
+
sm/lg padding silently won and the grip cell stayed wide). */
|
|
137
|
+
.hk-table .hk-table-drag-col {
|
|
138
|
+
padding-right: 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.hk-table .hk-table-drag-cell {
|
|
142
|
+
padding-right: 0;
|
|
143
|
+
width: 36px;
|
|
144
|
+
white-space: nowrap;
|
|
145
|
+
}
|
|
146
|
+
|
|
132
147
|
.hk-table-empty {
|
|
133
148
|
padding: var(--space-32, 32px) var(--space-16, 16px);
|
|
134
149
|
text-align: center;
|
|
135
150
|
color: var(--hi-color-text-secondary, #666666);
|
|
136
151
|
}
|
|
137
152
|
|
|
153
|
+
/* ── Drag-to-reorder (draggable) ─────────────────────────────────────── */
|
|
154
|
+
|
|
155
|
+
.hk-table-drag-handle {
|
|
156
|
+
display: inline-flex;
|
|
157
|
+
align-items: center;
|
|
158
|
+
justify-content: center;
|
|
159
|
+
width: var(--space-20, 20px);
|
|
160
|
+
height: var(--space-24, 24px);
|
|
161
|
+
padding: 0;
|
|
162
|
+
border: 0;
|
|
163
|
+
border-radius: var(--radius-sm, 4px);
|
|
164
|
+
background: transparent;
|
|
165
|
+
color: var(--hi-color-text-tertiary, #8b949e);
|
|
166
|
+
cursor: grab;
|
|
167
|
+
/* An explicit grip owns the gesture: the page must not pan under a
|
|
168
|
+
held drag (house pattern: HkDraggableList's handle). */
|
|
169
|
+
touch-action: none;
|
|
170
|
+
|
|
171
|
+
&:hover {
|
|
172
|
+
color: var(--hi-color-text-secondary, #666666);
|
|
173
|
+
background: var(--hi-color-bg-subtle, rgba(255, 255, 255, 0.04));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
&:active {
|
|
177
|
+
cursor: grabbing;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
&:focus-visible {
|
|
181
|
+
outline: none;
|
|
182
|
+
box-shadow: 0 0 0 2px var(--hi-color-bg-canvas, #0d1117), 0 0 0 4px var(--hi-color-primary, #7aa2f7);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* A sorted table refuses to reorder (the emitted index pair could not
|
|
186
|
+
reproduce the visual move against a derived arrangement) — the grip
|
|
187
|
+
must LOOK inert, not merely be inert. */
|
|
188
|
+
&[data-disabled] {
|
|
189
|
+
color: var(--hi-color-text-tertiary, #8b949e);
|
|
190
|
+
opacity: 0.4;
|
|
191
|
+
cursor: not-allowed;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* The row being dragged lifts in place (house pattern: HkTagInput's drag
|
|
196
|
+
lift — no floating ghost): dimmed, primary-tinted edge. A transform on
|
|
197
|
+
a <tr> is unreliable across engines, so the lift is paint-only. */
|
|
198
|
+
.hk-table-draggable .hk-table-row[data-dragging] {
|
|
199
|
+
opacity: 0.45;
|
|
200
|
+
background: rgb(from var(--hi-color-primary, #7aa2f7) r g b / 0.06);
|
|
201
|
+
box-shadow: inset 0 0 0 1px var(--hi-color-primary, #7aa2f7);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* Insertion cue: the slot the pointer resolves to paints a 2px line on
|
|
205
|
+
the edge the row would land across. */
|
|
206
|
+
.hk-table-draggable .hk-table-row[data-drop="before"] {
|
|
207
|
+
box-shadow: inset 0 2px 0 0 var(--hi-color-primary, #7aa2f7);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.hk-table-draggable .hk-table-row[data-drop="after"] {
|
|
211
|
+
box-shadow: inset 0 -2px 0 0 var(--hi-color-primary, #7aa2f7);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* Text under a live drag must not select (the press starts on the grip,
|
|
215
|
+
but a move across cells can start a native selection). */
|
|
216
|
+
.hk-table-draggable .hk-table-row[data-dragging] .hk-table-cell {
|
|
217
|
+
user-select: none;
|
|
218
|
+
-webkit-user-select: none;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* Reduced motion: the lift is paint-only already (no transform to snap);
|
|
222
|
+
the hover/press fades stay (house convention: HkDraggableList.scss). */
|
|
223
|
+
|
|
138
224
|
/* Alignment helpers (start/end so aligned columns mirror in RTL; the
|
|
139
225
|
tsx keeps emitting the hk-text-* names) */
|
|
140
226
|
.hk-text-left {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, type PropType } from "vue";
|
|
2
2
|
|
|
3
3
|
import { useI18n } from "../i18n/context";
|
|
4
|
+
import { usePointerReorder } from "../composables/usePointerReorder";
|
|
4
5
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
5
6
|
import "./HkTable.scss";
|
|
6
7
|
|
|
@@ -25,10 +26,18 @@ export default defineComponent({
|
|
|
25
26
|
hover: { type: Boolean, default: false },
|
|
26
27
|
sortable: { type: Boolean, default: false },
|
|
27
28
|
selectable: { type: Boolean, default: false },
|
|
29
|
+
/** Left grip column with pointer drag-to-reorder (and ArrowUp/Down on a
|
|
30
|
+
* focused handle). The emitted indices are indices into `rows` — the
|
|
31
|
+
* consumer applies `moveTo(rows, from, to)`. While a column sort is
|
|
32
|
+
* active the displayed arrangement is derived, so two indices cannot
|
|
33
|
+
* faithfully encode the visual move: reordering is INERT until the
|
|
34
|
+
* sort is cleared (the handles render dimmed and refuse presses). */
|
|
35
|
+
draggable: { type: Boolean, default: false },
|
|
28
36
|
emptyText: { type: String, default: "" },
|
|
29
37
|
},
|
|
30
38
|
emits: {
|
|
31
39
|
"update:selectedRows": (_rows: Record<string, unknown>[]) => true,
|
|
40
|
+
reorder: (_fromIndex: number, _toIndex: number) => true,
|
|
32
41
|
},
|
|
33
42
|
setup(props, { emit, slots }) {
|
|
34
43
|
const { t } = useI18n();
|
|
@@ -63,13 +72,23 @@ export default defineComponent({
|
|
|
63
72
|
return String(index);
|
|
64
73
|
}
|
|
65
74
|
|
|
75
|
+
/** Click-to-sort is TRI-state: ascending → descending → unsorted. The
|
|
76
|
+
* third click restores the consumer's own array order, which is what
|
|
77
|
+
* makes the state reachable again for a `draggable` table (reordering
|
|
78
|
+
* is inert while an arrangement is derived from a sort — see
|
|
79
|
+
* `reorderInert`). */
|
|
66
80
|
function toggleSort(key: string) {
|
|
67
|
-
if (sortKey.value
|
|
68
|
-
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
|
69
|
-
} else {
|
|
81
|
+
if (sortKey.value !== key) {
|
|
70
82
|
sortKey.value = key;
|
|
71
83
|
sortDirection.value = "asc";
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (sortDirection.value === "asc") {
|
|
87
|
+
sortDirection.value = "desc";
|
|
88
|
+
return;
|
|
72
89
|
}
|
|
90
|
+
sortKey.value = null;
|
|
91
|
+
sortDirection.value = "asc";
|
|
73
92
|
}
|
|
74
93
|
|
|
75
94
|
const sortedRows = computed(() => {
|
|
@@ -117,23 +136,161 @@ export default defineComponent({
|
|
|
117
136
|
selectedRowKeys.value.size === sortedRows.value.length
|
|
118
137
|
);
|
|
119
138
|
|
|
139
|
+
// ── Drag-to-reorder (draggable) ─────────────────────────────────────
|
|
140
|
+
/** The strip the reorder engine measures: the body rows in display
|
|
141
|
+
* order, read from the DOM on demand.
|
|
142
|
+
*
|
|
143
|
+
* A ref registry cannot be trusted here. Vue calls a REMOVED row's
|
|
144
|
+
* function ref with null, and the index that callback captured is the
|
|
145
|
+
* one the row had in the PREVIOUS render — which, after a mid-list
|
|
146
|
+
* deletion, is the very slot the row below just slid into. The stale
|
|
147
|
+
* null lands last and erases a live registration (measured: rows
|
|
148
|
+
* [Row-0, Row-2] render while the registry reads [Row-0, null]), so
|
|
149
|
+
* every later pointer drop resolves past the end and is swallowed by
|
|
150
|
+
* the bounds check. The DOM is the one source that cannot drift; the
|
|
151
|
+
* engine re-reads it per resolution, which is also what keeps a list
|
|
152
|
+
* that changes under a held press honest. */
|
|
153
|
+
function liveRowEls(): HTMLElement[] {
|
|
154
|
+
const host = wrapperHostRef.value;
|
|
155
|
+
// The host's FIRST table is this component's own (a nested table can
|
|
156
|
+
// only live inside one of its cells), and the `closest` filter keeps a
|
|
157
|
+
// nested table's rows out of the strip: they are descendants of the
|
|
158
|
+
// host too, and a bare descendant query would let an outer drag
|
|
159
|
+
// resolve onto them.
|
|
160
|
+
const own = host?.querySelector<HTMLElement>("table.hk-table") ?? null;
|
|
161
|
+
if (!own) return [];
|
|
162
|
+
return Array.from(own.querySelectorAll<HTMLElement>("tbody > .hk-table-row")).filter(
|
|
163
|
+
(row) => row.closest("table") === own,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Reordering is meaningful only over the array the consumer owns. A
|
|
168
|
+
* column sort is an arrangement DERIVED from that array — the two
|
|
169
|
+
* `reorder` indices could not tell the consumer how to reproduce the
|
|
170
|
+
* visual move — so a sorted table refuses to drag (dimmed handles,
|
|
171
|
+
* inert presses) rather than emit an edit that would land somewhere
|
|
172
|
+
* the user did not drop it. Clicking the sorted header a third time
|
|
173
|
+
* clears the sort (tri-state), which is the way back to dragging. */
|
|
174
|
+
const reorderInert = computed(() => sortKey.value !== null);
|
|
175
|
+
|
|
176
|
+
/** `moveTo` in the consumer's array: the entry at `from` takes `to`'s
|
|
177
|
+
* place and the entries between shift by one (HkTagInput's drop
|
|
178
|
+
* semantics — insert-at-target, what dropping a row ON another row
|
|
179
|
+
* looks like). */
|
|
180
|
+
function onReorderDrop(from: number, to: number): void {
|
|
181
|
+
if (reorderInert.value) return;
|
|
182
|
+
if (from === to || from < 0 || to < 0) return;
|
|
183
|
+
if (from >= props.rows.length || to >= props.rows.length) return;
|
|
184
|
+
emit("reorder", from, to);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The scrollable ancestor a live drag pulls along, resolved on demand
|
|
188
|
+
* (the engine re-reads it every frame while the pointer rests near an
|
|
189
|
+
* edge): the nearest ancestor that actually overflows vertically — the
|
|
190
|
+
* page shell, a modal body, a scroll container — or null when the
|
|
191
|
+
* table sits fully in view, where there is nothing to scroll. */
|
|
192
|
+
function dragScrollContainer(): HTMLElement | null {
|
|
193
|
+
let el = wrapperHostRef.value?.parentElement ?? null;
|
|
194
|
+
while (el) {
|
|
195
|
+
const overflowY =
|
|
196
|
+
typeof getComputedStyle === "function" ? getComputedStyle(el).overflowY : "";
|
|
197
|
+
if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight + 1) {
|
|
198
|
+
return el;
|
|
199
|
+
}
|
|
200
|
+
el = el.parentElement;
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const rowDrag = usePointerReorder({
|
|
206
|
+
items: liveRowEls,
|
|
207
|
+
axis: "y",
|
|
208
|
+
onDrop: onReorderDrop,
|
|
209
|
+
scrollContainer: dragScrollContainer,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
function onHandlePointerdown(e: PointerEvent, index: number): void {
|
|
213
|
+
if (reorderInert.value) return;
|
|
214
|
+
// The press's own row is looked up in the live strip: the render-time
|
|
215
|
+
// index is a fallback for a handle that somehow left the table.
|
|
216
|
+
const row = (e.currentTarget as HTMLElement | null)?.closest("tr");
|
|
217
|
+
const strip = row ? liveRowEls().indexOf(row as HTMLElement) : index;
|
|
218
|
+
if (strip >= 0) rowDrag.start(e, strip);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Keyboard twin of the drop: move one row one slot. Runs on a focused
|
|
222
|
+
* handle, so plain ArrowUp/Down are unambiguous (no competing cursor
|
|
223
|
+
* semantics on a button) and are consumed even at the ends, where the
|
|
224
|
+
* row has nowhere to go. */
|
|
225
|
+
function onHandleKeydown(e: KeyboardEvent, index: number): void {
|
|
226
|
+
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
if (reorderInert.value) return;
|
|
229
|
+
const delta = e.key === "ArrowUp" ? -1 : 1;
|
|
230
|
+
onReorderDrop(index, index + delta);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The handle's accessible name names the entry AND the action (house
|
|
234
|
+
* pattern: HkTagInput's reorderLabel) — the first column's value is
|
|
235
|
+
* the row's identity as far as this generic component can tell. */
|
|
236
|
+
function rowLabel(row: Record<string, unknown>): string {
|
|
237
|
+
const first = props.columns[0]?.key;
|
|
238
|
+
const value = first != null ? row[first] : undefined;
|
|
239
|
+
return value == null ? "" : String(value).trim();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function handleName(row: Record<string, unknown>): string {
|
|
243
|
+
const label = rowLabel(row);
|
|
244
|
+
if (!label) return t("hikari::table.dragHandle", "Drag to reorder");
|
|
245
|
+
const template = t("hikari::table.dragHandleRow", "Reorder {label}");
|
|
246
|
+
// split/join, not String.replace: a row label carrying `$&` / `$'`
|
|
247
|
+
// would otherwise be expanded as a replacement pattern (house
|
|
248
|
+
// interpolation, HkTagInput/HkAffixPicker).
|
|
249
|
+
const [before, after] = template.split("{label}");
|
|
250
|
+
if (after === undefined) return template;
|
|
251
|
+
return `${before}${label}${after}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Insertion cue for the slot the pointer currently resolves to:
|
|
255
|
+
* `dragOver` is a slot (0..n) over the strip — the line paints on the
|
|
256
|
+
* TOP edge of the row that would sit below the drop, or the BOTTOM
|
|
257
|
+
* edge of the last row for the trailing slot. A slot that resolves
|
|
258
|
+
* back onto the row being dragged paints nothing: the row already
|
|
259
|
+
* carries its lift, and a "drop here" line on the row in hand reads
|
|
260
|
+
* as a no-op target. */
|
|
261
|
+
const dropCue = computed<{ index: number; edge: "before" | "after" } | null>(() => {
|
|
262
|
+
if (!rowDrag.dragging.value || rowDrag.dragOver.value < 0) return null;
|
|
263
|
+
const slot = rowDrag.dragOver.value;
|
|
264
|
+
if (slot === rowDrag.dragFrom.value) return null;
|
|
265
|
+
const count = sortedRows.value.length;
|
|
266
|
+
if (slot >= count) return { index: count - 1, edge: "after" };
|
|
267
|
+
return { index: slot, edge: "before" };
|
|
268
|
+
});
|
|
269
|
+
|
|
120
270
|
const tableCls = computed(() => [
|
|
121
271
|
"hk-table",
|
|
122
272
|
`hk-table-${props.size}`,
|
|
123
273
|
props.bordered ? "hk-table-bordered" : "",
|
|
124
274
|
props.striped ? "hk-table-striped" : "",
|
|
125
275
|
props.hover ? "hk-table-hover" : "",
|
|
276
|
+
props.draggable ? "hk-table-draggable" : "",
|
|
126
277
|
]);
|
|
127
278
|
|
|
128
|
-
const totalCols = computed(
|
|
279
|
+
const totalCols = computed(
|
|
280
|
+
() => props.columns.length + (props.selectable ? 1 : 0) + (props.draggable ? 1 : 0)
|
|
281
|
+
);
|
|
129
282
|
|
|
130
|
-
return () =>
|
|
283
|
+
return () => {
|
|
284
|
+
return (
|
|
131
285
|
<div ref={wrapperHostRef} class="hk-table-host">
|
|
132
286
|
<div ref={wrapperRef} class="hk-table-wrapper">
|
|
133
287
|
<table class={tableCls.value}>
|
|
134
288
|
{props.caption && <caption class="hk-table-sr-only">{props.caption}</caption>}
|
|
135
289
|
<thead>
|
|
136
290
|
<tr class="hk-table-header-row">
|
|
291
|
+
{props.draggable && (
|
|
292
|
+
<th class="hk-table-header-cell hk-table-drag-col" style={{ width: "36px" }} aria-hidden="true" />
|
|
293
|
+
)}
|
|
137
294
|
{props.selectable && (
|
|
138
295
|
<th class="hk-table-header-cell" style={{ width: "40px" }}>
|
|
139
296
|
<label class="hk-table-checkbox-label">
|
|
@@ -213,8 +370,46 @@ export default defineComponent({
|
|
|
213
370
|
) : (
|
|
214
371
|
sortedRows.value.map((row, index) => {
|
|
215
372
|
const rowKey = getRowKey(row, index);
|
|
373
|
+
const dropCueEdge =
|
|
374
|
+
dropCue.value && dropCue.value.index === index ? dropCue.value.edge : null;
|
|
216
375
|
return (
|
|
217
|
-
<tr
|
|
376
|
+
<tr
|
|
377
|
+
key={rowKey}
|
|
378
|
+
class="hk-table-row"
|
|
379
|
+
data-dragging={rowDrag.dragging.value && rowDrag.dragFrom.value === index ? "" : undefined}
|
|
380
|
+
data-drop={dropCueEdge || undefined}
|
|
381
|
+
>
|
|
382
|
+
{props.draggable && (
|
|
383
|
+
<td class="hk-table-cell hk-table-drag-cell">
|
|
384
|
+
{/* A span, not a <button>: the reorder engine (house
|
|
385
|
+
rule, HkDraggableList's handle too) treats presses
|
|
386
|
+
on interactive controls as ACTIVATION, not drag —
|
|
387
|
+
the grip is a span so a press on it IS the drag
|
|
388
|
+
gesture. Keyboard stays first-class: focusable,
|
|
389
|
+
ArrowUp/Down nudges the row (aria-keyshortcuts). */}
|
|
390
|
+
<span
|
|
391
|
+
role="button"
|
|
392
|
+
tabindex={0}
|
|
393
|
+
class="hk-table-drag-handle"
|
|
394
|
+
aria-label={handleName(row)}
|
|
395
|
+
title={handleName(row)}
|
|
396
|
+
aria-keyshortcuts="ArrowUp ArrowDown"
|
|
397
|
+
aria-disabled={reorderInert.value || undefined}
|
|
398
|
+
data-disabled={reorderInert.value || undefined}
|
|
399
|
+
onPointerdown={(e: PointerEvent) => onHandlePointerdown(e, index)}
|
|
400
|
+
onKeydown={(e: KeyboardEvent) => onHandleKeydown(e, index)}
|
|
401
|
+
>
|
|
402
|
+
<svg width="12" height="14" viewBox="0 0 12 14" fill="currentColor" aria-hidden="true">
|
|
403
|
+
<circle cx="3.5" cy="3" r="1.2" />
|
|
404
|
+
<circle cx="8.5" cy="3" r="1.2" />
|
|
405
|
+
<circle cx="3.5" cy="7" r="1.2" />
|
|
406
|
+
<circle cx="8.5" cy="7" r="1.2" />
|
|
407
|
+
<circle cx="3.5" cy="11" r="1.2" />
|
|
408
|
+
<circle cx="8.5" cy="11" r="1.2" />
|
|
409
|
+
</svg>
|
|
410
|
+
</span>
|
|
411
|
+
</td>
|
|
412
|
+
)}
|
|
218
413
|
{props.selectable && (
|
|
219
414
|
<td class="hk-table-cell" style={{ width: "40px" }}>
|
|
220
415
|
<label class="hk-table-checkbox-label">
|
|
@@ -266,6 +461,7 @@ export default defineComponent({
|
|
|
266
461
|
</table>
|
|
267
462
|
</div>
|
|
268
463
|
</div>
|
|
269
|
-
|
|
464
|
+
);
|
|
465
|
+
};
|
|
270
466
|
},
|
|
271
467
|
});
|
|
@@ -170,6 +170,8 @@
|
|
|
170
170
|
"hikari::statusBar.tier.ws": "WebSocket",
|
|
171
171
|
"hikari::statusPill.ms": " مللي ثانية",
|
|
172
172
|
"hikari::table.noData": "لا توجد بيانات",
|
|
173
|
+
"hikari::table.dragHandle": "اسحب لإعادة الترتيب",
|
|
174
|
+
"hikari::table.dragHandleRow": "إعادة ترتيب {label}",
|
|
173
175
|
"hikari::tablePage.emptyTitle": "لا توجد بيانات",
|
|
174
176
|
"hikari::tabs.add": "إضافة علامة تبويب",
|
|
175
177
|
"hikari::theme.customThemeName": "سمة مخصصة",
|
|
@@ -170,6 +170,8 @@
|
|
|
170
170
|
"hikari::statusBar.tier.ws": "WebSocket",
|
|
171
171
|
"hikari::statusPill.ms": " ms",
|
|
172
172
|
"hikari::table.noData": "Keine Daten",
|
|
173
|
+
"hikari::table.dragHandle": "Zum Neuanordnen ziehen",
|
|
174
|
+
"hikari::table.dragHandleRow": "„{label}“ neu anordnen",
|
|
173
175
|
"hikari::tablePage.emptyTitle": "Keine Daten",
|
|
174
176
|
"hikari::tabs.add": "Tab hinzufügen",
|
|
175
177
|
"hikari::theme.customThemeName": "Benutzerdefiniertes Thema",
|
|
@@ -170,6 +170,8 @@
|
|
|
170
170
|
"hikari::statusBar.tier.ws": "WebSocket",
|
|
171
171
|
"hikari::statusPill.ms": " ms",
|
|
172
172
|
"hikari::table.noData": "No data",
|
|
173
|
+
"hikari::table.dragHandle": "Drag to reorder",
|
|
174
|
+
"hikari::table.dragHandleRow": "Reorder {label}",
|
|
173
175
|
"hikari::tablePage.emptyTitle": "No data",
|
|
174
176
|
"hikari::tabs.add": "Add tab",
|
|
175
177
|
"hikari::theme.customThemeName": "Custom theme",
|
|
@@ -170,6 +170,8 @@
|
|
|
170
170
|
"hikari::statusBar.tier.ws": "WebSocket",
|
|
171
171
|
"hikari::statusPill.ms": " ms",
|
|
172
172
|
"hikari::table.noData": "Sin datos",
|
|
173
|
+
"hikari::table.dragHandle": "Arrastra para reordenar",
|
|
174
|
+
"hikari::table.dragHandleRow": "Reordenar «{label}»",
|
|
173
175
|
"hikari::tablePage.emptyTitle": "Sin datos",
|
|
174
176
|
"hikari::tabs.add": "Añadir pestaña",
|
|
175
177
|
"hikari::theme.customThemeName": "Tema personalizado",
|
|
@@ -170,6 +170,8 @@
|
|
|
170
170
|
"hikari::statusBar.tier.ws": "WebSocket",
|
|
171
171
|
"hikari::statusPill.ms": " ms",
|
|
172
172
|
"hikari::table.noData": "Aucune donnée",
|
|
173
|
+
"hikari::table.dragHandle": "Glisser pour réordonner",
|
|
174
|
+
"hikari::table.dragHandleRow": "Réordonner « {label} »",
|
|
173
175
|
"hikari::tablePage.emptyTitle": "Aucune donnée",
|
|
174
176
|
"hikari::tabs.add": "Ajouter un onglet",
|
|
175
177
|
"hikari::theme.customThemeName": "Thème personnalisé",
|
|
@@ -170,6 +170,8 @@
|
|
|
170
170
|
"hikari::statusBar.tier.ws": "WebSocket",
|
|
171
171
|
"hikari::statusPill.ms": " ミリ秒",
|
|
172
172
|
"hikari::table.noData": "データなし",
|
|
173
|
+
"hikari::table.dragHandle": "ドラッグで並べ替え",
|
|
174
|
+
"hikari::table.dragHandleRow": "「{label}」の順序を変更",
|
|
173
175
|
"hikari::tablePage.emptyTitle": "データがありません",
|
|
174
176
|
"hikari::tabs.add": "タブを追加",
|
|
175
177
|
"hikari::theme.customThemeName": "カスタムテーマ",
|
|
@@ -170,6 +170,8 @@
|
|
|
170
170
|
"hikari::statusBar.tier.ws": "WebSocket",
|
|
171
171
|
"hikari::statusPill.ms": " 밀리초",
|
|
172
172
|
"hikari::table.noData": "데이터 없음",
|
|
173
|
+
"hikari::table.dragHandle": "드래그하여 순서 변경",
|
|
174
|
+
"hikari::table.dragHandleRow": "'{label}' 순서 변경",
|
|
173
175
|
"hikari::tablePage.emptyTitle": "데이터 없음",
|
|
174
176
|
"hikari::tabs.add": "탭 추가",
|
|
175
177
|
"hikari::theme.customThemeName": "사용자 정의 테마",
|
|
@@ -170,6 +170,8 @@
|
|
|
170
170
|
"hikari::statusBar.tier.ws": "WebSocket",
|
|
171
171
|
"hikari::statusPill.ms": " ms",
|
|
172
172
|
"hikari::table.noData": "Sem dados",
|
|
173
|
+
"hikari::table.dragHandle": "Arraste para reordenar",
|
|
174
|
+
"hikari::table.dragHandleRow": "Reordenar «{label}»",
|
|
173
175
|
"hikari::tablePage.emptyTitle": "Sem dados",
|
|
174
176
|
"hikari::tabs.add": "Adicionar separador",
|
|
175
177
|
"hikari::theme.customThemeName": "Tema personalizado",
|
|
@@ -170,6 +170,8 @@
|
|
|
170
170
|
"hikari::statusBar.tier.ws": "WebSocket",
|
|
171
171
|
"hikari::statusPill.ms": " мс",
|
|
172
172
|
"hikari::table.noData": "Нет данных",
|
|
173
|
+
"hikari::table.dragHandle": "Перетащите, чтобы изменить порядок",
|
|
174
|
+
"hikari::table.dragHandleRow": "Изменить порядок: {label}",
|
|
173
175
|
"hikari::tablePage.emptyTitle": "Нет данных",
|
|
174
176
|
"hikari::tabs.add": "Добавить вкладку",
|
|
175
177
|
"hikari::theme.customThemeName": "Пользовательская тема",
|
|
@@ -170,6 +170,8 @@
|
|
|
170
170
|
"hikari::statusBar.tier.ws": "WebSocket",
|
|
171
171
|
"hikari::statusPill.ms": " 毫秒",
|
|
172
172
|
"hikari::table.noData": "暂无数据",
|
|
173
|
+
"hikari::table.dragHandle": "拖动以调整顺序",
|
|
174
|
+
"hikari::table.dragHandleRow": "调整「{label}」的顺序",
|
|
173
175
|
"hikari::tablePage.emptyTitle": "暂无数据",
|
|
174
176
|
"hikari::tabs.add": "添加标签页",
|
|
175
177
|
"hikari::theme.customThemeName": "自定义主题",
|
|
@@ -170,6 +170,8 @@
|
|
|
170
170
|
"hikari::statusBar.tier.ws": "WebSocket",
|
|
171
171
|
"hikari::statusPill.ms": " 毫秒",
|
|
172
172
|
"hikari::table.noData": "暫無數據",
|
|
173
|
+
"hikari::table.dragHandle": "拖曳以調整順序",
|
|
174
|
+
"hikari::table.dragHandleRow": "調整「{label}」的順序",
|
|
173
175
|
"hikari::tablePage.emptyTitle": "暫無資料",
|
|
174
176
|
"hikari::tabs.add": "新增標籤頁",
|
|
175
177
|
"hikari::theme.customThemeName": "自訂主題",
|