@celestia-island/hikari 0.55.121 → 0.55.123

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.121",
3
+ "version": "0.55.123",
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",
@@ -168,6 +168,135 @@
168
168
  width: 100%;
169
169
  }
170
170
 
171
+ /* ---- Responsive label collapse ---- */
172
+ /* A `collapseLabel` button carries icon + text at wide viewports and
173
+ collapses to the SAME icon-only square below the chosen breakpoint
174
+ (chest 2026-10-05 direction: the ad-hoc `hidden sm:inline` label-span
175
+ pattern left the button as a padded icon rectangle on phones — the
176
+ exact squat shape the icon-only contract exists to prevent). The
177
+ geometry mirrors the icon-only ladder above per size; the breakpoint
178
+ px values mirror the runtime useBreakpoint scale (sm 640 / md 768 /
179
+ lg 1024 / xl 1280) — a media query cannot read the TS constant, keep
180
+ the two tables in sync. The TS side suppresses the collapse class
181
+ together with `block` (a full-width button has nothing to collapse
182
+ into), so no block override is needed here. */
183
+ .hk-btn-label {
184
+ /* Inline-flex so multi-node labels (text + inline marks) stay on one
185
+ row and center against the glyph box. */
186
+ display: inline-flex;
187
+ align-items: center;
188
+ min-width: 0;
189
+ }
190
+
191
+ @mixin hk-btn-collapse-geometry($square) {
192
+ .hk-btn-label {
193
+ display: none;
194
+ }
195
+
196
+ /* The exact icon-only geometry (all-side padding zero, width locked),
197
+ mirrored per size from the ladder above. */
198
+ padding: 0;
199
+ box-sizing: border-box;
200
+ width: $square;
201
+ }
202
+
203
+ .hk-btn-label-collapse-sm.hk-btn-xs {
204
+ @media (max-width: 639.98px) {
205
+ @include hk-btn-collapse-geometry(1.375rem);
206
+ }
207
+ }
208
+
209
+ .hk-btn-label-collapse-sm.hk-btn-sm {
210
+ @media (max-width: 639.98px) {
211
+ @include hk-btn-collapse-geometry(1.75rem);
212
+ }
213
+ }
214
+
215
+ .hk-btn-label-collapse-sm.hk-btn-md {
216
+ @media (max-width: 639.98px) {
217
+ @include hk-btn-collapse-geometry(2.5rem);
218
+ }
219
+ }
220
+
221
+ .hk-btn-label-collapse-sm.hk-btn-lg {
222
+ @media (max-width: 639.98px) {
223
+ @include hk-btn-collapse-geometry(2.75rem);
224
+ }
225
+ }
226
+
227
+ .hk-btn-label-collapse-md.hk-btn-xs {
228
+ @media (max-width: 767.98px) {
229
+ @include hk-btn-collapse-geometry(1.375rem);
230
+ }
231
+ }
232
+
233
+ .hk-btn-label-collapse-md.hk-btn-sm {
234
+ @media (max-width: 767.98px) {
235
+ @include hk-btn-collapse-geometry(1.75rem);
236
+ }
237
+ }
238
+
239
+ .hk-btn-label-collapse-md.hk-btn-md {
240
+ @media (max-width: 767.98px) {
241
+ @include hk-btn-collapse-geometry(2.5rem);
242
+ }
243
+ }
244
+
245
+ .hk-btn-label-collapse-md.hk-btn-lg {
246
+ @media (max-width: 767.98px) {
247
+ @include hk-btn-collapse-geometry(2.75rem);
248
+ }
249
+ }
250
+
251
+ .hk-btn-label-collapse-lg.hk-btn-xs {
252
+ @media (max-width: 1023.98px) {
253
+ @include hk-btn-collapse-geometry(1.375rem);
254
+ }
255
+ }
256
+
257
+ .hk-btn-label-collapse-lg.hk-btn-sm {
258
+ @media (max-width: 1023.98px) {
259
+ @include hk-btn-collapse-geometry(1.75rem);
260
+ }
261
+ }
262
+
263
+ .hk-btn-label-collapse-lg.hk-btn-md {
264
+ @media (max-width: 1023.98px) {
265
+ @include hk-btn-collapse-geometry(2.5rem);
266
+ }
267
+ }
268
+
269
+ .hk-btn-label-collapse-lg.hk-btn-lg {
270
+ @media (max-width: 1023.98px) {
271
+ @include hk-btn-collapse-geometry(2.75rem);
272
+ }
273
+ }
274
+
275
+ .hk-btn-label-collapse-xl.hk-btn-xs {
276
+ @media (max-width: 1279.98px) {
277
+ @include hk-btn-collapse-geometry(1.375rem);
278
+ }
279
+ }
280
+
281
+ .hk-btn-label-collapse-xl.hk-btn-sm {
282
+ @media (max-width: 1279.98px) {
283
+ @include hk-btn-collapse-geometry(1.75rem);
284
+ }
285
+ }
286
+
287
+ .hk-btn-label-collapse-xl.hk-btn-md {
288
+ @media (max-width: 1279.98px) {
289
+ @include hk-btn-collapse-geometry(2.5rem);
290
+ }
291
+ }
292
+
293
+ .hk-btn-label-collapse-xl.hk-btn-lg {
294
+ @media (max-width: 1279.98px) {
295
+ @include hk-btn-collapse-geometry(2.75rem);
296
+ }
297
+ }
298
+
299
+
171
300
  /* ---- Primary ---- */
172
301
 
173
302
  .hk-btn-primary {
@@ -255,3 +255,134 @@ describe("HkButton attr fallthrough (inheritAttrs: false)", () => {
255
255
  expect(cls).toContain("extra-class");
256
256
  });
257
257
  });
