@celestia-island/hikari 0.55.274 → 0.55.276

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.274",
3
+ "version": "0.55.276",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -40,15 +40,41 @@
40
40
  font-variant-numeric: tabular-nums;
41
41
  }
42
42
 
43
+ // Above-bar value label (showValues) — the same 10-unit voice as the
44
+ // axis, a step stronger so the count reads first.
45
+ &__value {
46
+ fill: rgb(var(--color-text) / 0.75);
47
+ font-size: 10px;
48
+ text-anchor: middle;
49
+ font-variant-numeric: tabular-nums;
50
+ }
51
+
52
+ // Slot mark (a bar's icon — the supership Star): a nested <svg> whose
53
+ // ink follows currentColor, so lucide's stroke picks up the axis voice
54
+ // without per-icon wiring.
55
+ &__xmark {
56
+ color: rgb(var(--color-text) / 0.55);
57
+ }
58
+
43
59
  &__bar {
44
60
  fill: rgb(var(--color-primary));
45
61
  outline: none;
46
62
  cursor: pointer;
47
- transition: filter 0.16s ease;
63
+ transition: filter 0.16s ease, opacity 0.16s ease;
48
64
 
49
65
  &:hover,
50
- &:focus-visible {
66
+ &:focus-visible,
67
+ // Dim-pair head (activeIndex): keeps the hover lift while the
68
+ // pointer may sit anywhere — the host pairs bars by wiring
69
+ // activeIndex from the hover events.
70
+ &[data-hot] {
51
71
  filter: brightness(1.08) saturate(1.15);
52
72
  }
73
+
74
+ // Dim-pair follower: every other bar recedes, same ease family as
75
+ // the lift so the pair breathes together.
76
+ &[data-dim] {
77
+ opacity: 0.45;
78
+ }
53
79
  }
54
80
  }
@@ -27,6 +27,10 @@ const BARS = [
27
27
  { label: "Wed", value: 0 },
28
28
  ];
29
29
 
