@celestia-island/hikari 0.54.4 → 0.54.10

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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkBlankCanvas.scss +29 -0
  3. package/src/components/HkBlankCanvas.test.tsx +147 -0
  4. package/src/components/HkBlankCanvas.tsx +125 -0
  5. package/src/components/HkDrawer.test.tsx +57 -0
  6. package/src/components/HkDrawer.tsx +6 -1
  7. package/src/components/HkKanban.scss +81 -0
  8. package/src/components/HkKanban.test.tsx +219 -0
  9. package/src/components/HkKanban.tsx +264 -0
  10. package/src/components/HkModal.test.tsx +76 -0
  11. package/src/components/HkModal.tsx +8 -1
  12. package/src/components/HkModalBreadcrumb.overflow.contract.test.ts +115 -0
  13. package/src/components/HkModalBreadcrumb.scss +114 -2
  14. package/src/components/HkModalBreadcrumb.test.tsx +549 -6
  15. package/src/components/HkModalBreadcrumb.tsx +433 -37
  16. package/src/components/HkPopover.tsx +8 -0
  17. package/src/components/HkPopoverSheet.test.tsx +21 -0
  18. package/src/components/HkSelectPanel.test.tsx +21 -0
  19. package/src/components/HkSelectPanel.tsx +3 -0
  20. package/src/components/HkWaterfall.animate.test.tsx +63 -0
  21. package/src/components/HkWaterfall.scss +43 -0
  22. package/src/components/HkWaterfall.test.tsx +407 -0
  23. package/src/components/HkWaterfall.tsx +489 -0
  24. package/src/i18n/locales/ar/components.json +2 -1
  25. package/src/i18n/locales/de/components.json +2 -1
  26. package/src/i18n/locales/en/components.json +2 -1
  27. package/src/i18n/locales/es/components.json +2 -1
  28. package/src/i18n/locales/fr/components.json +2 -1
  29. package/src/i18n/locales/ja/components.json +2 -1
  30. package/src/i18n/locales/ko/components.json +2 -1
  31. package/src/i18n/locales/pt/components.json +2 -1
  32. package/src/i18n/locales/ru/components.json +2 -1
  33. package/src/i18n/locales/zh-Hans/components.json +2 -1
  34. package/src/i18n/locales/zh-Hant/components.json +2 -1
  35. package/src/i18n/modalKeys.test.ts +59 -0
  36. package/src/index.ts +17 -0
  37. package/src/runtime/backStack.test.ts +171 -0
  38. package/src/runtime/backStack.ts +118 -39
  39. package/src/runtime/displayWidth.test.ts +102 -0
  40. package/src/runtime/displayWidth.ts +76 -0
  41. package/src/runtime/index.ts +6 -0
  42. package/src/runtime/usePopupManager.test.ts +60 -0
  43. package/src/runtime/usePopupManager.ts +42 -1
  44. package/src/styles/admin-tokens.scss +16 -3
  45. package/src/styles/authCardWidth.test.ts +343 -0
  46. package/src/theme/authCard.ts +38 -0
  47. package/src/theme/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.54.4",