258
+
259
+ describe("HkButton label prop + responsive collapse contract", () => {
260
+ it("renders the label prop in a .hk-btn-label span and keeps the button textual", () => {
261
+ const c = mount(h(HkButton, { icon: "gauge", label: "Test all" }));
262
+ const btn = button(c);
263
+ const label = c.querySelector(".hk-btn-label");
264
+ expect(label).not.toBeNull();
265
+ expect(label!.textContent).toBe("Test all");
266
+ // icon + label = a text button at wide viewports, never icon-only.
267
+ expect(btn.className).not.toContain("hk-btn-icon-only");
268
+ // The label prop is the accessible name unless one is given.
269
+ expect(btn.getAttribute("aria-label")).toBe("Test all");
270
+ });
271
+
272
+ it("prefers the label prop over the default slot and an explicit ariaLabel over the label", () => {
273
+ const c = mount(h(HkButton, { label: "From prop" }, () => "From slot"));
274
+ expect(c.querySelector(".hk-btn-label")!.textContent).toBe("From prop");
275
+ expect(c.textContent).not.toContain("From slot");
276
+
277
+ const named = mount(h(HkButton, { label: "Visible", ariaLabel: "Custom" }));
278
+ expect(button(named).getAttribute("aria-label")).toBe("Custom");
279
+ });
280
+
281
+ it("emits the collapse modifier class from collapseLabel", () => {
282
+ const c = mount(h(HkButton, { icon: "gauge", label: "Probe", collapseLabel: "sm" }));
283
+ expect(button(c).className).toContain("hk-btn-label-collapse-sm");
284
+ });
285
+
286
+ it("suppresses the collapse class with block or shortcut (nothing to collapse into)", () => {
287
+ const blocked = mount(
288
+ h(HkButton, { icon: "gauge", label: "Probe", collapseLabel: "sm", block: true }),
289
+ );
290
+ expect(button(blocked).className).not.toContain("hk-btn-label-collapse");
291
+
292
+ const withShortcut = mount(
293
+ h(HkButton, { icon: "gauge", label: "Probe", collapseLabel: "sm", shortcut: "Ctrl+B" }),
294
+ );
295
+ expect(button(withShortcut).className).not.toContain("hk-btn-label-collapse");
296
+ });
297
+
298
+ it("keeps label-prop buttons out of the icon-only square class", () => {
299
+ // The narrow-end square is the SCSS media query's job; the class
300
+ // must not flip at mount time or the wide viewport would square a
301
+ // labeled button.
302
+ const c = mount(h(HkButton, { icon: "gauge", label: "Probe", collapseLabel: "sm" }));
303
+ expect(button(c).className).not.toContain("hk-btn-icon-only");
304
+ });
305
+ });
306
+
307
+ // SCSS geometry contract for the responsive collapse (same textual style
308
+ // as the icon-only contract above — happy-dom does not lay out): every
309
+ // collapse breakpoint × size pair must exist, must collapse below the
310
+ // useBreakpoint px scale (sm 640 / md 768 / lg 1024 / xl 1280), and must
311
+ // land on the exact icon-only square width of its size.
312
+ describe("HkButton label-collapse SCSS contract", () => {
313
+ const scss = readFileSync(join(here, "HkButton.scss"), "utf-8");
314
+
315
+ const breakpoints: Array<[string, string]> = [
316
+ ["sm", "639.98px"],
317
+ ["md", "767.98px"],
318
+ ["lg", "1023.98px"],
319
+ ["xl", "1279.98px"],
320
+ ];
321
+ const squares: Array<[string, string]> = [
322
+ ["xs", "1.375rem"],
323
+ ["sm", "1.75rem"],
324
+ ["md", "2.5rem"],
325
+ ["lg", "2.75rem"],
326
+ ];
327
+
328
+ it("collapses every breakpoint × size pair to the icon-only square", () => {
329
+ for (const [bp, query] of breakpoints) {
330
+ for (const [size, square] of squares) {
331
+ const rule = scss.match(
332
+ new RegExp(
333
+ `\\.hk-btn-label-collapse-${bp}\\.hk-btn-${size}\\s*{\\s*@media \\(max-width: ${query}\\) {`,
334
+ ),
335
+ )?.[0];
336
+ expect(rule, `${bp}×${size} collapse rule must exist`).toBeTruthy();
337
+ }
338
+ }
339
+ });
340
+
341
+ it("hides the label and lands on the exact square inside each media block", () => {
342
+ // The per-pair rules @include one shared mixin; the geometry lives in
343
+ // its definition (the compiled CSS materializes it per media block).
344
+ const start = scss.indexOf("@mixin hk-btn-collapse-geometry($square) {");
345
+ expect(start, "collapse mixin must exist").toBeGreaterThan(-1);
346
+ // Slice to the mixin's closing brace (nested rules make a [^}] scan
347
+ // stop early, so index from the header to the documented tail line).
348
+ const end = scss.indexOf("width: $square;", start);
349
+ const mixin = scss.slice(start, end + "width: $square;".length);
350
+ expect(mixin).toContain(".hk-btn-label");
351
+ expect(mixin).toContain("display: none;");
352
+ expect(mixin).toContain("padding: 0;");
353
+ expect(mixin).toContain("box-sizing: border-box;");
354
+ expect(mixin).toContain("width: $square");
355
+
356
+ const smBlock = scss.match(
357
+ /\.hk-btn-label-collapse-sm\.hk-btn-sm\s*{\s*@media \(max-width: 639\.98px\) {([^}]*)}/,
358
+ )?.[1];
359
+ expect(smBlock, "sm×sm collapse body must exist").toBeTruthy();
360
+ expect(smBlock).toContain("@include hk-btn-collapse-geometry(1.75rem)");
361
+ });
362
+
363
+ it("mirrors the useBreakpoint scale breakpoints in order", () => {
364
+ // Keep the SCSS table honest against the runtime scale document —
365
+ // the px values appear in ascending breakpoint order.
366
+ const queries = [...scss.matchAll(/@media \(max-width: (\d+(?:\.\d+)?px)\)/g)].map(
367
+ (m) => m[1],
368
+ );
369
+ expect(queries).toEqual([
370
+ "639.98px",
371
+ "639.98px",
372
+ "639.98px",
373
+ "639.98px",
374
+ "767.98px",
375
+ "767.98px",
376
+ "767.98px",
377
+ "767.98px",
378
+ "1023.98px",
379
+ "1023.98px",
380
+ "1023.98px",
381
+ "1023.98px",
382
+ "1279.98px",
383
+ "1279.98px",
384
+ "1279.98px",
385
+ "1279.98px",
386
+ ]);
387
+ });
388
+ });
@@ -13,6 +13,14 @@ export type ButtonVariant =
13
13
  | "outline";
14
14
  export type ButtonSize = "xs" | "sm" | "md" | "lg";
15
15
 
