@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.
- package/package.json +1 -1
- package/src/components/HkBlankCanvas.scss +29 -0
- package/src/components/HkBlankCanvas.test.tsx +147 -0
- package/src/components/HkBlankCanvas.tsx +125 -0
- package/src/components/HkDrawer.test.tsx +57 -0
- package/src/components/HkDrawer.tsx +6 -1
- package/src/components/HkKanban.scss +81 -0
- package/src/components/HkKanban.test.tsx +219 -0
- package/src/components/HkKanban.tsx +264 -0
- package/src/components/HkModal.test.tsx +76 -0
- package/src/components/HkModal.tsx +8 -1
- package/src/components/HkModalBreadcrumb.overflow.contract.test.ts +115 -0
- package/src/components/HkModalBreadcrumb.scss +114 -2
- package/src/components/HkModalBreadcrumb.test.tsx +549 -6
- package/src/components/HkModalBreadcrumb.tsx +433 -37
- package/src/components/HkPopover.tsx +8 -0
- package/src/components/HkPopoverSheet.test.tsx +21 -0
- package/src/components/HkSelectPanel.test.tsx +21 -0
- package/src/components/HkSelectPanel.tsx +3 -0
- package/src/components/HkWaterfall.animate.test.tsx +63 -0
- package/src/components/HkWaterfall.scss +43 -0
- package/src/components/HkWaterfall.test.tsx +407 -0
- package/src/components/HkWaterfall.tsx +489 -0
- package/src/i18n/locales/ar/components.json +2 -1
- package/src/i18n/locales/de/components.json +2 -1
- package/src/i18n/locales/en/components.json +2 -1
- package/src/i18n/locales/es/components.json +2 -1
- package/src/i18n/locales/fr/components.json +2 -1
- package/src/i18n/locales/ja/components.json +2 -1
- package/src/i18n/locales/ko/components.json +2 -1
- package/src/i18n/locales/pt/components.json +2 -1
- package/src/i18n/locales/ru/components.json +2 -1
- package/src/i18n/locales/zh-Hans/components.json +2 -1
- package/src/i18n/locales/zh-Hant/components.json +2 -1
- package/src/i18n/modalKeys.test.ts +59 -0
- package/src/index.ts +17 -0
- package/src/runtime/backStack.test.ts +171 -0
- package/src/runtime/backStack.ts +118 -39
- package/src/runtime/displayWidth.test.ts +102 -0
- package/src/runtime/displayWidth.ts +76 -0
- package/src/runtime/index.ts +6 -0
- package/src/runtime/usePopupManager.test.ts +60 -0
- package/src/runtime/usePopupManager.ts +42 -1
- package/src/styles/admin-tokens.scss +16 -3
- package/src/styles/authCardWidth.test.ts +343 -0
- package/src/theme/authCard.ts +38 -0
- package/src/theme/index.ts +1 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|