3
+ "version": "0.54.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -0,0 +1,29 @@
1
+ .hk-blank-canvas {
2
+ position: relative;
3
+ width: 100%;
4
+ height: 100%;
5
+ min-width: 0;
6
+ min-height: 0;
7
+ }
8
+
9
+ /* The mount is absolute so the host's renderer can size itself from the
10
+ element's box without this component ever contributing layout of its own
11
+ (a padded or scrolling parent would make the renderer's own measurements
12
+ disagree with the surface it draws on). */
13
+ .hk-blank-canvas-mount {
14
+ position: absolute;
15
+ inset: 0;
16
+ }
17
+
18
+ /* Overlay chrome (device cards, leader lines, HUD) sits above the surface.
19
+ Pointer events pass through the layer and are re-enabled per child, so the
20
+ canvas keeps receiving gestures between the cards. */
21
+ .hk-blank-canvas-overlay {
22
+ position: absolute;
23
+ inset: 0;
24
+ pointer-events: none;
25
+ }
26
+
27
+ .hk-blank-canvas-overlay > * {
28
+ pointer-events: auto;
29
+ }
@@ -0,0 +1,147 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, type App } from "vue";
3
+
4
+ import HkBlankCanvas from "./HkBlankCanvas";
5
+
6
+ /**
7
+ * HkBlankCanvas tests — the size-ready gate, the resize/teardown signals and
8
+ * the overlay layer. The gate is the part worth pinning: initialising a
9
+ * renderer against a 0 × 0 element is what makes a 3D view flash a wrong
10
+ * first frame.
11
+ */
12
+
13
+ const apps: App[] = [];
14
+ let originalRect: typeof HTMLElement.prototype.getBoundingClientRect;
15
+ let nextSize = { width: 0, height: 0 };
16
+ /** Captured by the ResizeObserver stub so a test can drive a resize. */
17
+ let observed: (() => void) | null = null;
18
+
19
+ beforeEach(() => {
20
+ observed = null;
21
+ originalRect = HTMLElement.prototype.getBoundingClientRect;
22
+ HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
23
+ if (this.classList?.contains("hk-blank-canvas-mount")) {
24
+ return {
25
+ width: nextSize.width,
26
+ height: nextSize.height,
27
+ top: 0,
28
+ left: 0,
29
+ right: nextSize.width,
30
+ bottom: nextSize.height,
31
+ x: 0,
32
+ y: 0,
33
+ toJSON: () => ({}),
34
+ } as DOMRect;
35
+ }
36
+ return originalRect.call(this);
37
+ };
38
+ (globalThis as { ResizeObserver?: unknown }).ResizeObserver = class {
39
+ constructor(callback: () => void) {
40
+ observed = callback;
41
+ }
42
+ observe(): void {}
43
+ disconnect(): void {}
44
+ unobserve(): void {}
45
+ };
46
+ });
47
+
48
+ afterEach(() => {
49
+ for (const app of apps.splice(0)) app.unmount();
50
+ HTMLElement.prototype.getBoundingClientRect = originalRect;
51
+ });
52
+
53
+ /** The component measures inside a rAF, so tests wait one. */
54
+ const oneFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
55
+
56
+ function mount(props: Record<string, unknown>, slots: Record<string, unknown> = {}) {
57
+ const container = document.createElement("div");
58
+ document.body.appendChild(container);
59
+ const Root = defineComponent({
60
+ setup() {
61
+ return () => h(HkBlankCanvas, props as never, slots);
62
+ },
63
+ });
64
+ const app = createApp(Root);
65
+ app.mount(container);
66
+ apps.push(app);
67
+ return container;
68
+ }
69
+
70
+ describe("HkBlankCanvas", () => {
71
+ it("does not mount while the element measures 0x0", async () => {
72
+ const onMount = vi.fn();
73
+ nextSize = { width: 0, height: 0 };
74
+ const root = mount({ onMount });
75
+ await nextTick();
76
+ await oneFrame();
77
+ expect(onMount).not.toHaveBeenCalled();
78
+ expect(root.querySelector(".hk-blank-canvas")?.hasAttribute("data-ready")).toBe(false);
79
+ });
80
+
81
+ it("mounts once the element has a size, and reports it", async () => {
82
+ const onMount = vi.fn();
83
+ nextSize = { width: 800, height: 600 };
84
+ const root = mount({ onMount });
85
+ await nextTick();
86
+ await oneFrame();
87
+ expect(onMount).toHaveBeenCalledTimes(1);
88
+ const [el, size] = onMount.mock.calls[0];
89
+ expect((el as HTMLElement).classList.contains("hk-blank-canvas-mount")).toBe(true);
90
+ expect(size).toEqual({ width: 800, height: 600 });
91
+ await nextTick();
92
+ expect(root.querySelector(".hk-blank-canvas")?.hasAttribute("data-ready")).toBe(true);
93
+ });
94
+
95
+ it("reports a later size change as a resize, never as a second mount", async () => {
96
+ const onMount = vi.fn();
97
+ const onResize = vi.fn();
98
+ nextSize = { width: 800, height: 600 };
99
+ mount({ onMount, onResize });
100
+ await nextTick();
101
+ await oneFrame();
102
+ expect(onMount).toHaveBeenCalledTimes(1);
103
+
104
+ nextSize = { width: 1024, height: 768 };
105
+ observed?.();
106
+ await nextTick();
107
+ expect(onMount).toHaveBeenCalledTimes(1);
108
+ expect(onResize).toHaveBeenCalledWith({ width: 1024, height: 768 });
109
+ });
110
+
111
+ it("does not report a resize when the box did not change", async () => {
112
+ const onResize = vi.fn();
113
+ nextSize = { width: 800, height: 600 };
114
+ mount({ onResize });
115
+ await nextTick();
116
+ await oneFrame();
117
+ observed?.();
118
+ await nextTick();
119
+ expect(onResize).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it("signals teardown only after a successful mount", async () => {
123
+ const onUnmount = vi.fn();
124
+ nextSize = { width: 0, height: 0 };
125
+ mount({ onUnmount });
126
+ await nextTick();
127
+ await oneFrame();
128
+ for (const app of apps.splice(0)) app.unmount();
129
+ expect(onUnmount).not.toHaveBeenCalled();
130
+
131
+ // The same teardown after a real mount does fire.
132
+ nextSize = { width: 640, height: 480 };
133
+ mount({ onUnmount });
134
+ await nextTick();
135
+ await oneFrame();
136
+ for (const app of apps.splice(0)) app.unmount();
137
+ expect(onUnmount).toHaveBeenCalledTimes(1);
138
+ });
139
+
140
+ it("renders the overlay above the mount", async () => {
141
+ nextSize = { width: 10, height: 10 };
142
+ const root = mount({}, { overlay: () => h("div", { class: "hud" }, "hud") });
143
+ await nextTick();
144
+ const overlay = root.querySelector(".hk-blank-canvas-overlay");
145
+ expect(overlay?.querySelector(".hud")?.textContent).toBe("hud");
146
+ });
147
+ });
@@ -0,0 +1,125 @@
1
+ import {
2
+ defineComponent,
3
+ onBeforeUnmount,
4
+ onMounted,
5
+ ref,
6
+ shallowRef,
7
+ type PropType,
8
+ } from "vue";
9
+
10
+ import "./HkBlankCanvas.scss";
11
+
12
+ /** The size the host's renderer gets told about, in CSS pixels. */
13
+ export interface BlankCanvasSize {
14
+ width: number;
15
+ height: number;
16
+ }
17
+
18
+ /**
19
+ * HkBlankCanvas — the base view for a full-bleed mount.
20
+ *
21
+ * Some views are a canvas and nothing else: the 3D twin hands the whole
22
+ * surface to a three.js renderer, an embedded editor hands it to whatever
23
+ * the host embeds. Their shape is not a layout this library should model, so
24
+ * this component owns only the parts every one of them needs:
25
+ *
26
+ * - **a 100% × 100% host** — the mount element fills its parent, and the
27
+ * component imposes no padding, scroll container or chrome of its own;
28
+ * - **a size-ready gate** — `onMount` is not called until the host has a
29
+ * non-zero size. Initialising a WebGL context (or an editor) against a
30
+ * 0 × 0 element is the cause of the "first frame is wrong, then it
31
+ * settles" flicker; waiting one frame for layout is cheaper than
32
+ * rebuilding the surface;
33
+ * - **resize and teardown signals** — `onResize` on every observed size
34
+ * change, `onUnmount` before the element goes away, so a renderer can
35
+ * dispose its GPU resources;
36
+ * - **an overlay slot** — DOM drawn ABOVE the mount (device cards, leader
37
+ * lines, HUD), which is what the 3D views put over their canvas.
38
+ *
39
+ * The component draws nothing itself: no background, no placeholder, no
40
+ * error state. A host that needs those renders them in the default slot or
41
+ * the overlay.
42
+ */
43
+ export default defineComponent({
44
+ name: "HkBlankCanvas",
45
+ props: {
46
+ /** Called once, when the mount element first has a non-zero size. */
47
+ onMount: {
48
+ type: Function as PropType<((el: HTMLElement, size: BlankCanvasSize) => void) | undefined>,
49
+ default: undefined,
50
+ },
51
+ /** Called on every later size change (rounded to whole pixels). */
52
+ onResize: {
53
+ type: Function as PropType<((size: BlankCanvasSize) => void) | undefined>,
54
+ default: undefined,
55
+ },
56
+ /** Called before the mount element is removed. */
57
+ onUnmount: {
58
+ type: Function as PropType<(() => void) | undefined>,
59
+ default: undefined,
60
+ },
61
+ /** Accessible label for the mount region. */
62
+ ariaLabel: { type: String, default: undefined },
63
+ },
64
+ setup(props, { slots, expose }) {
65
+ const mountEl = shallowRef<HTMLElement | null>(null);
66
+ const size = ref<BlankCanvasSize>({ width: 0, height: 0 });
67
+ /** Whether `onMount` has fired — the gate that keeps a renderer from
68
+ * initialising against a 0 × 0 element. */
69
+ const ready = ref(false);
70
+ let ro: ResizeObserver | null = null;
71
+
72
+ function measure(): BlankCanvasSize {
73
+ const el = mountEl.value;
74
+ if (!el) return { width: 0, height: 0 };
75
+ const rect = el.getBoundingClientRect();
76
+ return { width: Math.round(rect.width), height: Math.round(rect.height) };
77
+ }
78
+
79
+ function publish() {
80
+ const next = measure();
81
+ const changed = next.width !== size.value.width || next.height !== size.value.height;
82
+ if (changed) size.value = next;
83
+ if (!ready.value) {
84
+ if (next.width === 0 || next.height === 0) return;
85
+ ready.value = true;
86
+ props.onMount?.(mountEl.value as HTMLElement, next);
87
+ return;
88
+ }
89
+ if (changed) props.onResize?.(next);
90
+ }
91
+
92
+ onMounted(() => {
93
+ // One frame first: the element exists but its parent may not have been
94
+ // laid out yet, and the whole point of the gate is not to initialise
95
+ // against the pre-layout box.
96
+ requestAnimationFrame(publish);
97
+ if (typeof ResizeObserver !== "undefined" && mountEl.value) {
98
+ ro = new ResizeObserver(publish);
99
+ ro.observe(mountEl.value);
100
+ }
101
+ });
102
+
103
+ onBeforeUnmount(() => {
104
+ ro?.disconnect();
105
+ ro = null;
106
+ if (ready.value) props.onUnmount?.();
107
+ ready.value = false;
108
+ });
109
+
110
+ expose({ size, ready, measure });
111
+
112
+ return () => (
113
+ <div
114
+ class="hk-blank-canvas"
115
+ aria-label={props.ariaLabel}
116
+ data-ready={ready.value ? "" : undefined}
117
+ >
118
+ <div ref={mountEl} class="hk-blank-canvas-mount">
119
+ {slots.default?.()}
120
+ </div>
121
+ <div class="hk-blank-canvas-overlay">{slots.overlay?.()}</div>
122
+ </div>
123
+ );
124
+ },
125
+ });
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
2
2
  import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
3
 
4
4
  import HkDrawer from "./HkDrawer";
5
+ import { usePopupManager } from "../runtime/usePopupManager";
5
6
 
6
7
  const mounts: ReturnType<typeof createApp>[] = [];
7
8
  const containers: HTMLElement[] = [];
@@ -89,3 +90,59 @@ describe("HkDrawer back-guard (window-first back priority)", () => {
89
90
  app.unmount();
90
91
  });
91
92
  });