30
+ /** A tiny stand-in for a lucide glyph: any icon FUNCTION draws the
31
+ * 24-unit box the slot-mark wrapper maps onto its 12-unit mark. */
32
+ const StarGlyph = () => <rect x="0" y="0" width="24" height="24" />;
33
+
30
34
  describe("HkBarChart", () => {
31
35
  it("renders one labeled bar per bucket and skips zero buckets' paths", () => {
32
36
  const el = mount(<HkBarChart bars={BARS} />);
@@ -59,4 +63,126 @@ describe("HkBarChart", () => {
59
63
  "weekly playtime",
60
64
  );
61
65
  });
66
+
67
+ // ── showValues ───────────────────────────────────────────────────
68
+
69
+ it("showValues renders formatValue above each bar and skips zero buckets", () => {
70
+ const el = mount(
71
+ <HkBarChart bars={BARS} showValues formatValue={(v) => `${Math.round(v / 3600)}h`} />,
72
+ );
73
+ const values = [...el.querySelectorAll(".hk-bar-chart__value")];
74
+ // The zero bucket (Wed) carries no label — the wowsp tier histogram's
75
+ // old labelY-null behavior: an empty slot never shows a stray "0".
76
+ expect(values.map((t) => t.textContent)).toEqual(["1h", "2h"]);
77
+ // Each label's baseline sits exactly 5 units above its bar's top —
78
+ // the path's y coordinates sit at odd indices ("M x0 y L x0 y… Q x y
79
+ // x y …"), and their minimum IS the top.
80
+ const topOf = (d: string) => {
81
+ const nums = d.split(/[\sMLQAZ]+/).filter(Boolean).map(Number);
82
+ return Math.min(...nums.filter((_, i) => i % 2 === 1));
83
+ };
84
+ const tops = [...el.querySelectorAll(".hk-bar-chart__bar")].map((p) =>
85
+ topOf(p.getAttribute("d") ?? ""),
86
+ );
87
+ expect(Number(values[0].getAttribute("y"))).toBeCloseTo(tops[0] - 5, 6);
88
+ expect(Number(values[1].getAttribute("y"))).toBeCloseTo(tops[1] - 5, 6);
89
+ });
90
+
91
+ it("showValues off by default — old usage renders no value labels", () => {
92
+ const el = mount(<HkBarChart bars={BARS} />);
93
+ expect(el.querySelectorAll(".hk-bar-chart__value")).toHaveLength(0);
94
+ });
95
+
96
+ // ── hover event + activeIndex dim-pair ───────────────────────────
97
+
98
+ it("emits hover on bar mouseenter / mouseleave with focus / blur parity", () => {
99
+ const hovers: (number | null)[] = [];
100
+ const el = mount(<HkBarChart bars={BARS} onHover={(i) => hovers.push(i)} />);
101
+ const bars = [...el.querySelectorAll(".hk-bar-chart__bar")];
102
+ bars[0].dispatchEvent(new Event("mouseenter"));
103
+ bars[1].dispatchEvent(new Event("focus"));
104
+ bars[1].dispatchEvent(new Event("blur"));
105
+ bars[0].dispatchEvent(new Event("mouseleave"));
106
+ expect(hovers).toEqual([0, 1, null, null]);
107
+ });
108
+
109
+ it("activeIndex carries data-hot on the paired bar and data-dim on the others", () => {
110
+ const el = mount(<HkBarChart bars={BARS} activeIndex={0} />);
111
+ const bars = [...el.querySelectorAll(".hk-bar-chart__bar")];
112
+ expect(bars[0].getAttribute("data-hot")).toBe("true");
113
+ expect(bars[0].hasAttribute("data-dim")).toBe(false);
114
+ expect(bars[1].getAttribute("data-dim")).toBe("true");
115
+ expect(bars[1].hasAttribute("data-hot")).toBe(false);
116
+ });
117
+
118
+ it("defaults to no pair — no data-hot / data-dim anywhere", () => {
119
+ const el = mount(<HkBarChart bars={BARS} />);
120
+ expect(el.querySelector("[data-hot], [data-dim]")).toBeNull();
121
+ });
122
+
123
+ // ── icon slot mark (the supership Star) ──────────────────────────
124
+
125
+ it("renders a resolvable icon in place of the x-label text, sized and centered", () => {
126
+ const el = mount(
127
+ <HkBarChart
128
+ bars={[
129
+ { label: "10", value: 5, icon: StarGlyph },
130
+ { label: "11", value: 7 },
131
+ ]}
132
+ />,
133
+ );
134
+ const marks = [...el.querySelectorAll(".hk-bar-chart__xmark")];
135
+ expect(marks).toHaveLength(1);
136
+ // The nested-svg wrapper maps the 24-unit lucide box onto a 12-unit
137
+ // mark centered on slot 0 (centerX 182) at the x-label's optical
138
+ // center (baselineY 196 + 4).
139
+ expect(marks[0].getAttribute("viewBox")).toBe("0 0 24 24");
140
+ expect(marks[0].getAttribute("width")).toBe("12");
141
+ expect(marks[0].getAttribute("height")).toBe("12");
142
+ expect(marks[0].getAttribute("x")).toBe("176");
143
+ expect(marks[0].getAttribute("y")).toBe("200");
144
+ // The icon swapped out that slot's text; the other slot keeps its text.
145
+ expect([...el.querySelectorAll(".hk-bar-chart__x")].map((t) => t.textContent)).toEqual([
146
+ "11",
147
+ ]);
148
+ // The bar's aria string keeps the TEXT label, icon or not.
149
+ expect(el.querySelector(".hk-bar-chart__bar")?.getAttribute("aria-label")).toContain("10");
150
+ });
151
+
152
+ it("normalizes icons like HkPillToggleGroup — vnode passes through, junk falls back to text", () => {
153
+ const el = mount(
154
+ <HkBarChart
155
+ bars={[
156
+ { label: "a", value: 3, icon: <rect x="4" y="4" width="16" height="16" /> },
157
+ { label: "b", value: 4, icon: "not-an-icon" },
158
+ ]}
159
+ />,
160
+ );
161
+ // The prebuilt vnode rides the mark wrapper; the junk string does not
162
+ // blank the slot — it falls back to the text label.
163
+ expect(el.querySelectorAll(".hk-bar-chart__xmark")).toHaveLength(1);
164
+ expect(el.querySelectorAll(".hk-bar-chart__xmark rect")).toHaveLength(1);
165
+ expect([...el.querySelectorAll(".hk-bar-chart__x")].map((t) => t.textContent)).toEqual(["b"]);
166
+ });
167
+
168
+ // ── back-compat: the wowsp PlaytimeView usage surface ────────────
169
+
170
+ it("old-props usage renders exactly the old surface", () => {
171
+ const el = mount(
172
+ <HkBarChart
173
+ bars={BARS.map((b) => ({ label: b.label, value: b.value, hint: b.hint }))}
174
+ width={480}
175
+ formatValue={(v) => `${Math.round(v / 3600)}h`}
176
+ />,
177
+ );
178
+ expect(el.querySelectorAll(".hk-bar-chart__bar")).toHaveLength(2);
179
+ expect(el.querySelectorAll(".hk-bar-chart__value")).toHaveLength(0);
180
+ expect(el.querySelectorAll(".hk-bar-chart__xmark")).toHaveLength(0);
181
+ expect(el.querySelector("[data-hot], [data-dim]")).toBeNull();
182
+ expect([...el.querySelectorAll(".hk-bar-chart__x")].map((t) => t.textContent)).toEqual([
183
+ "Mon",
184
+ "Tue",
185
+ "Wed",
186
+ ]);
187
+ });
62
188
  });
@@ -1,8 +1,43 @@
1
- import { computed, defineComponent, type PropType } from "vue";
1
+ import { computed, defineComponent, h, type PropType, type VNode } from "vue";
2
2
 
3
3
  import { barLayout, type BarSlot } from "./barChart";
4
4
  import "./HkBarChart.scss";
5
5
 
6
+ /** One bar entry. */
7
+ export interface HkBarChartBar {
8
+ /** Text label under the slot — also the bar's aria string, even when
9
+ * an `icon` replaces the visible text. */
10
+ label: string;
11
+ value: number;
12
+ /** Hover/focus hint; defaults to `label: formatValue(value)`. */
13
+ hint?: string;
14
+ /** Optional slot mark rendered IN PLACE of the x-label text (the tier
15
+ * histogram's supership Star). Accepts EITHER a component (a lucide
16
+ * icon function, a defineComponent options object) or a prebuilt
17
+ * vnode — normalized exactly like HkPillToggleGroup's icon handling;
18
+ * anything else falls back to the text label (never a blank slot). */
19
+ icon?: unknown;
20
+ }
21
+
22
+ /** Normalize a bar icon to a renderable vnode — the HkPillToggleGroup
23
+ * convention verbatim: a component (function or options object) is
24
+ * instantiated with `h`; a prebuilt vnode passes through; anything
25
+ * else (string, number, null) renders nothing, and the caller falls
26
+ * back to the text label. */
27
+ function resolveIcon(icon: unknown): VNode | null {
28
+ if (icon == null) return null;
29
+ if (typeof icon === "object") return icon as VNode;
30
+ if (typeof icon === "function") return h(icon as Parameters<typeof h>[0]);
31
+ return null;
32
+ }
33
+
34
+ /** The slot-mark box (wowsp's tier-star convention): lucide glyphs draw
35
+ * in a 24×24 box, so the wrapper nested <svg> maps that box onto a
36
+ * 12-unit mark centered on the x-label's optical center — the 10-unit
37
+ * text's baseline is baselineY + 14 and its glyph box spans
38
+ * ≈ baselineY + 6 … + 14, so a mark from +4 to +16 shares its center. */
39
+ const BAR_MARK = { glyphBox: 24, size: 12, top: 4 } as const;
40
+
6
41
  /**
7
42
  * HkBarChart — one bar per bucket over a hand-drawn declarative SVG (no
8
43
  * chart library; the HkTreemap/HkCalendarHeatmap dependency-free line).
@@ -16,6 +51,15 @@ import "./HkBarChart.scss";
16
51
  * wowsp delegated-tooltip hook reads — hikari itself renders no native
17
52
  * title bubble).
18
53
  *
54
+ * The tier histogram's needs ride along as OPTIONAL props (wowsp
55
+ * ShipDistCharts): `showValues` paints formatValue(value) above each
56
+ * bar (empty slots carry no label), `hover` + `activeIndex` implement a
57
+ * dim-pair — the HOST wires activeIndex from the hover events, the bar
58
+ * at activeIndex carries `data-hot` and every other bar `data-dim` —
59
+ * and a bar's `icon` replaces its x-label text (the supership slot's
60
+ * nested-svg Star, positioned/sized here from the 24-unit lucide box).
61
+ * Defaults reproduce the old output exactly.
62
+ *
19
63
  * Dumb by design: the caller owns aggregation and passes the settled
20
64
  * buckets in. All geometry lives in ./barChart (pure, pinned by its own
21
65
  * tests).
@@ -27,7 +71,7 @@ export default defineComponent({
27
71
  props: {
28
72
  /** The bars, in axis order (oldest → newest for trend charts). */
29
73
  bars: {
30
- type: Array as PropType<{ label: string; value: number; hint?: string }[]>,
74
+ type: Array as PropType<HkBarChartBar[]>,
31
75
  required: true,
32
76
  },
33
77
  /** Frame size (the SVG viewBox; CSS scales it). */
@@ -42,10 +86,25 @@ export default defineComponent({
42
86
  ariaLabel: { type: String, default: null },
43
87
  /** Rendered when every bucket is zero / the array is empty. */
44
88
  emptyText: { type: String, default: "No data" },
89
+ /** Paint formatValue(value) as a small label ABOVE each bar. Skip
90
+ * rule: bars with value <= 0 carry NO label (their slot stays an
91
+ * empty slot) — the wowsp tier histogram's old labelY-null behavior,
92
+ * so an unplayed bucket never shows a stray "0". */
93
+ showValues: { type: Boolean, default: false },
94
+ /** The dim-pair head: when set, the bar at this index carries
95
+ * `data-hot` (kept hover lift) and every other bar carries
96
+ * `data-dim` (≈0.45 opacity). The HOST implements the pair by
97
+ * wiring this from the `hover` events; null (the default) draws no
98
+ * pair at all. */
99
+ activeIndex: { type: Number as PropType<number | null>, default: null },
45
100
  },
46
101
  emits: {
47
102
  /** A bar was clicked (its bucket index). */
48
103
  select: (index: number) => typeof index === "number",
104
+ /** The pointer entered a bar (its bucket index) or left it / the
105
+ * keyboard focus moved onto or off one (focus/blur parity — null
106
+ * when the hover ended). */
107
+ hover: (index: number | null) => typeof index === "number" || index === null,
49
108
  },
50
109
  setup(props, { emit }) {
51
110
  const layout = computed(() =>
@@ -60,7 +119,12 @@ export default defineComponent({
60
119
  props.ariaLabel ??
61
120
  props.bars.map((b) => `${b.label} ${props.formatValue(b.value)}`).join(", "),
62
121
  );
63
- const hintOf = (b: { label: string; value: number; hint?: string }, i: number): string =>
122
+ /** The dim-pair head, normalized: anything but a number (undefined,
123
+ * null, garbage) means "no pair". */
124
+ const active = computed(() =>
125
+ typeof props.activeIndex === "number" ? props.activeIndex : null,
126
+ );
127
+ const hintOf = (b: HkBarChartBar, i: number): string =>
64
128
  b.hint ?? `${b.label}: ${props.formatValue(b.value)}`;
65
129
 
66
130
  return () => {
@@ -90,23 +154,58 @@ export default defineComponent({
90
154
  </text>
91
155
  </g>
92
156
  ))}
93
- {l.bars.map((s: BarSlot, i: number) => (
94
- <g key={i}>
95
- {s.path ? (
96
- <path
97
- class="hk-bar-chart__bar"
98
- d={s.path}
99
- data-hint={hintOf(props.bars[i], i)}
100
- aria-label={hintOf(props.bars[i], i)}
101
- tabindex={0}
102
- onClick={() => emit("select", i)}
103
- />
104
- ) : null}
105
- <text class="hk-bar-chart__x" x={s.centerX} y={l.baselineY + 14}>
106
- {props.bars[i].label}
107
- </text>
108
- </g>
109
- ))}
157
+ {l.bars.map((s: BarSlot, i: number) => {
158
+ const bar = props.bars[i];
159
+ const mark = resolveIcon(bar.icon);
160
+ return (
161
+ <g key={i}>
162
+ {s.path ? (
163
+ <path
164
+ class="hk-bar-chart__bar"
165
+ d={s.path}
166
+ data-hint={hintOf(bar, i)}
167
+ aria-label={hintOf(bar, i)}
168
+ tabindex={0}
169
+ data-hot={active.value === i || undefined}
170
+ data-dim={(active.value !== null && active.value !== i) || undefined}
171
+ onClick={() => emit("select", i)}
172
+ onMouseenter={() => emit("hover", i)}
173
+ onMouseleave={() => emit("hover", null)}
174
+ onFocus={() => emit("hover", i)}
175
+ onBlur={() => emit("hover", null)}
176
+ />
177
+ ) : null}
178
+ {props.showValues && s.labelY != null ? (
179
+ <text class="hk-bar-chart__value" x={s.centerX} y={s.labelY}>
180
+ {props.formatValue(bar.value)}
181
+ </text>
182
+ ) : null}
183
+ {mark != null ? (
184
+ // The slot mark (the supership's Star): a nested <svg>
185
+ // wrapper maps the icon's 24-unit glyph box onto a
186
+ // 12-unit mark centered on the x-label's optical
187
+ // center — positioned HERE so the consumer passes a
188
+ // bare lucide component and knows no geometry. Ink
189
+ // (currentColor) lives in SCSS.
190
+ <svg
191
+ class="hk-bar-chart__xmark"
192
+ x={s.centerX - BAR_MARK.size / 2}
193
+ y={l.baselineY + BAR_MARK.top}
194
+ width={BAR_MARK.size}
195
+ height={BAR_MARK.size}
196
+ viewBox={`0 0 ${BAR_MARK.glyphBox} ${BAR_MARK.glyphBox}`}
197
+ aria-hidden="true"
198
+ >
199
+ {mark}
200
+ </svg>
201
+ ) : (
202
+ <text class="hk-bar-chart__x" x={s.centerX} y={l.baselineY + 14}>
203
+ {bar.label}
204
+ </text>
205
+ )}
206
+ </g>
207
+ );
208
+ })}
110
209
  </svg>
111
210
  );
112
211
  };
@@ -53,6 +53,32 @@
53
53
  }
54
54
  }
55
55
 
56
+ /* The horizontal axis: the SAME list laid out as ONE never-wrapping row
57
+ * (wowsp's overlay-columns editor is the first host). Only geometry
58
+ * changes — the drop line moves to the leading/trailing edge (the
59
+ * horizontal twin of the top/bottom inset above; the descendant rules
60
+ * below out-specify the vertical box-shadow, so they REPLACE it rather
61
+ * than stack onto it), and min-width: 0 lets an over-full row shrink
62
+ * its items so the single line always holds (the content lane keeps its
63
+ * own min-width, so ellipsis chains keep working inside them). The
64
+ * reserved handle lane stays the row's leading column exactly as it is
65
+ * vertically, and the actions lane stays the row's trailing edge. */
66
+ .hk-draggable-list[data-axis="horizontal"] {
67
+ flex-direction: row;
68
+ flex-wrap: nowrap;
69
+
70
+ .hk-draggable-list-item {
71
+ min-width: 0;
72
+ }
73
+
74
+ .hk-draggable-list-item[data-placeholder="before"] {
75
+ box-shadow: inset 2px 0 0 0 var(--hi-color-primary, #7aa2f7);
76
+ }
77
+ .hk-draggable-list-item[data-placeholder="after"] {
78
+ box-shadow: inset -2px 0 0 0 var(--hi-color-primary, #7aa2f7);
79
+ }
80
+ }
81
+
56
82
  .hk-draggable-list-handle {
57
83
  display: flex;
58
84
  align-items: center;
@@ -108,3 +108,66 @@ describe("HkDraggableList actions-lane sheet", () => {
108
108
  expect(content).toContain("flex: 1");
109
109
  });
110
110
  });
111
+
112
+ describe("HkDraggableList horizontal-axis sheet", () => {
113
+ /** axis="horizontal" (DOM twin: the horizontal-axis cases in
114
+ * HkDraggableList.test.tsx) lays the SAME list as one never-wrapping
115
+ * row. Only geometry changes: the drop line moves to the item's
116
+ * leading/trailing edge — the horizontal twin of the top/bottom inset
117
+ * — and the items become shrinkable so the single line always holds. */
118
+ const css = listCss();
119
+
120
+ // ruleBody selectors are written in the COMPILED form: sass expanded
121
+ // output drops attribute-value quotes ([data-axis=horizontal]).
122
+
123
+ it("lays the horizontal list out as one never-wrapping row", () => {
124
+ const body = ruleBody(css, ".hk-draggable-list[data-axis=horizontal]");
125
+ expect(body, "the horizontal-axis layout rule must exist").not.toBeNull();
126
+ expect(body).toContain("flex-direction: row");
127
+ expect(body).toContain("flex-wrap: nowrap");
128
+ });
129
+
130
+ it("lets an over-full row shrink its items so the one line holds", () => {
131
+ const body = ruleBody(
132
+ css,
133
+ ".hk-draggable-list[data-axis=horizontal] .hk-draggable-list-item",
134
+ );
135
+ expect(body, "the horizontal item rule must exist").not.toBeNull();
136
+ expect(body).toContain("min-width: 0");
137
+ });
138
+
139
+ it("marks the drop edge on the leading/trailing side, not top/bottom", () => {
140
+ const before = ruleBody(
141
+ css,
142
+ ".hk-draggable-list[data-axis=horizontal] .hk-draggable-list-item[data-placeholder=before]",
143
+ );
144
+ expect(before, "the horizontal before rule must exist").not.toBeNull();
145
+ expect(before).toContain("inset 2px 0 0 0");
146
+ expect(before).not.toContain("inset 0 2px");
147
+ expect(before).not.toContain("inset 0 -2px");
148
+
149
+ const after = ruleBody(
150
+ css,
151
+ ".hk-draggable-list[data-axis=horizontal] .hk-draggable-list-item[data-placeholder=after]",
152
+ );
153
+ expect(after, "the horizontal after rule must exist").not.toBeNull();
154
+ expect(after).toContain("inset -2px 0 0 0");
155
+ expect(after).not.toContain("inset 0 2px");
156
+ expect(after).not.toContain("inset 0 -2px");
157
+ });
158
+
159
+ it("keeps the vertical drop edges on top/bottom for the default axis", () => {
160
+ // First source match = the plain vertical rule (the horizontal twin
161
+ // comes later and under a descendant selector); its body must carry
162
+ // the top/bottom inset and never the horizontal one.
163
+ const before = ruleBody(css, ".hk-draggable-list-item[data-placeholder=before]");
164
+ expect(before, "the vertical before rule must exist").not.toBeNull();
165
+ expect(before).toContain("inset 0 2px 0 0");
166
+ expect(before).not.toContain("inset 2px 0 0 0");
167
+
168
+ const after = ruleBody(css, ".hk-draggable-list-item[data-placeholder=after]");
169
+ expect(after, "the vertical after rule must exist").not.toBeNull();
170
+ expect(after).toContain("inset 0 -2px 0 0");
171
+ expect(after).not.toContain("inset -2px 0 0 0");
172
+ });
173
+ });
@@ -525,3 +525,193 @@ describe("HkDraggableList actions lane", () => {
525
525
  fireWin("pointerup");
526
526
  });
527
527
  });
