@celestia-island/hikari 0.55.275 → 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.275",
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",
@@ -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
+ });
@@ -46,6 +46,16 @@ export default defineComponent({
46
46
  * mirroring the menu/vocabulary a11y rulings (#736/#737): chrome
47
47
  * announces structure, the host supplies the name. */
48
48
  label: { type: String, default: undefined },
49
+ /** The direction the list flows: "vertical" (the default) stacks
50
+ * rows in one column; "horizontal" lays them in ONE never-wrapping
51
+ * row (wowsp's overlay-columns editor is the first host). Only the
52
+ * pointer geometry differs — the arm threshold reads |dx| instead
53
+ * of the hypot, the ghost follows BOTH axes, and the placeholder
54
+ * targets slots by pointer.x against their horizontal midpoints
55
+ * (the same adjacent-midpoint rule, walked along the row). Roles,
56
+ * slots, the reserved handle lane and the commit-once-at-pointerup
57
+ * contract are identical on both axes. */
58
+ axis: { type: String as PropType<"vertical" | "horizontal">, default: "vertical" },
49
59
  },
50
60
  emits: {
51
61
  reorder: (_fromIndex: number, _toIndex: number) => true,
@@ -173,21 +183,27 @@ export default defineComponent({
173
183
  }
174
184
  }
175
185
 
176
- function moveGhost(clientY: number) {
186
+ function moveGhost(clientX: number, clientY: number) {
177
187
  const s = drag.value;
178
188
  if (!s || !ghostNode) return;
179
189
  // gx/gy are the ghost's visual top-left (client coords minus the
180
190
  // visual grab offset); the teleported ghost paints local px scaled
181
- // by the cumulative zoom — divide once at the write layer.
191
+ // by the cumulative zoom — divide once at the write layer. A
192
+ // vertical drag pins the ghost to the source row's x (rows only
193
+ // ever move along the column); a horizontal drag follows BOTH
194
+ // axes — a card drifts vertically as naturally as a row drifts.
182
195
  const z = ancestorZoom(document.body);
183
- const gx = (s.originX - s.grabOffsetX) / z;
196
+ const gx = (props.axis === "horizontal"
197
+ ? clientX - s.grabOffsetX
198
+ : s.originX - s.grabOffsetX) / z;
184
199
  const gy = (clientY - s.grabOffsetY) / z;
185
200
  ghostNode.style.transform = `translate3d(${gx}px, ${gy}px, 0)`;
186
201
  }
187
202
 
188
- function recomputePlaceholder(clientY: number) {
203
+ function recomputePlaceholder(clientX: number, clientY: number) {
189
204
  const s = drag.value;
190
205
  if (!s) return;
206
+ const horizontal = props.axis === "horizontal";
191
207
  const rows = rowEls.value;
192
208
  let target = s.fromIndex;
193
209
  let fallback = s.fromIndex;
@@ -200,10 +216,15 @@ export default defineComponent({
200
216
  if (!el) continue;
201
217
 
202
218
  const rect = el.getBoundingClientRect();
203
- const mid = rect.top + rect.height / 2;
219
+ // The adjacent-midpoint rule along the list's ONE axis — the
220
+ // column walks by y, the row walks by x. The horizontal form is
221
+ // the plain rule over every slot: this list never wraps, so the
222
+ // pointer's line is always the whole row (no banding).
223
+ const mid = horizontal ? rect.left + rect.width / 2 : rect.top + rect.height / 2;
224
+ const pointer = horizontal ? clientX : clientY;
204
225
  fallback = i;
205
226
 
206
- if (clientY < mid) {
227
+ if (pointer < mid) {
207
228
  target = i;
208
229
  break;
209
230
  }
@@ -227,8 +248,14 @@ export default defineComponent({
227
248
 
228
249
  if (!p.armed) {
229
250
  // Arm past the threshold: only now does the drag publish, the
230
- // ghost rise, and the gesture claim its defaults.
231
- if (Math.hypot(e.clientX - p.state.originX, e.clientY - p.state.originY) <= ARM_PX) {
251
+ // ghost rise, and the gesture claim its defaults. Vertical arms
252
+ // on the hypot (travel along either axis counts); horizontal
253
+ // arms on |dx| alone — a y-wiggle under a pressed card is
254
+ // jitter, never a drag intent.
255
+ const travelled = props.axis === "horizontal"
256
+ ? Math.abs(e.clientX - p.state.originX)
257
+ : Math.hypot(e.clientX - p.state.originX, e.clientY - p.state.originY);
258
+ if (travelled <= ARM_PX) {
232
259
  return;
233
260
  }
234
261
  p.armed = true;
@@ -243,8 +270,8 @@ export default defineComponent({
243
270
  }
244
271
 
245
272
  e.preventDefault();
246
- moveGhost(e.clientY);
247
- recomputePlaceholder(e.clientY);
273
+ moveGhost(e.clientX, e.clientY);
274
+ recomputePlaceholder(e.clientX, e.clientY);
248
275
  }
249
276
 
250
277
  function onPointerUp() {
@@ -291,6 +318,7 @@ export default defineComponent({
291
318
  class="hk-draggable-list"
292
319
  role="list"
293
320
  aria-label={props.label || undefined}
321
+ data-axis={props.axis === "horizontal" ? "horizontal" : undefined}
294
322
  data-dragging={s ? "" : undefined}
295
323
  data-all-locked={allLocked || undefined}
296
324
  >
@@ -51,6 +51,13 @@ export {
51
51
  DESKTOP_GUTTER_PX,
52
52
  GUTTER_MOBILE_MAX_WIDTH,
53
53
  } from "./viewportGutter";
54
+ // The cumulative CSS-zoom factor between a body-teleported element and
55
+ // the viewport root (runtime/cssZoom.ts). Hosts that measure anchors
56
+ // with gBCR and write fixed px themselves — a horizontal HkDraggableList
57
+ // port especially — need the same ghost-math division the components run
58
+ // internally; until this re-export existed the factor had to be copied
59
+ // verbatim into consumers.
60
+ export { ancestorZoom } from "./cssZoom";
54
61
  export {
55
62
  displayWidthUnits,
56
63
  glyphWidthUnits,