93
+
94
+ describe("HkDrawer window-stack close channel", () => {
95
+ function mount(open: { value: boolean }, closable: boolean, title: string) {
96
+ const container = document.createElement("div");
97
+ document.body.appendChild(container);
98
+ containers.push(container);
99
+ const Wrapper = defineComponent({
100
+ setup() {
101
+ return () =>
102
+ h(HkDrawer, {
103
+ modelValue: open.value,
104
+ closable,
105
+ title,
106
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
107
+ }, { default: () => h("div", "content") });
108
+ },
109
+ });
110
+ const app = createApp(Wrapper);
111
+ mounts.push(app);
112
+ app.mount(container);
113
+ return app;
114
+ }
115
+
116
+ it("closes through the popup manager when the stack navigates back", async () => {
117
+ const manager = usePopupManager();
118
+ const below = manager.register("modal", true, "Below");
119
+ try {
120
+ const open = ref(true);
121
+ mount(open, true, "Details");
122
+ await nextTick();
123
+
124
+ expect(manager.closeAbove(below.id)).toBe(1);
125
+ await nextTick();
126
+ expect(open.value).toBe(false);
127
+ } finally {
128
+ manager.unregister(below.id);
129
+ }
130
+ });
131
+
132
+ it("cannot close a non-closable drawer", async () => {
133
+ const manager = usePopupManager();
134
+ const below = manager.register("modal", true, "Below");
135
+ try {
136
+ const open = ref(true);
137
+ mount(open, false, "Locked");
138
+ await nextTick();
139
+
140
+ // Asked, but the drawer refuses — exactly as it does for Escape.
141
+ expect(manager.closeAbove(below.id)).toBe(1);
142
+ await nextTick();
143
+ expect(open.value).toBe(true);
144
+ } finally {
145
+ manager.unregister(below.id);
146
+ }
147
+ });
148
+ });
@@ -126,7 +126,12 @@ export default defineComponent({
126
126
  // Register with the drawer title so the modal-stack breadcrumb
127
127
  // labels this layer by name — a drawer is a window on every
128
128
  // form factor and must never fall back to a generic label.
129
- handle.value = manager.register("drawer", true, props.title);
129
+ // Close channel for the modal-stack breadcrumb (see
130
+ // usePopupManager): the drawer owns its dismissal, and a
131
+ // non-closable drawer refuses like it does for Escape.
132
+ handle.value = manager.register("drawer", true, props.title, false, () => {
133
+ if (props.closable) close();
134
+ });
130
135
  overlayHook.open();
131
136
  previouslyFocused = document.activeElement as HTMLElement | null;
132
137
  if (backGuardEnabled() && backGuard.entries === 0) {
@@ -0,0 +1,81 @@
1
+ .hk-kanban {
2
+ position: relative;
3
+ display: flex;
4
+ flex-direction: column;
5
+ min-height: 0;
6
+ height: 100%;
7
+ }
8
+
9
+ .hk-kanban-strip {
10
+ flex: 1 1 auto;
11
+ min-height: 0;
12
+ }
13
+
14
+ /* Lanes are flex items, not CSS columns: each keeps its own header and its
15
+ own card scroller, so a card moving between lanes never reflows the strip. */
16
+ .hk-kanban-lanes {
17
+ display: flex;
18
+ align-items: stretch;
19
+ gap: var(--space-12);
20
+ min-height: 100%;
21
+ }
22
+
23
+ .hk-kanban[data-axis="vertical"] .hk-kanban-lanes {
24
+ flex-direction: column;
25
+ }
26
+
27
+ .hk-kanban-lane {
28
+ flex: 0 0 auto;
29
+ display: flex;
30
+ flex-direction: column;
31
+ min-width: 0;
32
+ width: var(--hk-kanban-lane-size, 18rem);
33
+ border-radius: var(--radius-lg, 0.75rem);
34
+ }
35
+
36
+ .hk-kanban[data-axis="vertical"] .hk-kanban-lane {
37
+ width: auto;
38
+ height: var(--hk-kanban-lane-size, 12rem);
39
+ }
40
+
41
+ /* The one drop affordance the board owns: the host styles the cards. */
42
+ .hk-kanban-lane[data-drop-target] {
43
+ outline: 2px dashed currentColor;
44
+ outline-offset: -2px;
45
+ }
46
+
47
+ .hk-kanban-lane-header {
48
+ flex: 0 0 auto;
49
+ padding: var(--space-8);
50
+ }
51
+
52
+ /* The card scroller is per lane, so a long lane scrolls without moving the
53
+ strip — and the lane axis stays free for the board's own scrolling. */
54
+ .hk-kanban-cards {
55
+ flex: 1 1 auto;
56
+ min-height: 0;
57
+ display: flex;
58
+ flex-direction: column;
59
+ gap: var(--space-8);
60
+ padding: 0 var(--space-8) var(--space-8);
61
+ overflow-y: auto;
62
+ }
63
+
64
+ .hk-kanban-card {
65
+ flex: 0 0 auto;
66
+ }
67
+
68
+ .hk-kanban-card[draggable="true"] {
69
+ cursor: grab;
70
+ }
71
+
72
+ .hk-kanban[data-dragging] .hk-kanban-card {
73
+ cursor: grabbing;
74
+ }
75
+
76
+ /* The tail is a drop target so a card can be appended to any lane,
77
+ including an empty one. */
78
+ .hk-kanban-lane-tail {
79
+ flex: 1 1 auto;
80
+ min-height: var(--space-24);
81
+ }
@@ -0,0 +1,219 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, type App } from "vue";
3
+
4
+ import HkKanban from "./HkKanban";
5
+
6
+ /**
7
+ * HkKanban tests — lanes, headers, cards, the scroll axis, and the drag
8
+ * contract (a drop emits both ends; a drop on the origin emits nothing).
9
+ */
10
+
11
+ interface Lane {
12
+ key: string;
13
+ cards: string[];
14
+ }
15
+
16
+ const apps: App[] = [];
17
+
18
+ afterEach(() => {
19
+ for (const app of apps.splice(0)) app.unmount();
20
+ });
21
+
22
+ function mount(props: Record<string, unknown>, slots: Record<string, unknown> = {}) {
23
+ const container = document.createElement("div");
24
+ document.body.appendChild(container);
25
+ const Root = defineComponent({
26
+ setup() {
27
+ return () =>
28
+ h(
29
+ HkKanban,
30
+ { ref: undefined, ...props } as never,
31
+ {
32
+ laneHeader: ({ lane }: { lane: unknown }) =>
33
+ h("span", { class: "lane-name" }, (lane as Lane).key),
34
+ card: ({ card }: { card: unknown }) => h("div", { class: "card" }, String(card)),
35
+ ...slots,
36
+ },
37
+ );
38
+ },
39
+ });
40
+ const app = createApp(Root);
41
+ app.mount(container);
42
+ apps.push(app);
43
+ return container;
44
+ }
45
+
46
+ const LANES: Lane[] = [
47
+ { key: "todo", cards: ["a", "b"] },
48
+ { key: "doing", cards: ["c"] },
49
+ ];
50
+
51
+ const base = {
52
+ lanes: LANES,
53
+ laneKeyOf: (lane: unknown) => (lane as Lane).key,
54
+ cardsOf: (lane: unknown) => (lane as Lane).cards,
55
+ };
56
+
57
+ /** A drag event the board understands in a DOM without dataTransfer. */
58
+ function dragEvent(type: string): DragEvent {
59
+ const event = new Event(type, { bubbles: true, cancelable: true }) as DragEvent;
60
+ Object.defineProperty(event, "dataTransfer", {
61
+ value: { setData: () => {}, effectAllowed: "", dropEffect: "" },
62
+ configurable: true,
63
+ });
64
+ return event;
65
+ }
66
+
67
+ describe("HkKanban", () => {
68
+ it("renders every lane with its header and its cards", async () => {
69
+ const root = mount(base);
70
+ await nextTick();
71
+ const lanes = [...root.querySelectorAll<HTMLElement>(".hk-kanban-lane")];
72
+ expect(lanes.map((lane) => lane.dataset.lane)).toEqual(["todo", "doing"]);
73
+ expect(lanes[0].querySelector(".lane-name")?.textContent).toBe("todo");
74
+ expect(
75
+ [...lanes[0].querySelectorAll<HTMLElement>(".card")].map((card) => card.textContent),
76
+ ).toEqual(["a", "b"]);
77
+ });
78
+
79
+ it("carries the scroll axis it was given", async () => {
80
+ const root = mount({ ...base, axis: "vertical" });
81
+ await nextTick();
82
+ expect(root.querySelector(".hk-kanban")?.getAttribute("data-axis")).toBe("vertical");
83
+ });
84
+
85
+ it("renders the empty slot when there are no lanes", async () => {
86
+ const root = mount(
87
+ { ...base, lanes: [] },
88
+ { empty: () => h("p", { class: "none" }, "none") },
89
+ );
90
+ await nextTick();
91
+ expect(root.querySelector(".none")?.textContent).toBe("none");
92
+ expect(root.querySelectorAll(".hk-kanban-lane")).toHaveLength(0);
93
+ });
94
+
95
+ it("emits a move with both ends when a card lands on another lane", async () => {
96
+ const moves: unknown[] = [];
97
+ const root = mount({ ...base, draggable: true, onMove: (m: unknown) => moves.push(m) });
98
+ await nextTick();
99
+ const cards = root.querySelectorAll<HTMLElement>(".hk-kanban-card");
100
+ const target = root.querySelector<HTMLElement>(".hk-kanban-lane[data-lane='doing'] .hk-kanban-lane-tail");
101
+ expect(cards[0] && target).toBeTruthy();
102
+
103
+ cards[0].dispatchEvent(dragEvent("dragstart"));
104
+ target?.dispatchEvent(dragEvent("dragover"));
105
+ target?.dispatchEvent(dragEvent("drop"));
106
+
107
+ expect(moves).toHaveLength(1);
108
+ expect(moves[0]).toMatchObject({
109
+ card: "a",
110
+ fromLaneKey: "todo",
111
+ fromIndex: 0,
112
+ toLaneKey: "doing",
113
+ toIndex: 1,
114
+ });
115
+ });
116
+
117
+ it("marks the lane under a dragged card as the drop target", async () => {
118
+ const root = mount({ ...base, draggable: true });
119
+ await nextTick();
120
+ const card = root.querySelector<HTMLElement>(".hk-kanban-card");
121
+ const lane = root.querySelector<HTMLElement>(".hk-kanban-lane[data-lane='doing']");
122
+ card?.dispatchEvent(dragEvent("dragstart"));
123
+ lane?.dispatchEvent(dragEvent("dragover"));
124
+ await nextTick();
125
+ expect(lane?.hasAttribute("data-drop-target")).toBe(true);
126
+ });
127
+
128
+ it("stays quiet when a card is dropped where it started", async () => {
129
+ // The host should not have to filter "nothing moved" itself.
130
+ const moves: unknown[] = [];
131
+ const root = mount({ ...base, draggable: true, onMove: (m: unknown) => moves.push(m) });
132
+ await nextTick();
133
+ const card = root.querySelector<HTMLElement>(".hk-kanban-card");
134
+ card?.dispatchEvent(dragEvent("dragstart"));
135
+ card?.dispatchEvent(dragEvent("dragover"));
136
+ card?.dispatchEvent(dragEvent("drop"));
137
+ expect(moves).toHaveLength(0);
138
+ });
139
+
140
+ it("does not make cards draggable unless asked", async () => {
141
+ const root = mount(base);
142
+ await nextTick();
143
+ const card = root.querySelector<HTMLElement>(".hk-kanban-card");
144
+ expect(card?.hasAttribute("draggable")).toBe(false);
145
+ });
146
+
147
+ it("scrolls the strip on BOTH axes when the axis is both", async () => {
148
+ // `both` used to be a value nothing consumed: the strip was horizontal
149
+ // whatever it said. A board whose lanes and cards both overflow has to
150
+ // travel on either axis.
151
+ const root = mount({ ...base, axis: "both" });
152
+ await nextTick();
153
+ expect(root.querySelector(".hk-kanban")?.getAttribute("data-axis")).toBe("both");
154
+ expect(root.querySelector(".hk-scroll-container")?.getAttribute("data-axis")).toBe("both");
155
+ });
156
+
157
+ it("scrolls the strip down when the axis is vertical", async () => {
158
+ const root = mount({ ...base, axis: "vertical" });
159
+ await nextTick();
160
+ expect(root.querySelector(".hk-scroll-container")?.getAttribute("data-axis")).toBe("vertical");
161
+ });
162
+
163
+ it("passes the lane size through as the shared variable", async () => {
164
+ const root = mount({ ...base, laneSize: "22rem" });
165
+ await nextTick();
166
+ const lanes = root.querySelector<HTMLElement>(".hk-kanban-lanes");
167
+ expect(lanes?.style.getPropertyValue("--hk-kanban-lane-size")).toBe("22rem");
168
+ });
169
+
170
+ it("accepts a drop anywhere on the lane, not only on a card", async () => {
171
+ // The whole lane is highlighted as a target, so the whole lane has to be
172
+ // one: hovering the header used to show the outline and then swallow the
173
+ // drop silently.
174
+ const moves: Array<Record<string, unknown>> = [];
175
+ const root = mount({
176
+ ...base,
177
+ draggable: true,
178
+ onMove: (m: Record<string, unknown>) => moves.push(m),
179
+ });
180
+ await nextTick();
181
+ root.querySelector<HTMLElement>(".hk-kanban-card")?.dispatchEvent(dragEvent("dragstart"));
182
+ const header = root.querySelector<HTMLElement>(
183
+ ".hk-kanban-lane[data-lane='doing'] .hk-kanban-lane-header",
184
+ );
185
+ header?.dispatchEvent(dragEvent("dragover"));
186
+ header?.dispatchEvent(dragEvent("drop"));
187
+
188
+ expect(moves).toHaveLength(1);
189
+ expect(moves[0]).toMatchObject({ card: "a", toLaneKey: "doing", toIndex: 1 });
190
+ });
191
+
192
+ it("marks the drop as handled, so the browser cannot fall back", async () => {
193
+ const root = mount({ ...base, draggable: true });
194
+ await nextTick();
195
+ root.querySelector<HTMLElement>(".hk-kanban-card")?.dispatchEvent(dragEvent("dragstart"));
196
+ const event = dragEvent("drop");
197
+ root.querySelector<HTMLElement>(".hk-kanban-lane[data-lane='doing']")?.dispatchEvent(event);
198
+ expect(event.defaultPrevented).toBe(true);
199
+ });
200
+
201
+ it("keeps the highlight while the pointer crosses cards inside one lane", async () => {
202
+ // dragleave bubbles from every child, so an unguarded handler blinks the
203
+ // outline off when the pointer moves from one card to the next.
204
+ const root = mount({ ...base, draggable: true });
205
+ await nextTick();
206
+ const cards = root.querySelectorAll<HTMLElement>(".hk-kanban-card");
207
+ const lane = root.querySelector<HTMLElement>(".hk-kanban-lane[data-lane='todo']");
208
+ cards[0]?.dispatchEvent(dragEvent("dragstart"));
209
+ lane?.dispatchEvent(dragEvent("dragover"));
210
+ await nextTick();
211
+ expect(lane?.hasAttribute("data-drop-target")).toBe(true);
212
+
213
+ const leave = dragEvent("dragleave");
214
+ Object.defineProperty(leave, "relatedTarget", { value: cards[1], configurable: true });
215
+ cards[0]?.dispatchEvent(leave);
216
+ await nextTick();
217
+ expect(lane?.hasAttribute("data-drop-target")).toBe(true);
218
+ });
219
+ });