528
+
529
+ describe("HkDraggableList horizontal axis", () => {
530
+ /** axis="horizontal" lays the SAME list as one never-wrapping row
531
+ * (wowsp's overlay-columns editor). Only the pointer geometry
532
+ * changes: the arm reads |dx| alone, the ghost follows BOTH axes,
533
+ * and the placeholder targets slots by pointer.x against their
534
+ * horizontal midpoints (the plain single-line rule — the list never
535
+ * wraps). Roles, slots, the reserved handle lane and the
536
+ * commit-once-at-pointerup contract stay exactly as vertical. */
537
+
538
+ const PointerEventCtor: typeof MouseEvent =
539
+ (globalThis as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
540
+
541
+ const pointerInit: MouseEventInit = { bubbles: true, cancelable: true };
542
+
543
+ function fire(el: Element, type: string, x = 0, y = 0) {
544
+ el.dispatchEvent(new PointerEventCtor(type, {
545
+ ...pointerInit,
546
+ button: 0,
547
+ clientX: x,
548
+ clientY: y,
549
+ }));
550
+ }
551
+
552
+ function fireWin(type: string, x = 0, y = 0) {
553
+ window.dispatchEvent(new PointerEventCtor(type, {
554
+ ...pointerInit,
555
+ clientX: x,
556
+ clientY: y,
557
+ }));
558
+ }
559
+
560
+ const emitLog: Array<{ from: number; to: number }> = [];
561
+
562
+ function mountHorizontal(items: string[], opts?: { lockedKeys?: string[] }): HTMLElement {
563
+ emitLog.length = 0;
564
+ const container = document.createElement("div");
565
+ document.body.appendChild(container);
566
+ const app = createApp({
567
+ render: () =>
568
+ h(
569
+ HDraggableList,
570
+ {
571
+ items: items.map((key) => ({ key })),
572
+ axis: "horizontal",
573
+ lockedKeys: opts?.lockedKeys ?? [],
574
+ onReorder: (from: number, to: number) => emitLog.push({ from, to }),
575
+ },
576
+ {
577
+ item: ({ item }: { item: { key: string } }) =>
578
+ h("span", { class: "row-body" }, item.key),
579
+ },
580
+ ),
581
+ });
582
+ app.mount(container);
583
+ mounts.push({ app, container });
584
+ return container.querySelector(".hk-draggable-list") as HTMLElement;
585
+ }
586
+
587
+ function rowsOf(root: HTMLElement): HTMLElement[] {
588
+ return Array.from(root.querySelectorAll(".hk-draggable-list-item")) as HTMLElement[];
589
+ }
590
+
591
+ /** happy-dom reports zero rects; pin the slots to a 100px-each single
592
+ * line [0..100][100..200][200..300] (40px tall) so the midpoint rule
593
+ * walks real geometry. */
594
+ function layLine(root: HTMLElement, width = 100, height = 40): HTMLElement[] {
595
+ const rows = rowsOf(root);
596
+ rows.forEach((row, i) => {
597
+ const left = i * width;
598
+ row.getBoundingClientRect = () =>
599
+ ({
600
+ left,
601
+ top: 0,
602
+ right: left + width,
603
+ bottom: height,
604
+ width,
605
+ height,
606
+ x: left,
607
+ y: 0,
608
+ toJSON: () => ({}),
609
+ }) as DOMRect;
610
+ });
611
+ return rows;
612
+ }
613
+
614
+ it("keeps list semantics and marks no axis on the vertical default", () => {
615
+ const verticalRoot = mountList(["a"], []);
616
+ expect(verticalRoot.hasAttribute("data-axis")).toBe(false);
617
+
618
+ const root = mountHorizontal(["a", "b"]);
619
+ expect(root.getAttribute("data-axis")).toBe("horizontal");
620
+ expect(root.getAttribute("role")).toBe("list");
621
+ const rows = rowsOf(root);
622
+ expect(rows).toHaveLength(2);
623
+ expect(rows.every((row) => row.getAttribute("role") === "listitem")).toBe(true);
624
+ });
625
+
626
+ it("arms on |dx| alone — vertical travel never arms a horizontal drag", async () => {
627
+ const root = mountHorizontal(["a", "b"]);
628
+ layLine(root);
629
+ const row = rowsOf(root)[0]!;
630
+
631
+ fire(row, "pointerdown", 50, 20);
632
+ // 30px of pure-Y travel: under the |dx| arm, still a plain press.
633
+ fireWin("pointermove", 50, 50);
634
+ await nextTick();
635
+ expect(row.hasAttribute("data-dragging")).toBe(false);
636
+
637
+ // 6px of pure-X travel crosses ARM_PX: the drag publishes.
638
+ fireWin("pointermove", 56, 50);
639
+ await nextTick();
640
+ expect(row.getAttribute("data-dragging")).not.toBeNull();
641
+
642
+ fireWin("pointerup");
643
+ });
644
+
645
+ it("moves the ghost on BOTH axes", async () => {
646
+ const root = mountHorizontal(["a"]);
647
+ layLine(root); // slot [0..100]
648
+ // A stale ghost from an earlier failed run would shadow this assert.
649
+ document.querySelectorAll(".hk-draggable-list-ghost").forEach((n) => n.remove());
650
+ const row = rowsOf(root)[0]!;
651
+
652
+ fire(row, "pointerdown", 50, 20); // grabOffset (50, 20)
653
+ fireWin("pointermove", 106, 20); // arm past ARM_PX on |dx|
654
+ await new Promise((resolve) => requestAnimationFrame(resolve));
655
+ let ghost = document.querySelector(".hk-draggable-list-ghost") as HTMLElement | null;
656
+ expect(ghost, "the ghost must rise next frame").not.toBeNull();
657
+
658
+ fireWin("pointermove", 260, 130); // both axes travel: (260-50, 130-20)
659
+ ghost = document.querySelector(".hk-draggable-list-ghost");
660
+ expect(ghost!.style.transform).toBe("translate3d(210px, 110px, 0)");
661
+
662
+ fireWin("pointerup");
663
+ });
664
+
665
+ it("targets the placeholder by pointer.x against slot midpoints", async () => {
666
+ const root = mountHorizontal(["a", "b", "c"]);
667
+ const rows = layLine(root);
668
+ const row = rows[0]!;
669
+
670
+ fire(row, "pointerdown", 50, 20);
671
+ fireWin("pointermove", 60, 20); // arm; still left of b's midpoint (150) → before b
672
+ await nextTick();
673
+ expect(rows[1]!.getAttribute("data-placeholder")).toBe("after");
674
+
675
+ fireWin("pointermove", 180, 20); // past b's midpoint, under c's (250) → before c
676
+ await nextTick();
677
+ expect(rows[1]!.hasAttribute("data-placeholder")).toBe(false);
678
+ expect(rows[2]!.getAttribute("data-placeholder")).toBe("after");
679
+
680
+ fireWin("pointerup");
681
+ // Commit-once-at-pointerup: ONE reorder, at the placeholder slot.
682
+ expect(emitLog).toEqual([{ from: 0, to: 2 }]);
683
+ });
684
+
685
+ it("marks the leading edge when the pointer retreats before earlier slots", async () => {
686
+ const root = mountHorizontal(["a", "b", "c"]);
687
+ const rows = layLine(root);
688
+ const row = rows[2]!;
689
+
690
+ fire(row, "pointerdown", 250, 20);
691
+ fireWin("pointermove", 240, 20); // arm
692
+ await nextTick();
693
+
694
+ fireWin("pointermove", 20, 20); // before a's midpoint (50) → lands before a
695
+ await nextTick();
696
+ expect(rows[0]!.getAttribute("data-placeholder")).toBe("before");
697
+ expect(rows[1]!.hasAttribute("data-placeholder")).toBe(false);
698
+
699
+ fireWin("pointerup");
700
+ expect(emitLog).toEqual([{ from: 2, to: 0 }]);
701
+ });
702
+
703
+ it("skips locked slots when walking the row", async () => {
704
+ const root = mountHorizontal(["a", "b", "c"], { lockedKeys: ["b"] });
705
+ const rows = layLine(root);
706
+ const row = rows[0]!;
707
+
708
+ fire(row, "pointerdown", 50, 20);
709
+ fireWin("pointermove", 160, 20); // past b's midpoint — b is locked, c takes the drop
710
+ await nextTick();
711
+ expect(rows[1]!.hasAttribute("data-placeholder")).toBe(false);
712
+ expect(rows[2]!.getAttribute("data-placeholder")).toBe("after");
713
+
714
+ fireWin("pointerup");
715
+ expect(emitLog).toEqual([{ from: 0, to: 2 }]);
716
+ });
717
+ });