@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.115",
3
+ "version": "0.55.116",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -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
- setup(props, { slots }) {
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 columns={props.columns} rows={props.rows} rowKey={props.rowKey}>
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 === key) {
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(() => props.columns.length + (props.selectable ? 1 : 0));
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 key={rowKey} class="hk-table-row">
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": "自訂主題",