16
+ /** Breakpoints below which a `collapseLabel` button hides its label and
17
+ * collapses to the icon-only square. Mirrors the runtime useBreakpoint
18
+ * scale (sm 640 / md 768 / lg 1024 / xl 1280) — the SCSS side repeats
19
+ * the px values because a media query cannot read a TS constant; keep
20
+ * the two in sync. "xs" is deliberately absent: collapse-at-xs means
21
+ * "always collapsed", which is just the icon prop + label-less button. */
22
+ export type ButtonLabelCollapse = "sm" | "md" | "lg" | "xl";
23
+
16
24
  export default defineComponent({
17
25
  name: "HkButton",
18
26
  inheritAttrs: false,
@@ -26,6 +34,24 @@ export default defineComponent({
26
34
  shortcut: { type: String, default: undefined },
27
35
  icon: { type: String, default: undefined },
28
36
  suffix: { type: String, default: undefined },
37
+ /** Plain-text label, rendered in a `.hk-btn-label` span after the
38
+ * glyph. Pairs with `collapseLabel`: at wide viewports the button
39
+ * reads icon + text; below the chosen breakpoint the label hides
40
+ * and the button collapses to the icon-only square. When set, the
41
+ * default slot is ignored (one label source per button) and the
42
+ * accessible name falls back to this text, so the button keeps its
43
+ * name at breakpoints where the visible label is display:none. */
44
+ label: { type: String, default: undefined },
45
+ /** Breakpoint below which the label hides and the button collapses
46
+ * to the icon-only square (see `ButtonLabelCollapse`). Requires
47
+ * `label`; ignored together with `block` (a full-width button has
48
+ * nothing to collapse into) and with `shortcut` (the shortcut chip
49
+ * is an extra child the square width would visibly clip — the same
50
+ * exclusion the icon-only contract applies). */
51
+ collapseLabel: {
52
+ type: String as PropType<ButtonLabelCollapse>,
53
+ default: undefined,
54
+ },
29
55
  },
30
56
  emits: {
31
57
  click: (_e: MouseEvent) => true,
@@ -40,6 +66,9 @@ export default defineComponent({
40
66
  props.block ? "hk-btn-block" : "",
41
67
  props.loading ? "hk-btn-loading" : "",
42
68
  props.shortcut ? "hk-btn-has-shortcut" : "",
69
+ props.collapseLabel && props.label && !props.block && !props.shortcut
70
+ ? `hk-btn-label-collapse-${props.collapseLabel}`
71
+ : "",
43
72
  ]);
44
73
 
45
74
  return () => {
@@ -51,9 +80,13 @@ export default defineComponent({
51
80
  // reactive, so a cached flag would go stale when a parent toggles
52
81
  // the label slot without touching the icon props. A shortcut chip
53
82
  // is excluded: it renders as an extra flex child the fixed square
54
- // width would visibly clip.
83
+ // width would visibly clip. A `label` prop counts as text (the
84
+ // responsive collapse handles the narrow end via CSS instead).
55
85
  const iconOnly =
56
- !slots.default && Boolean(props.icon || props.suffix) && !props.shortcut;
86
+ !slots.default &&
87
+ props.label === undefined &&
88
+ Boolean(props.icon || props.suffix) &&
89
+ !props.shortcut;
57
90
 
58
91
  return (
59
92
  <button
@@ -62,7 +95,7 @@ export default defineComponent({
62
95
  disabled={props.disabled || props.loading}
63
96
  class={[buttonClass.value, iconOnly ? "hk-btn-icon-only" : "", attrs.class]}
64
97
  style={attrs.style || undefined}
65
- aria-label={props.ariaLabel}
98
+ aria-label={props.ariaLabel ?? props.label}
66
99
  aria-busy={props.loading || undefined}
67
100
  onClick={(e) => emit("click", e)}
68
101
  >
@@ -116,7 +149,11 @@ export default defineComponent({
116
149
  <HIcon name={props.icon} size={16} />
117
150
  </span>
118
151
  ) : null}
119
- {slots.default?.()}
152
+ {props.label !== undefined && props.label !== "" ? (
153
+ <span class="hk-btn-label">{props.label}</span>
154
+ ) : (
155
+ slots.default?.()
156
+ )}
120
157
  {props.suffix ? (
121
158
  <span class="hk-btn-suffix">
122
159
  <HIcon name={props.suffix} size={16} />
@@ -13,9 +13,16 @@ import HTable from "./HkTable";
13
13
  * `reorder(from, to)` as indices into `rows` (the consumer applies
14
14
  * moveTo(rows, from, to) — insert-at-target)
15
15
  * - a pointer drag past the neighbours' midpoints emits the same pair
16
+ * - the drag LIFTS: a detached ghost (a snapshot of the pressed row on
17
+ * <body>) follows the pointer, the pressed row stays behind as the
18
+ * equal-size placeholder (dimmed), and the slot the pointer resolves to
19
+ * is painted by shifting the arrangement — the placeholder translates
20
+ * onto the target row's spot, the rows in between glide one across
21
+ * - a real drop FREEZES the arrangement until the consumer's `rows`
22
+ * carries it; a cancelled gesture glides everything home and never
23
+ * emits
16
24
  * - the emit is INERT while a column sort is active (a derived arrangement
17
25
  * cannot be reproduced by a two-index move) and the grips announce it
18
- * - a cancelled gesture never emits
19
26
  * - HkAdminTablePage forwards `draggable` + `reorder` (source contract)
20
27
  *
21
28
  * (Repo test convention: raw createApp + document queries, no
@@ -31,6 +38,9 @@ afterEach(() => {
31
38
  app.unmount();
32
39
  container.remove();
33
40
  }
41
+ // A ghost fading at release is removed by a bus timer — a test that
42
+ // ends inside that window must not leak it into the next one.
43
+ document.body.querySelectorAll(".hk-table-drag-ghost").forEach((n) => n.remove());
34
44
  });
35
45
 
36
46
  interface RowsRef {
@@ -236,7 +246,7 @@ describe("HkTable drag-to-reorder", () => {
236
246
  expect(t.container.querySelectorAll('tbody [data-dragging]').length).toBe(0);
237
247
  });
238
248
 
239
- it("paints the insertion cue on the row the pointer resolves to", async () => {
249
+ it("shifts the arrangement onto the slot the pointer resolves to", async () => {
240
250
  const t = mountTable(3);
241
251
  const grip = handles(t.container)[0]!;
242
252
  grip.dispatchEvent(pointer("pointerdown", 18, 20));
@@ -244,12 +254,264 @@ describe("HkTable drag-to-reorder", () => {
244
254
  await nextTick();
245
255
  // Pointer between rows 1 (mid 60) and 2 (mid 100): the walk stops at
246
256
  // 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.
257
+ // = 1. The placeholder (row 0) rides DOWN onto row 1's spot and row 1
258
+ // glides UP into the vacated slot — the swap the user watches, not a
259
+ // line on an edge. Row 2 is untouched.
248
260
  const trs = t.container.querySelectorAll("tbody .hk-table-row");
249
- expect(trs[1]?.getAttribute("data-drop")).toBe("before");
261
+ // The row NAMES the shift (custom property + data-shift); the
262
+ // stylesheet moves its cells — happy-dom computes no CSS, so the
263
+ // paint wiring is pinned by the source contract below.
264
+ expect((trs[0]?.getAttribute("style") ?? "").replace(/\s+/g, "")).toContain("--hk-drag-shift:40px");
265
+ expect(trs[0]?.hasAttribute("data-shift")).toBe(true);
266
+ expect((trs[1]?.getAttribute("style") ?? "").replace(/\s+/g, "")).toContain("--hk-drag-shift:-40px");
267
+ expect(trs[1]?.hasAttribute("data-shift")).toBe(true);
268
+ expect(trs[2]?.getAttribute("style")).toBeNull();
269
+ expect(trs[2]?.hasAttribute("data-shift")).toBe(false);
250
270
  window.dispatchEvent(pointer("pointerup", 18, 70));
251
271
  await nextTick();
252
- expect(trs[1]?.getAttribute("data-drop")).toBeNull();
272
+ });
273
+
274
+ it("paints no shift while the pointer still resolves to the dragged row", async () => {
275
+ const t = mountTable(3);
276
+ const grip = handles(t.container)[0]!;
277
+ grip.dispatchEvent(pointer("pointerdown", 18, 20));
278
+ // Past the threshold but still inside row 0's own band (mid 20): the
279
+ // resolved slot is the row's own — an arrangement equal to the strip
280
+ // as laid out must not paint transforms.
281
+ window.dispatchEvent(pointer("pointermove", 18, 26));
282
+ await nextTick();
283
+ for (const tr of t.container.querySelectorAll("tbody .hk-table-row")) {
284
+ expect(tr.getAttribute("style")).toBeNull();
285
+ }
286
+ window.dispatchEvent(pointer("pointerup", 18, 26));
287
+ await nextTick();
288
+ expect(t.emitted).toEqual([]);
289
+ });
290
+
291
+ /** One animation-bus frame: the ghost attaches on `onceFrame`, so a lift
292
+ * is only observable after the bus drains a frame. */
293
+ async function busFrame(): Promise<void> {
294
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
295
+ await nextTick();
296
+ }
297
+
298
+ function ghost(): HTMLElement | null {
299
+ // The LIVE ghost only — a previous gesture's node fading out is no
300
+ // longer the lift.
301
+ return document.body.querySelector<HTMLElement>(".hk-table-drag-ghost:not([data-fading])");
302
+ }
303
+
304
+ it("lifts the pressed row as a detached ghost over an equal-size placeholder", async () => {
305
+ const t = mountTable(3);
306
+ const grip = handles(t.container)[0]!;
307
+ grip.dispatchEvent(pointer("pointerdown", 18, 20));
308
+ window.dispatchEvent(pointer("pointermove", 18, 26));
309
+ await nextTick();
310
+ await busFrame();
311
+ // The ghost is a snapshot of the pressed row, mounted on <body> — NOT
312
+ // the strip — and carries no placeholder paint of its own.
313
+ const g = ghost();
314
+ expect(g, "the drag ghost on body").toBeTruthy();
315
+ expect(g!.textContent).toContain("Row-0");
316
+ expect(g!.querySelector("[data-dragging]")).toBeNull();
317
+ // The strip row it left is the placeholder: dimmed in place, same
318
+ // element (so exactly the size the strip laid out).
319
+ const trs = t.container.querySelectorAll("tbody .hk-table-row");
320
+ expect(trs[0]?.hasAttribute("data-dragging")).toBe(true);
321
+ expect(trs[0]?.getAttribute("style")).toBeNull();
322
+ window.dispatchEvent(pointer("pointerup", 18, 26));
323
+ await nextTick();
324
+ });
325
+
326
+ it("the ghost follows the pointer vertically and stays anchored to the row's left edge", async () => {
327
+ const t = mountTable(3);
328
+ const grip = handles(t.container)[0]!;
329
+ grip.dispatchEvent(pointer("pointerdown", 18, 20));
330
+ window.dispatchEvent(pointer("pointermove", 18, 26));
331
+ await nextTick();
332
+ await busFrame();
333
+ const g = ghost();
334
+ expect(g?.style.transform).toContain("translate3d(0px, 0px");
335
+ // Straight down from the press (86 - 20: the grab point stays under
336
+ // the pointer), and a horizontal jitter must NOT slide the ghost
337
+ // sideways. The move rides its own task, as a real gesture's does.
338
+ window.dispatchEvent(pointer("pointermove", 90, 86));
339
+ expect(g?.style.transform).toContain("translate3d(0px, 66px");
340
+ window.dispatchEvent(pointer("pointerup", 90, 86));
341
+ await nextTick();
342
+ });
343
+
344
+ it("a real drop freezes the arrangement until the consumer's rows carry it", async () => {
345
+ const t = mountTable(3);
346
+ const grip = handles(t.container)[0]!;
347
+ grip.dispatchEvent(pointer("pointerdown", 18, 20));
348
+ window.dispatchEvent(pointer("pointermove", 18, 70));
349
+ await nextTick();
350
+ await busFrame();
351
+ window.dispatchEvent(pointer("pointerup", 18, 70));
352
+ await nextTick();
353
+ // Dropped 0 → 1: the emit flew, but the consumer has not applied the
354
+ // move yet — the dropped arrangement must STAY painted (no snap back
355
+ // to the pre-drop order while the commit is in the air), the
356
+ // placeholder is un-dimmed, and the ghost is fading at the slot.
357
+ expect(t.emitted).toEqual([[0, 1]]);
358
+ const trs = t.container.querySelectorAll("tbody .hk-table-row");
359
+ expect((trs[0]?.getAttribute("style") ?? "").replace(/\s+/g, "")).toContain("--hk-drag-shift:40px");
360
+ expect(trs[0]?.hasAttribute("data-dragging")).toBe(false);
361
+ expect(document.body.querySelector(".hk-table-drag-ghost[data-fading]")).toBeTruthy();
362
+ expect(t.container.querySelector("table")?.hasAttribute("data-shift-held")).toBe(true);
363
+ // The consumer applies the emitted move: the hold lifts in the same
364
+ // paint that carries the new order (which is where the shift already
365
+ // put the rows — no jump).
366
+ await t.apply(0, 1);
367
+ const settled = t.container.querySelectorAll("tbody .hk-table-row");
368
+ for (const tr of settled) expect(tr.getAttribute("style")).toBeNull();
369
+ expect(t.container.querySelector("table")?.hasAttribute("data-shift-held")).toBe(false);
370
+ });
371
+
372
+ it("a cancelled gesture glides the arrangement home and never emits", async () => {
373
+ const t = mountTable(3);
374
+ const grip = handles(t.container)[0]!;
375
+ grip.dispatchEvent(pointer("pointerdown", 18, 20));
376
+ window.dispatchEvent(pointer("pointermove", 18, 70));
377
+ await nextTick();
378
+ expect(
379
+ (t.container.querySelector("tbody .hk-table-row")?.getAttribute("style") ?? "").replace(/\s+/g, ""),
380
+ ).toContain("--hk-drag-shift:40px");
381
+ window.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, pointerId: 7 }));
382
+ await nextTick();
383
+ // No emit, transforms off. The release beat keeps the transitions
384
+ // ARMED (data-releasing) for exactly one shift window — a CSS
385
+ // transition reads the after-change style, so without it the glide
386
+ // home would snap.
387
+ expect(t.emitted).toEqual([]);
388
+ expect(t.container.querySelector("table")?.hasAttribute("data-releasing")).toBe(true);
389
+ for (const tr of t.container.querySelectorAll("tbody .hk-table-row")) {
390
+ expect(tr.getAttribute("style")).toBeNull();
391
+ }
392
+ // The beat lapses: the strip is idle again, no arming left behind.
393
+ await new Promise((resolve) => setTimeout(resolve, 200));
394
+ expect(t.container.querySelector("table")?.hasAttribute("data-releasing")).toBe(false);
395
+ });
396
+
397
+ it("a new drag while a held arrangement is painted measures the clean strip", async () => {
398
+ const t = mountTable(3);
399
+ // First drag: drop 0 → 1 and leave the arrangement held (the consumer
400
+ // has not applied it).
401
+ const grip0 = handles(t.container)[0]!;
402
+ grip0.dispatchEvent(pointer("pointerdown", 18, 20));
403
+ window.dispatchEvent(pointer("pointermove", 18, 70));
404
+ window.dispatchEvent(pointer("pointerup", 18, 70));
405
+ await nextTick();
406
+ expect(t.container.querySelector("table")?.hasAttribute("data-shift-held")).toBe(true);
407
+ // Second drag starts immediately: the stale shifts must not pollute
408
+ // the measurement — the new gesture resolves against the strip the
409
+ // data describes (row 0 at top 0), not against the shifted paint.
410
+ const grip1 = handles(t.container)[1]!;
411
+ grip1.dispatchEvent(pointer("pointerdown", 18, 60));
412
+ window.dispatchEvent(pointer("pointermove", 18, 66));
413
+ await nextTick();
414
+ // Dragging the (display) row 1 in its own band: no arrangement shift.
415
+ for (const tr of t.container.querySelectorAll("tbody .hk-table-row")) {
416
+ const style = tr.getAttribute("style") ?? "";
417
+ // Row 0 still carries the HELD first-drop shift only if the second
418
+ // gesture resolved nothing — but the hold was released by the new
419
+ // drag, so nothing may be painted.
420
+ expect(style).not.toContain("--hk-drag-shift");
421
+ }
422
+ window.dispatchEvent(pointer("pointerup", 18, 66));
423
+ await nextTick();
424
+ expect(t.emitted).toEqual([[0, 1]]);
425
+ });
426
+
427
+ it("shifts pixel-exact across UNEVEN rows", async () => {
428
+ // happy-dom pins the geometry by hand, so uneven heights are one
429
+ // defineProperty away: a 40/100/20 strip exercises the anchored-delta
430
+ // math the uniform grid cannot distinguish from a height product.
431
+ const t = mountTable(3);
432
+ const heights = [40, 100, 20];
433
+ const tops: number[] = [];
434
+ let acc = 0;
435
+ t.container.querySelectorAll("tbody .hk-table-row").forEach((tr, i) => {
436
+ tops.push(acc);
437
+ const box = { left: 0, right: 300, top: acc, bottom: acc + heights[i]!, width: 300, height: heights[i]!, x: 0, y: acc };
438
+ Object.defineProperty(tr, "getBoundingClientRect", { configurable: true, value: () => box as DOMRect });
439
+ acc += heights[i]!;
440
+ });
441
+ const grip = handles(t.container)[0]!;
442
+ grip.dispatchEvent(pointer("pointerdown", 18, 20));
443
+ // Past row 1's midpoint (top 40 + 50 = 90) but inside row 2 (140..160):
444
+ // slot 2, from 0 → insert-at-target 1: the placeholder takes row 1's
445
+ // spot (top 40), row 1 takes the placeholder's (top 0).
446
+ window.dispatchEvent(pointer("pointermove", 18, 145));
447
+ await nextTick();
448
+ const trs = t.container.querySelectorAll("tbody .hk-table-row");
449
+ expect((trs[0]?.getAttribute("style") ?? "").replace(/\s+/g, "")).toContain(`--hk-drag-shift:${tops[1]! - tops[0]!}px`);
450
+ expect((trs[1]?.getAttribute("style") ?? "").replace(/\s+/g, "")).toContain(`--hk-drag-shift:${tops[0]! - tops[1]!}px`);
451
+ expect(trs[2]?.getAttribute("style")).toBeNull();
452
+ window.dispatchEvent(pointer("pointerup", 18, 145));
453
+ await nextTick();
454
+ });
455
+
456
+ it("re-measures when the consumer replaces rows UNDER a live drag", async () => {
457
+ const t = mountTable(3);
458
+ const grip = handles(t.container)[0]!;
459
+ grip.dispatchEvent(pointer("pointerdown", 18, 20));
460
+ window.dispatchEvent(pointer("pointermove", 18, 70));
461
+ await nextTick();
462
+ expect(
463
+ (t.container.querySelector("tbody .hk-table-row")?.getAttribute("style") ?? "").replace(/\s+/g, ""),
464
+ ).toContain("--hk-drag-shift");
465
+ // The refresh lands mid-drag (a websocket push, a concurrent editor):
466
+ // the strip re-orders (the engine keeps the drag on the pressed
467
+ // ELEMENT, which now sits at display index 1) and the replacement
468
+ // must re-measure — the paint drops until the next resolution.
469
+ await t.setOrder([t.rows.value![2]!, t.rows.value![0]!, t.rows.value![1]!]);
470
+ // A SECOND push relayouts the strip UNEVEN (0 / 100 / 200): a shift
471
+ // computed against the stale 40px tops would read ±80px where the
472
+ // fresh measurement must read ±100px — the assertion separates
473
+ // re-measured from stale.
474
+ const trs = t.container.querySelectorAll("tbody .hk-table-row");
475
+ const stripTops = [0, 100, 200];
476
+ const stripHeights = [100, 100, 20];
477
+ trs.forEach((tr, i) => {
478
+ const start = stripTops[i]!;
479
+ const box = { left: 0, right: 300, top: start, bottom: start + stripHeights[i]!, width: 300, height: stripHeights[i]!, x: 0, y: start };
480
+ Object.defineProperty(tr, "getBoundingClientRect", { configurable: true, value: () => box as DOMRect });
481
+ });
482
+ t.rows.value = t.rows.value!.slice();
483
+ await nextTick();
484
+ // Gliding the pointer into the last row's band re-resolves the slot
485
+ // against the NEW strip: insert at 2 → the pressed row takes row 2's
486
+ // spot and row 2 takes the vacated one.
487
+ window.dispatchEvent(pointer("pointermove", 18, 215));
488
+ await nextTick();
489
+ const styles = [...trs].map((tr) => (tr.getAttribute("style") ?? "").replace(/\s+/g, ""));
490
+ expect(styles[1]).toContain("--hk-drag-shift:100px");
491
+ expect(styles[2]).toContain("--hk-drag-shift:-100px");
492
+ // Row 0 left put: no shift named. (happy-dom renders Vue's cleared
493
+ // style binding as an empty style="" husk; real engines drop it.)
494
+ expect(styles[0]).not.toContain("--hk-drag-shift");
495
+ window.dispatchEvent(pointer("pointerup", 18, 215));
496
+ await nextTick();
497
+ // The gesture ends with the drop against the replaced strip.
498
+ expect(t.emitted).toEqual([[1, 2]]);
499
+ });
500
+
501
+ it("the ghost is hidden from the accessibility tree and carries no focusable chrome", async () => {
502
+ const t = mountTable(3);
503
+ const grip = handles(t.container)[0]!;
504
+ grip.dispatchEvent(pointer("pointerdown", 18, 20));
505
+ window.dispatchEvent(pointer("pointermove", 18, 26));
506
+ await nextTick();
507
+ await busFrame();
508
+ const g = ghost();
509
+ expect(g?.getAttribute("aria-hidden")).toBe("true");
510
+ // The snapshot stripped the grip's tabindex/role: no second tab stop.
511
+ expect(g?.querySelector("[tabindex]")).toBeNull();
512
+ expect(g?.querySelector('[role="button"]')).toBeNull();
513
+ window.dispatchEvent(pointer("pointerup", 18, 26));
514
+ await nextTick();
253
515
  });
254
516
 
255
517
  it("is inert while a column sort is active — grips announce disabled and emits never fire", async () => {
@@ -293,21 +555,6 @@ describe("HkTable drag-to-reorder", () => {
293
555
  expect(t.emitted).toEqual([[0, 1]]);
294
556
  });
295
557
 
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
558
  /** A pointer gesture against an inert table: press + travel + release. */
312
559
  function gripDragAttempt(t: RowsRef & { container: HTMLElement }): void {
313
560
  const grip = handles(t.container)[0]!;
@@ -484,6 +731,50 @@ describe("HkTable grip CSS (source contract)", () => {
484
731
  expect(scss).toMatch(/\.hk-table \.hk-table-drag-cell\s*\{/);
485
732
  expect(scss).toMatch(/\.hk-table \.hk-table-drag-col\s*\{/);
486
733
  });
734
+
735
+ it("paints the placeholder on the CELLS and rounds the end cells", () => {
736
+ // The regression this pins (R1 P1): <tr>-level decoration is the
737
+ // engine-unreliable class the predecessor's own warning ruled out —
738
+ // the placeholder tint and its rounding must live on the cells, whose
739
+ // boxes transform and round identically everywhere.
740
+ expect(scss).toMatch(/\.hk-table-draggable \.hk-table-row\[data-dragging\] > \.hk-table-cell \{/);
741
+ expect(scss).toMatch(/\[data-dragging\] > \.hk-table-cell:first-child \{[\s\S]*?border-top-left-radius: var\(--radius-md/);
742
+ expect(scss).toMatch(/\[data-dragging\] > \.hk-table-cell:last-child \{[\s\S]*?border-bottom-right-radius: var\(--radius-md/);
743
+ // The shift likewise rides the cells (named by the row's custom
744
+ // property), never a row transform.
745
+ expect(scss).toMatch(/\[data-shift\] > \.hk-table-cell \{[\s\S]*?transform: translateY\(var\(--hk-drag-shift/);
746
+ expect(scss).not.toMatch(/^\s*clip-path:/m);
747
+ // And the line-cue era is gone: no data-drop edge shadows survive.
748
+ expect(scss).not.toMatch(/data-drop/);
749
+ });
750
+
751
+ it("the shift glides ride only a live, held, or releasing strip", () => {
752
+ // Transitions armed by `data-dragging` / `data-shift-held` /
753
+ // `data-releasing` on the TABLE: idle cells carry no transform
754
+ // transition, so the forced stale-transform clear at a new drag's
755
+ // measure never glides — and `data-releasing` is what keeps the
756
+ // cancelled return a glide (the after-change style governs).
757
+ expect(scss).toMatch(/\.hk-table-draggable\[data-dragging\] \.hk-table-row > \.hk-table-cell,/);
758
+ expect(scss).toMatch(/\.hk-table-draggable\[data-shift-held\] \.hk-table-row > \.hk-table-cell,/);
759
+ expect(scss).toMatch(/\.hk-table-draggable\[data-releasing\] \.hk-table-row > \.hk-table-cell \{/);
760
+ });
761
+
762
+ it("the ghost is a detached, semi-transparent, rounded lift below the popup bands", () => {
763
+ expect(scss).toMatch(/\.hk-table-drag-ghost\s*\{/);
764
+ expect(scss).toMatch(/position: fixed;/);
765
+ expect(scss).toMatch(/border-radius: var\(--radius-md/);
766
+ expect(scss).toMatch(/z-index: var\(--hk-z-drag, 950\)/);
767
+ // The settle is a fade, driven by data-fading.
768
+ expect(scss).toMatch(/&\[data-fading]\s*\{/);
769
+ });
770
+
771
+ it("reduced motion snaps the arrangement glides and the ghost fade", () => {
772
+ const guard = scss.indexOf("@media (prefers-reduced-motion: reduce)");
773
+ expect(guard).toBeGreaterThan(-1);
774
+ const block = scss.slice(guard);
775
+ expect(block).toContain(".hk-table-drag-ghost");
776
+ expect(block).toContain("transition: opacity 150ms ease");
777
+ });
487
778
  });
488
779
 
489
780
  describe("HkAdminTablePage drag passthrough (source contract)", () => {
@@ -192,23 +192,62 @@
192
192
  }
193
193
  }
194
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. */
195
+ /* The pressed row becomes the PLACEHOLDER: the same element, so exactly
196
+ the size the strip laid out, dimmed and primary-tinted where it sits
197
+ (house pattern: HkDraggableList's source row). The paint lives on the
198
+ CELLS, not the <tr>: a row's cells are plain boxes in every engine,
199
+ while row-level decoration (a clip-path rounding a box-shadow ring)
200
+ rides the engine's table paint pass and is exactly the kind of <tr>
201
+ styling the predecessor's own warning ruled unreliable. The tint rounds
202
+ on the end cells' radii — the "selected border is square" fix, drawn
203
+ where rounding is guaranteed. */
198
204
  .hk-table-draggable .hk-table-row[data-dragging] {
199
- opacity: 0.45;
205
+ opacity: 0.4;
206
+ }
207
+
208
+ .hk-table-draggable .hk-table-row[data-dragging] > .hk-table-cell {
200
209
  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
210
  }
203
211
 
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);
212
+ .hk-table-draggable .hk-table-row[data-dragging] > .hk-table-cell:first-child {
213
+ border-top-left-radius: var(--radius-md, 8px);
214
+ border-bottom-left-radius: var(--radius-md, 8px);
215
+ }
216
+
217
+ .hk-table-draggable .hk-table-row[data-dragging] > .hk-table-cell:last-child {
218
+ border-top-right-radius: var(--radius-md, 8px);
219
+ border-bottom-right-radius: var(--radius-md, 8px);
208
220
  }
209
221
 
210
- .hk-table-draggable .hk-table-row[data-drop="after"] {
211
- box-shadow: inset 0 -2px 0 0 var(--hi-color-primary, #7aa2f7);
222
+ /* The FLIP shift: the row NAMES the displacement (a custom property is
223
+ inert paint-wise) and its cells carry it — a <td> is a plain box, so
224
+ the transform is engine-proof where a <tr> transform is not (the
225
+ predecessor's own warning). Rows the arrangement leaves put carry no
226
+ [data-shift] and no property. */
227
+ .hk-table-draggable[data-dragging] .hk-table-row[data-shift] > .hk-table-cell,
228
+ .hk-table-draggable[data-shift-held] .hk-table-row[data-shift] > .hk-table-cell,
229
+ .hk-table-draggable[data-releasing] .hk-table-row[data-shift] > .hk-table-cell {
230
+ transform: translateY(var(--hk-drag-shift, 0px));
231
+ }
232
+
233
+ /* Arrangement shifts (the placeholder onto the resolved slot, neighbours
234
+ gliding one slot across), the placeholder's un-dim on release and the
235
+ cancelled arrangement's return glide transition; they are armed only
236
+ while a gesture owns the strip (`data-dragging`), while a held drop
237
+ waits for the consumer's rows (`data-shift-held`), or for the one beat
238
+ a release needs to glide home (`data-releasing` — the arming attribute
239
+ must outlive the transform removal, since a CSS transition reads the
240
+ after-change style) — so the forced stale-transform clear at a NEW
241
+ drag's measure never glides, and idle cells never carry a transition. */
242
+ .hk-table-draggable[data-dragging] .hk-table-row > .hk-table-cell,
243
+ .hk-table-draggable[data-shift-held] .hk-table-row > .hk-table-cell,
244
+ .hk-table-draggable[data-releasing] .hk-table-row > .hk-table-cell {
245
+ transition: transform 150ms cubic-bezier(0.16, 1, 0.3, 1);
246
+ }
247
+
248
+ .hk-table-draggable[data-dragging] .hk-table-row,
249
+ .hk-table-draggable[data-releasing] .hk-table-row {
250
+ transition: opacity 150ms cubic-bezier(0.16, 1, 0.3, 1);
212
251
  }
213
252
 
214
253
  /* Text under a live drag must not select (the press starts on the grip,
@@ -218,8 +257,62 @@
218
257
  -webkit-user-select: none;
219
258
  }
220
259
 
221
- /* Reduced motion: the lift is paint-only already (no transform to snap);
222
- the hover/press fades stay (house convention: HkDraggableList.scss). */
260
+ /* The lifted row the pointer carries: a detached, semi-transparent clone
261
+ of the pressed row (house pattern: HkDraggableList's ghost), appended
262
+ to <body> and positioned via the inline translate3d the tsx writes on
263
+ every pointer move — no class transform could win that cascade. It is
264
+ a plain positioned box, so THIS is where the lift gets the rounded
265
+ corners and the elevation shadow the <tr> placeholder cannot draw. */
266
+ .hk-table-drag-ghost {
267
+ position: fixed;
268
+ top: 0;
269
+ left: 0;
270
+ margin: 0;
271
+ opacity: 0.88;
272
+ border-radius: var(--radius-md, 8px);
273
+ box-shadow:
274
+ 0 8px 24px rgb(0 0 0 / 28%),
275
+ 0 2px 6px rgb(0 0 0 / 18%),
276
+ inset 0 0 0 1px var(--hi-color-primary, #7aa2f7);
277
+ background: var(--hi-color-bg-elevated, #161b22);
278
+ overflow: hidden;
279
+ pointer-events: none;
280
+ /* Drag ghost: above page chrome (--z-sidebar 900) but below every
281
+ popup band (overlay 1000 < dropdown 2000 < tooltip 3000 < toast 4000)
282
+ so a drag never buries toasts or open panels. */
283
+ z-index: var(--hk-z-drag, 950);
284
+ will-change: transform;
285
+ transition: opacity 150ms cubic-bezier(0.16, 1, 0.3, 1);
286
+
287
+ &[data-fading] {
288
+ opacity: 0;
289
+ }
290
+
291
+ > .hk-table {
292
+ margin: 0;
293
+ }
294
+ }
295
+
296
+ /* Reduced motion: the arrangement glides snap instead of tweening, and
297
+ the ghost skips its fade (the row follows the pointer 1:1 — direct
298
+ manipulation, not autonomous motion — so IT is left alone; house
299
+ convention: HkDraggableList.scss / HkAffixPicker.scss guards). */
300
+ @media (prefers-reduced-motion: reduce) {
301
+ .hk-table-draggable[data-dragging] .hk-table-row > .hk-table-cell,
302
+ .hk-table-draggable[data-shift-held] .hk-table-row > .hk-table-cell,
303
+ .hk-table-draggable[data-releasing] .hk-table-row > .hk-table-cell {
304
+ transition: none;
305
+ }
306
+
307
+ .hk-table-draggable[data-dragging] .hk-table-row,
308
+ .hk-table-draggable[data-releasing] .hk-table-row {
309
+ transition: opacity 150ms ease;
310
+ }
311
+
312
+ .hk-table-drag-ghost {
313
+ transition: none;
314
+ }
315
+ }
223
316
 
224
317
  /* Alignment helpers (start/end so aligned columns mirror in RTL; the
225
318
  tsx keeps emitting the hk-text-* names) */
@@ -1,8 +1,12 @@
1
- import { computed, defineComponent, onBeforeUnmount, onMounted, ref, type PropType } from "vue";
1
+ import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch, type PropType } from "vue";
2
2
 
3
3
  import { useI18n } from "../i18n/context";
4
4
  import { usePointerReorder } from "../composables/usePointerReorder";
5
+ import { useReportedTransition } from "../composables/useReportedTransition";
5
6
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
7
+ import { onceFrame } from "../runtime/animationBus";
8
+ import { scheduleCronAfter, type CronHandle } from "../runtime/cronBus";
9
+ import { ancestorZoom } from "../runtime/cssZoom";
6
10
  import "./HkTable.scss";
7
11
 
8
12
  interface Column {
@@ -13,6 +17,13 @@ interface Column {
13
17
  align?: "left" | "center" | "right";
14
18
  }
15
19
 
20
+ /** Duration of every drag motion this component paints: the arrangement
21
+ * shift (placeholder onto the resolved slot, neighbours gliding across)
22
+ * and the ghost's settle fade on release. One number so the transition
23
+ * the user sees and the animation window reported to the shared bus
24
+ * (useReportedTransition) cannot drift apart. */
25
+ const DRAG_SHIFT_MS = 150;
26
+
16
27
  export default defineComponent({
17
28
  name: "HkTable",
18
29
  props: {
@@ -181,6 +192,15 @@ export default defineComponent({
181
192
  if (reorderInert.value) return;
182
193
  if (from === to || from < 0 || to < 0) return;
183
194
  if (from >= props.rows.length || to >= props.rows.length) return;
195
+ // The arrangement the user dropped is ALREADY on screen (the live
196
+ // shift painted it while the pointer hovered) — freeze it, so the
197
+ // commit-whenever-the-consumer-lands-it is seamless: the render that
198
+ // carries the new order paints the rows exactly where the shift
199
+ // already put them. A consumer that persists over the network first
200
+ // (the house pattern: every admin list upsert) keeps showing the
201
+ // chosen order instead of snapping back to the pre-drop one.
202
+ arrangement.value = { from, slot: to };
203
+ arrangementHeld.value = true;
184
204
  emit("reorder", from, to);
185
205
  }
186
206
 
@@ -209,13 +229,306 @@ export default defineComponent({
209
229
  scrollContainer: dragScrollContainer,
210
230
  });
211
231
 
232
+ // ── Drag lift: ghost, placeholder and the FLIP shift ────────────────
233
+ // A lift no longer paints on the strip alone. The pressed row becomes
234
+ // the PLACEHOLDER — the same element, so exactly the size the strip
235
+ // laid out, dimmed and tinted where it sits — and a detached,
236
+ // semi-transparent GHOST (a snapshot of the row the user grabbed)
237
+ // floats with the pointer above the page. The slot the pointer
238
+ // resolves to is painted by SHIFTING the arrangement: the placeholder
239
+ // translates onto the target row's spot and every row in between
240
+ // glides one slot across, so the drop commits an arrangement the user
241
+ // has already watched settle — the old paint (a line on an edge) is
242
+ // gone. Everything runs on the shared animation context: the shifts
243
+ // and the settle are reported transitions (useReportedTransition, so
244
+ // the bus knows motion is in flight), the ghost attaches on a bus
245
+ // frame (onceFrame) and its removal on a bus timer
246
+ // (scheduleCronAfter), and the `prefers-reduced-motion` media guard in
247
+ // the stylesheet snaps the glides.
248
+
249
+ const anim = useReportedTransition(DRAG_SHIFT_MS);
250
+
251
+ /** The strip measured at drag start: per-row visual tops/heights plus
252
+ * the cumulative CSS zoom the rows paint under (chest's root DPI
253
+ * scale). The arrangement math runs in these VISUAL px (gBCR space)
254
+ * and divides by the zoom once at the write layer — a transform on a
255
+ * row inside a zoomed subtree paints zoom× its local px. */
256
+ interface StripMeasure {
257
+ tops: number[];
258
+ zoom: number;
259
+ left: number;
260
+ width: number;
261
+ }
262
+ const stripMeasure = ref<StripMeasure | null>(null);
263
+ /** The painted arrangement: display index `from`'s placeholder sits at
264
+ * slot `slot`. Live while the drag moves; FROZEN after a real drop
265
+ * (see `onReorderDrop`) until `rows` carries the emitted move. */
266
+ const arrangement = ref<{ from: number; slot: number } | null>(null);
267
+ const arrangementHeld = ref(false);
268
+
269
+ /** Row displacement (LOCAL px, ready for a transform) for display
270
+ * index `index` under the live arrangement — the FLIP delta between
271
+ * the slot the strip measured the row at and the slot the arrangement
272
+ * puts it in. Anchored to the strip's own measured positions: the row
273
+ * landing at final slot f sits exactly where the strip laid the f-th
274
+ * row out, so uneven rows land pixel-exact too. */
275
+ function rowShift(index: number): number {
276
+ const m = stripMeasure.value;
277
+ const a = arrangement.value;
278
+ if (!m || !a || a.from === a.slot) return 0;
279
+ if (index < 0 || index >= m.tops.length) return 0;
280
+ const order = m.tops.map((_, i) => i);
281
+ order.splice(a.from, 1);
282
+ order.splice(a.slot, 0, a.from);
283
+ const finalTop = new Array<number>(m.tops.length).fill(0);
284
+ for (let f = 0; f < order.length; f += 1) finalTop[order[f]!] = m.tops[f]!;
285
+ const delta = finalTop[index]! - m.tops[index]!;
286
+ return Math.abs(delta) < 0.5 ? 0 : delta / m.zoom;
287
+ }
288
+
289
+ function measureStrip(): void {
290
+ const rows = liveRowEls();
291
+ if (rows.length === 0) {
292
+ stripMeasure.value = null;
293
+ return;
294
+ }
295
+ // A drag starting while a HELD arrangement is still painted would
296
+ // measure the shifted paint — force the stale transforms off first
297
+ // (the render will not re-apply them: the arrangement was cleared).
298
+ // removeProperty rather than an empty write: an empty transform
299
+ // value would leave a `style=""` husk on rows that never carried
300
+ // anything else.
301
+ for (const row of rows) {
302
+ row.style.removeProperty("transform");
303
+ if (!row.getAttribute("style")) row.removeAttribute("style");
304
+ }
305
+ const zoom = ancestorZoom(rows[0]!);
306
+ const tops: number[] = [];
307
+ rows.forEach((row) => {
308
+ tops.push(row.getBoundingClientRect().top);
309
+ });
310
+ const source = rows[rowDrag.dragFrom.value]?.getBoundingClientRect();
311
+ stripMeasure.value = {
312
+ tops,
313
+ zoom,
314
+ left: source?.left ?? 0,
315
+ width: source?.width ?? 0,
316
+ };
317
+ }
318
+
319
+ // ── The ghost ──
320
+ let ghostNode: HTMLElement | null = null;
321
+ let ghostFading: HTMLElement | null = null;
322
+ let ghostHTML = "";
323
+ let ghostStartTop = 0;
324
+ let ghostGrabOffsetY = 0;
325
+ let pressClientY = 0;
326
+ let ghostFadeTimer: CronHandle | null = null;
327
+
328
+ /** Snapshot the pressed row BEFORE the drag paint lands on it (this
329
+ * runs in the pre-render watcher): the ghost is the row the user
330
+ * grabbed, not the dimmed placeholder it is about to become. The
331
+ * clone must not carry the row's interactive chrome — its grip keeps
332
+ * `tabindex`/`role` in the snapshot HTML, and a second tabbable copy
333
+ * of every row would be a keyboard trap — so the attributes come off
334
+ * and the ghost root is hidden from the accessibility tree. */
335
+ function captureGhost(): void {
336
+ const row = liveRowEls()[rowDrag.dragFrom.value];
337
+ ghostHTML = row
338
+ ? row.outerHTML
339
+ .replace(/\sdata-dragging="[^"]*"/g, "")
340
+ .replace(/\sdata-drop="[^"]*"/g, "")
341
+ .replace(/\stabindex="[^"]*"/g, "")
342
+ .replace(/\srole="button"/g, "")
343
+ : "";
344
+ const rect = row?.getBoundingClientRect();
345
+ ghostStartTop = rect?.top ?? 0;
346
+ ghostGrabOffsetY = pressClientY - ghostStartTop;
347
+ }
348
+
349
+ /** Attach the snapshot as a fixed-position clone above the page. The
350
+ * ghost lives on <body> — inside any root CSS zoom subtree its px are
351
+ * LOCAL while the measured rect reports the root VISUAL space — so
352
+ * every write divides by the body's cumulative zoom (house pattern:
353
+ * HkDraggableList's ghost). */
354
+ function attachGhost(): void {
355
+ const m = stripMeasure.value;
356
+ if (!m || !ghostHTML || m.width <= 0) return;
357
+ detachGhost();
358
+ const zb = ancestorZoom(document.body);
359
+ const node = document.createElement("div");
360
+ node.className = "hk-table-drag-ghost";
361
+ // The clone's table carries the source table's classes, so size and
362
+ // bordered variants paint identically to the row it lifted.
363
+ const table = document.createElement("table");
364
+ const sourceTable = wrapperHostRef.value?.querySelector("table.hk-table");
365
+ if (sourceTable) table.className = sourceTable.className;
366
+ table.style.width = `${m.width / zb}px`;
367
+ table.style.tableLayout = "fixed";
368
+ const tbody = document.createElement("tbody");
369
+ tbody.className = "hk-table-body";
370
+ tbody.innerHTML = ghostHTML;
371
+ // Fixed layout takes its column spec from the first row's widths —
372
+ // pin each clone cell to the source's rendered width so the ghost's
373
+ // columns line up with the strip it floats over.
374
+ const sourceCells = liveRowEls()[rowDrag.dragFrom.value]?.querySelectorAll<HTMLElement>("td, th");
375
+ const ghostCells = tbody.querySelectorAll<HTMLElement>("td, th");
376
+ ghostCells.forEach((cell, i) => {
377
+ const w = sourceCells?.[i]?.getBoundingClientRect().width ?? 0;
378
+ if (w > 0) cell.style.width = `${w / zb}px`;
379
+ });
380
+ table.appendChild(tbody);
381
+ node.appendChild(table);
382
+ node.style.width = `${m.width / zb}px`;
383
+ moveGhostTo(node, pressClientY);
384
+ node.style.pointerEvents = "none";
385
+ node.setAttribute("aria-hidden", "true");
386
+ document.body.appendChild(node);
387
+ ghostNode = node;
388
+ }
389
+
390
+ function moveGhostTo(node: HTMLElement, clientY: number): void {
391
+ const m = stripMeasure.value;
392
+ if (!m) return;
393
+ const zb = ancestorZoom(document.body);
394
+ // Vertical only: the ghost stays anchored to the row's own left
395
+ // edge, so a sideways jitter never slides the row across columns.
396
+ const x = m.left / zb;
397
+ const y = (clientY - ghostGrabOffsetY) / zb;
398
+ node.style.transform = `translate3d(${x}px, ${y}px, 0)`;
399
+ }
400
+
401
+ function onDragPointerMove(event: PointerEvent): void {
402
+ if (!ghostNode) return;
403
+ moveGhostTo(ghostNode, event.clientY);
404
+ }
405
+
406
+ /** The ghost hands off to the placeholder: a short fade at the
407
+ * position where the pointer released, removed on a bus timer. */
408
+ function fadeGhost(): void {
409
+ if (!ghostNode) return;
410
+ const node = ghostNode;
411
+ ghostNode = null;
412
+ ghostFading = node;
413
+ node.setAttribute("data-fading", "");
414
+ ghostFadeTimer?.disconnect();
415
+ ghostFadeTimer = scheduleCronAfter(() => {
416
+ ghostFadeTimer = null;
417
+ ghostFading?.remove();
418
+ ghostFading = null;
419
+ }, DRAG_SHIFT_MS);
420
+ }
421
+
422
+ function detachGhost(): void {
423
+ ghostFadeTimer?.disconnect();
424
+ ghostFadeTimer = null;
425
+ ghostNode?.remove();
426
+ ghostNode = null;
427
+ // A fade in flight has already left `ghostNode` — it is owned by the
428
+ // timer alone, so an unmount mid-fade must reap it here or it would
429
+ // linger on <body> with its removal cancelled.
430
+ ghostFading?.remove();
431
+ ghostFading = null;
432
+ }
433
+
434
+ /** The beat after a cancel / release-on-origin: the transforms come
435
+ * off in the same flush that removes `data-dragging`, and a CSS
436
+ * transition takes the AFTER-change style — with the arming attribute
437
+ * gone the return would snap. `data-releasing` keeps the transitions
438
+ * armed for exactly one shift window so the glide is real. */
439
+ const releasing = ref(false);
440
+ let releaseTimer: CronHandle | null = null;
441
+
442
+ watch(() => rowDrag.dragging.value, (active) => {
443
+ if (active) {
444
+ arrangementHeld.value = false;
445
+ releaseTimer?.disconnect();
446
+ releaseTimer = null;
447
+ releasing.value = false;
448
+ arrangement.value = null;
449
+ measureStrip();
450
+ captureGhost();
451
+ onceFrame(() => {
452
+ // The frame may land after a fast release — never paint a lift
453
+ // for a gesture that is already over.
454
+ if (!rowDrag.dragging.value) return;
455
+ attachGhost();
456
+ anim.run();
457
+ });
458
+ window.addEventListener("pointermove", onDragPointerMove);
459
+ } else {
460
+ // The engine clears its refs before `onDrop` runs, so by the time
461
+ // this flushes, a real drop has already frozen the arrangement.
462
+ window.removeEventListener("pointermove", onDragPointerMove);
463
+ if (!arrangementHeld.value) {
464
+ // Cancel / release on the origin: glide the arrangement home.
465
+ const moved = arrangement.value != null && arrangement.value.from !== arrangement.value.slot;
466
+ arrangement.value = null;
467
+ if (moved) {
468
+ releasing.value = true;
469
+ anim.run();
470
+ releaseTimer?.disconnect();
471
+ releaseTimer = scheduleCronAfter(() => {
472
+ releaseTimer = null;
473
+ releasing.value = false;
474
+ }, DRAG_SHIFT_MS);
475
+ }
476
+ }
477
+ fadeGhost();
478
+ }
479
+ });
480
+
481
+ watch(() => rowDrag.dragOver.value, (slot) => {
482
+ if (!rowDrag.dragging.value || slot < 0) return;
483
+ const from = rowDrag.dragFrom.value;
484
+ const a = arrangement.value;
485
+ if (a && a.from === from && a.slot === slot) return;
486
+ arrangement.value = { from, slot };
487
+ // A slot equal to the origin paints no shift — reporting motion for
488
+ // nothing would only tell the bus a lie.
489
+ if (from !== slot) anim.run();
490
+ });
491
+
492
+ watch(() => props.rows, () => {
493
+ // The consumer's array moved — the committed drop, an external edit,
494
+ // a refresh. The held arrangement's job is done: clearing in this
495
+ // same pre-render flush paints the new order and drops the shifts in
496
+ // one paint, which coincide (that is the freeze's whole point).
497
+ // NOTE the contract: reference replacement. A consumer mutating the
498
+ // array in place must emit a replacement (or re-key) for the hold to
499
+ // lift — the house consumers all do.
500
+ arrangementHeld.value = false;
501
+ if (rowDrag.dragging.value) {
502
+ // A strip replaced UNDER a live drag: the measurement is stale
503
+ // (tops were read at press time) and the engine will resolve
504
+ // against the NEW elements — re-measure the clean strip so the
505
+ // paint follows, and let the shift re-arm on the next resolution.
506
+ arrangement.value = null;
507
+ measureStrip();
508
+ return;
509
+ }
510
+ arrangement.value = null;
511
+ });
512
+
513
+ onBeforeUnmount(() => {
514
+ window.removeEventListener("pointermove", onDragPointerMove);
515
+ releaseTimer?.disconnect();
516
+ releaseTimer = null;
517
+ detachGhost();
518
+ });
519
+
212
520
  function onHandlePointerdown(e: PointerEvent, index: number): void {
213
521
  if (reorderInert.value) return;
214
522
  // The press's own row is looked up in the live strip: the render-time
215
523
  // index is a fallback for a handle that somehow left the table.
216
524
  const row = (e.currentTarget as HTMLElement | null)?.closest("tr");
217
525
  const strip = row ? liveRowEls().indexOf(row as HTMLElement) : index;
218
- if (strip >= 0) rowDrag.start(e, strip);
526
+ if (strip >= 0) {
527
+ // Where the finger grabbed the row — the ghost hangs the strip's
528
+ // lift off this point so the grab stays under the pointer.
529
+ pressClientY = e.clientY;
530
+ rowDrag.start(e, strip);
531
+ }
219
532
  }
220
533
 
221
534
  /** Keyboard twin of the drop: move one row one slot. Runs on a focused
@@ -251,22 +564,6 @@ export default defineComponent({
251
564
  return `${before}${label}${after}`;
252
565
  }
253
566
 
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
-
270
567
  const tableCls = computed(() => [
271
568
  "hk-table",
272
569
  `hk-table-${props.size}`,
@@ -276,6 +573,19 @@ export default defineComponent({
276
573
  props.draggable ? "hk-table-draggable" : "",
277
574
  ]);
278
575
 
576
+ /** Drag-scoped switches the stylesheet reads: `data-dragging` arms the
577
+ * shift transitions for as long as a gesture owns the strip;
578
+ * `data-shift-held` keeps them armed after a drop while the frozen
579
+ * arrangement waits for `rows` to carry it; `data-releasing` arms
580
+ * them for the one beat a cancelled arrangement needs to glide home
581
+ * (the arming attribute must OUTLIVE the transform removal, or the
582
+ * after-change style snaps). */
583
+ const dragScope = computed(() => ({
584
+ "data-dragging": rowDrag.dragging.value ? "" : undefined,
585
+ "data-shift-held": arrangementHeld.value ? "" : undefined,
586
+ "data-releasing": releasing.value ? "" : undefined,
587
+ }));
588
+
279
589
  const totalCols = computed(
280
590
  () => props.columns.length + (props.selectable ? 1 : 0) + (props.draggable ? 1 : 0)
281
591
  );
@@ -284,7 +594,7 @@ export default defineComponent({
284
594
  return (
285
595
  <div ref={wrapperHostRef} class="hk-table-host">
286
596
  <div ref={wrapperRef} class="hk-table-wrapper">
287
- <table class={tableCls.value}>
597
+ <table class={tableCls.value} {...dragScope.value}>
288
598
  {props.caption && <caption class="hk-table-sr-only">{props.caption}</caption>}
289
599
  <thead>
290
600
  <tr class="hk-table-header-row">
@@ -370,14 +680,22 @@ export default defineComponent({
370
680
  ) : (
371
681
  sortedRows.value.map((row, index) => {
372
682
  const rowKey = getRowKey(row, index);
373
- const dropCueEdge =
374
- dropCue.value && dropCue.value.index === index ? dropCue.value.edge : null;
683
+ // The FLIP shift this row rides under the live (or frozen)
684
+ // arrangement — 0 for rows the arrangement leaves put. It
685
+ // travels to the CELLS as a custom property, not as a row
686
+ // transform: `<tr>` transforms are unreliable across
687
+ // engines (the predecessor lift avoided them for exactly
688
+ // this), while a <td> is a plain box everywhere — and a
689
+ // custom property is inert paint-wise, so the row element
690
+ // only NAMES the shift and the stylesheet moves the paint.
691
+ const shiftPx = rowShift(index);
375
692
  return (
376
693
  <tr
377
694
  key={rowKey}
378
695
  class="hk-table-row"
696
+ style={shiftPx ? { "--hk-drag-shift": `${shiftPx}px` } : undefined}
697
+ data-shift={shiftPx ? "" : undefined}
379
698
  data-dragging={rowDrag.dragging.value && rowDrag.dragFrom.value === index ? "" : undefined}
380
- data-drop={dropCueEdge || undefined}
381
699
  >
382
700
  {props.draggable && (
383
701
  <td class="hk-table-cell hk-table-drag-cell">
@@ -8,7 +8,7 @@
8
8
  // badges, nav tags) resolve without the wildcard import. Unknown names fall
9
9
  // back to `Info`, matching the previous `|| LucideIcons.Info` behavior.
10
10
 
11
- import { Activity, ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Ban, Battery, Bell, Bot, Box, Braces, Brain, Cable, Calendar, Camera, ChartBarBig as BarChart3, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Circle, CircleAlert as AlertCircle, CircleCheck as CheckCircle, CircleCheckBig as CheckCircle2, CircleDot, Clock, Copy, Cpu, Database, Dot, Download, Ellipsis as MoreHorizontal, ExternalLink, Eye, FileArchive, FileCode, FileImage, FileText, Filter, Flag, Flame, FolderOpen, Gauge, Globe, HardDrive, History, Image, Info, Kanban, Key, KeyRound, Layers, LayoutDashboard, LoaderCircle as Loader2, LogOut, Lock, Maximize, Maximize2, MessageSquare, Mic, MicOff, Minimize, Minus, Monitor, Network, Package, Pause, Pencil, Play, Plug, Plus, Radio, RefreshCw, RotateCw, ScanSearch, Save, Search, Send, Server, Settings, Share2, Shield, Sparkles, Star, Table, Tag, Thermometer, Trash2, TriangleAlert as AlertTriangle, Upload, Video, Volume2, VolumeX, Webhook, Wind, Workflow, Wrench, X, Zap, ZoomIn, ZoomOut } from "lucide-vue-next";
11
+ import { Activity, ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Ban, Battery, Bell, Bot, Box, Braces, Brain, Cable, Calendar, Camera, ChartBarBig as BarChart3, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Circle, CircleAlert as AlertCircle, CircleCheck as CheckCircle, CircleCheckBig as CheckCircle2, CircleDot, Clock, Copy, Cpu, Database, Dot, Download, Ellipsis as MoreHorizontal, ExternalLink, Eye, FileArchive, FileCode, FileImage, FileText, Filter, Flag, Flame, FolderOpen, Gauge, Globe, HardDrive, History, Image, Info, Kanban, Key, KeyRound, Layers, LayoutDashboard, LoaderCircle as Loader2, LogOut, Lock, Maximize, Maximize2, MessageSquare, Mic, MicOff, Minimize, Minus, Monitor, Network, Package, Pause, Pencil, Play, Plug, Plus, Radio, RefreshCw, RotateCw, ScanSearch, Save, Search, Send, Server, Settings, Share2, Shield, SlidersHorizontal, Sparkles, Star, Table, Tag, Thermometer, Trash2, TriangleAlert as AlertTriangle, Upload, Video, Volume2, VolumeX, Webhook, Wind, Workflow, Wrench, X, Zap, ZoomIn, ZoomOut } from "lucide-vue-next";
12
12
  import type { Component } from "vue";
13
13
 
14
14
 
@@ -109,7 +109,7 @@ const REGISTRY: Record<string, Component> = {
109
109
  Network, Package, Pause, Pencil, Play, Plug, Plus, Radio, RefreshCw,
110
110
  RotateCw,
111
111
  ScanSearch, Save, Search, Send, Server, Settings, Share2, Shield,
112
- Sparkles, Star, Table, Tag, Thermometer, Trash2, Upload, Video, Volume2,
112
+ SlidersHorizontal, Sparkles, Star, Table, Tag, Thermometer, Trash2, Upload, Video, Volume2,
113
113
  VolumeX, Webhook, Wind, Workflow, Wrench, X, Zap, ZoomIn, ZoomOut,
114
114
  };
115
115