@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 +1 -1
- package/src/components/HkButton.scss +129 -0
- package/src/components/HkButton.test.tsx +131 -0
- package/src/components/HkButton.tsx +41 -4
- package/src/components/HkTable.drag.test.tsx +311 -20
- package/src/components/HkTable.scss +106 -13
- package/src/components/HkTable.tsx +340 -22
- package/src/composables/iconRegistry.ts +2 -2
package/package.json
CHANGED
|
@@ -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 &&
|
|
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
|
-
{
|
|
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("
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
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.
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
/*
|
|
222
|
-
the
|
|
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)
|
|
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
|
-
|
|
374
|
-
|
|
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
|
|