@celestia-island/hikari 0.54.8 → 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/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/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 +7 -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/package.json
CHANGED
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
defineComponent,
|
|
4
|
+
ref,
|
|
5
|
+
shallowRef,
|
|
6
|
+
type PropType,
|
|
7
|
+
} from "vue";
|
|
8
|
+
|
|
9
|
+
import "./HkKanban.scss";
|
|
10
|
+
import HScrollContainer from "./HkScrollContainer";
|
|
11
|
+
|
|
12
|
+
/** Which way a board scrolls. */
|
|
13
|
+
export type KanbanAxis = "horizontal" | "vertical" | "both";
|
|
14
|
+
|
|
15
|
+
/** What the `laneHeader` slot receives — enough to draw the header AS a
|
|
16
|
+
* card, which is what the chat node list does with its left-hand labels. */
|
|
17
|
+
export interface KanbanHeaderSlotProps {
|
|
18
|
+
lane: unknown;
|
|
19
|
+
laneIndex: number;
|
|
20
|
+
laneKey: string;
|
|
21
|
+
cards: readonly unknown[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What the `card` slot receives for one card. */
|
|
25
|
+
export interface KanbanCardSlotProps {
|
|
26
|
+
card: unknown;
|
|
27
|
+
cardIndex: number;
|
|
28
|
+
lane: unknown;
|
|
29
|
+
laneIndex: number;
|
|
30
|
+
laneKey: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Emitted when a card is dropped on another lane (or another position). */
|
|
34
|
+
export interface KanbanMove {
|
|
35
|
+
card: unknown;
|
|
36
|
+
fromLaneKey: string;
|
|
37
|
+
fromIndex: number;
|
|
38
|
+
toLaneKey: string;
|
|
39
|
+
/** Where the card lands in the target lane, in the TARGET's coordinates:
|
|
40
|
+
* the host inserts at this index AFTER removing the card from its source
|
|
41
|
+
* lane (so moving a card to the end is `toIndex === targetLength`). */
|
|
42
|
+
toIndex: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* HkKanban — the base view for boards of typed, draggable cards.
|
|
47
|
+
*
|
|
48
|
+
* It is deliberately NOT a waterfall variant. A waterfall enumerates one
|
|
49
|
+
* stream into columns; a board is a set of LANES, each with a header that may
|
|
50
|
+
* itself be a card, each holding cards that can be dragged between lanes.
|
|
51
|
+
* The chat node list is this shape taken to its extreme (the left-hand
|
|
52
|
+
* label of every row is a card of its own), and so is the multidimensional
|
|
53
|
+
* table's board view — which is why both specialise this component rather
|
|
54
|
+
* than the waterfall.
|
|
55
|
+
*
|
|
56
|
+
* What it owns:
|
|
57
|
+
*
|
|
58
|
+
* - **lanes** — ordered, with a stable key each (the header's identity and
|
|
59
|
+
* the drop target);
|
|
60
|
+
* - **axes** — `horizontal` (lanes side by side), `vertical` (lanes
|
|
61
|
+
* stacked) or `both`, because a board's lane axis and its card axis are
|
|
62
|
+
* independent;
|
|
63
|
+
* - **headers** — rendered through a slot, so a header can be a plain
|
|
64
|
+
* label, a card, or a control cluster;
|
|
65
|
+
* - **drag and drop** — when `draggable`, cards carry the native drag
|
|
66
|
+
* contract and a completed drop emits `move` with both ends; the host
|
|
67
|
+
* applies it (and may reject it, since the board never mutates data).
|
|
68
|
+
*
|
|
69
|
+
* What it does NOT own: card content, lane sizing beyond `laneSize`, and any
|
|
70
|
+
* data mutation — a board is a view of the host's model, never its owner.
|
|
71
|
+
*/
|
|
72
|
+
export default defineComponent({
|
|
73
|
+
name: "HkKanban",
|
|
74
|
+
props: {
|
|
75
|
+
/** The lanes, in display order. */
|
|
76
|
+
lanes: { type: Array as PropType<readonly unknown[]>, required: true },
|
|
77
|
+
/** Stable key of a lane — the header's identity and the drop target. */
|
|
78
|
+
laneKeyOf: {
|
|
79
|
+
type: Function as PropType<(lane: unknown, index: number) => string>,
|
|
80
|
+
required: true,
|
|
81
|
+
},
|
|
82
|
+
/** The cards inside a lane, in display order. */
|
|
83
|
+
cardsOf: {
|
|
84
|
+
type: Function as PropType<(lane: unknown, index: number) => readonly unknown[]>,
|
|
85
|
+
required: true,
|
|
86
|
+
},
|
|
87
|
+
/** Stable key of a card, when the host has one (drag identity). */
|
|
88
|
+
cardKeyOf: {
|
|
89
|
+
type: Function as PropType<(card: unknown, index: number) => string>,
|
|
90
|
+
default: undefined,
|
|
91
|
+
},
|
|
92
|
+
/** Which way the board scrolls. */
|
|
93
|
+
axis: { type: String as PropType<KanbanAxis>, default: "horizontal" },
|
|
94
|
+
/** CSS width (horizontal / both) or height (vertical) of one lane. */
|
|
95
|
+
laneSize: { type: String, default: undefined },
|
|
96
|
+
/** Whether cards can be dragged between lanes. */
|
|
97
|
+
draggable: { type: Boolean, default: false },
|
|
98
|
+
ariaLabel: { type: String, default: undefined },
|
|
99
|
+
},
|
|
100
|
+
emits: ["move"],
|
|
101
|
+
setup(props, { slots, emit, expose }) {
|
|
102
|
+
const scrollHost = shallowRef<{ getScrollElement: () => HTMLElement | undefined } | null>(null);
|
|
103
|
+
/** The card being dragged, kept at module scope of the setup so a drop
|
|
104
|
+
* can name both ends without the host tracking drag state. */
|
|
105
|
+
const dragging = ref<{ laneKey: string; index: number; card: unknown } | null>(null);
|
|
106
|
+
const dropTarget = ref<string | null>(null);
|
|
107
|
+
|
|
108
|
+
const cardsFor = (lane: unknown, index: number) => props.cardsOf(lane, index);
|
|
109
|
+
|
|
110
|
+
/** The scroll axis of the lane strip itself, straight from `axis`: a
|
|
111
|
+
* horizontal board scrolls across, a vertical one down, and `both`
|
|
112
|
+
* scrolls the strip BOTH ways (the lane axis and the card axis are
|
|
113
|
+
* independent, and a board may need to travel on either). */
|
|
114
|
+
const stripAxis = computed(() => props.axis);
|
|
115
|
+
|
|
116
|
+
function onDragStart(card: unknown, laneKey: string, index: number, event: DragEvent) {
|
|
117
|
+
if (!props.draggable) return;
|
|
118
|
+
dragging.value = { laneKey, index, card };
|
|
119
|
+
event.dataTransfer?.setData("text/plain", laneKey);
|
|
120
|
+
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function onDragOver(laneKey: string, event: DragEvent) {
|
|
124
|
+
if (!props.draggable || !dragging.value) return;
|
|
125
|
+
// Only a lane that would actually accept the card is a target.
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
|
128
|
+
dropTarget.value = laneKey;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function onDragLeave(laneKey: string, event: DragEvent) {
|
|
132
|
+
// dragleave bubbles from every child the pointer crosses, so a move
|
|
133
|
+
// from one card to the next inside the SAME lane would otherwise blink
|
|
134
|
+
// the highlight off; only a leave that exits the lane counts.
|
|
135
|
+
const next = event.relatedTarget as Node | null;
|
|
136
|
+
const lane = event.currentTarget as Node | null;
|
|
137
|
+
if (next && lane && lane.contains(next)) return;
|
|
138
|
+
if (dropTarget.value === laneKey) dropTarget.value = null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Where a drop lands inside a lane: on the card under the pointer, or
|
|
142
|
+
* at the end when the pointer is over the lane's header, footer, the
|
|
143
|
+
* gaps between cards, or the tail. The whole lane is a target because
|
|
144
|
+
* the whole lane is highlighted as one. */
|
|
145
|
+
function dropIndexWithin(event: DragEvent, cardCount: number): number {
|
|
146
|
+
const target = event.target as HTMLElement | null;
|
|
147
|
+
const card = target?.closest?.("[data-card-index]");
|
|
148
|
+
const raw = card?.getAttribute("data-card-index");
|
|
149
|
+
const index = raw === null || raw === undefined ? Number.NaN : Number(raw);
|
|
150
|
+
return Number.isInteger(index) && index >= 0 && index < cardCount ? index : cardCount;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function onDrop(laneKey: string, cardCount: number, event: DragEvent) {
|
|
154
|
+
if (!props.draggable || !dragging.value) return;
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
const from = dragging.value;
|
|
157
|
+
const index = dropIndexWithin(event, cardCount);
|
|
158
|
+
dragging.value = null;
|
|
159
|
+
dropTarget.value = null;
|
|
160
|
+
// A drop on the card it started from is not a move — the host should
|
|
161
|
+
// not have to filter that itself.
|
|
162
|
+
if (from.laneKey === laneKey && from.index === index) return;
|
|
163
|
+
emit("move", {
|
|
164
|
+
card: from.card,
|
|
165
|
+
fromLaneKey: from.laneKey,
|
|
166
|
+
fromIndex: from.index,
|
|
167
|
+
toLaneKey: laneKey,
|
|
168
|
+
toIndex: index,
|
|
169
|
+
} satisfies KanbanMove);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function onDragEnd() {
|
|
173
|
+
dragging.value = null;
|
|
174
|
+
dropTarget.value = null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
expose({ scrollHost, dragging });
|
|
178
|
+
|
|
179
|
+
return () => (
|
|
180
|
+
<div
|
|
181
|
+
class="hk-kanban"
|
|
182
|
+
data-axis={props.axis}
|
|
183
|
+
data-dragging={dragging.value ? "" : undefined}
|
|
184
|
+
aria-label={props.ariaLabel}
|
|
185
|
+
>
|
|
186
|
+
{props.lanes.length === 0
|
|
187
|
+
? (slots.empty?.() ?? null)
|
|
188
|
+
: (
|
|
189
|
+
<HScrollContainer
|
|
190
|
+
ref={scrollHost}
|
|
191
|
+
class="hk-kanban-strip"
|
|
192
|
+
axis={stripAxis.value}
|
|
193
|
+
overscanScreens={1}
|
|
194
|
+
>
|
|
195
|
+
<div
|
|
196
|
+
class="hk-kanban-lanes"
|
|
197
|
+
// One variable; the stylesheet decides whether it is the
|
|
198
|
+
// lane's width (horizontal boards) or its height (vertical).
|
|
199
|
+
style={
|
|
200
|
+
props.laneSize ? { "--hk-kanban-lane-size": props.laneSize } : undefined
|
|
201
|
+
}
|
|
202
|
+
>
|
|
203
|
+
{props.lanes.map((lane, laneIndex) => {
|
|
204
|
+
const laneKey = props.laneKeyOf(lane, laneIndex);
|
|
205
|
+
const cards = cardsFor(lane, laneIndex);
|
|
206
|
+
return (
|
|
207
|
+
<section
|
|
208
|
+
key={laneKey}
|
|
209
|
+
class="hk-kanban-lane"
|
|
210
|
+
data-lane={laneKey}
|
|
211
|
+
data-drop-target={dropTarget.value === laneKey ? "" : undefined}
|
|
212
|
+
onDragover={(event: DragEvent) => onDragOver(laneKey, event)}
|
|
213
|
+
onDragleave={(event: DragEvent) => onDragLeave(laneKey, event)}
|
|
214
|
+
onDrop={(event: DragEvent) => onDrop(laneKey, cards.length, event)}
|
|
215
|
+
>
|
|
216
|
+
<header class="hk-kanban-lane-header">
|
|
217
|
+
{slots.laneHeader?.({
|
|
218
|
+
lane,
|
|
219
|
+
laneIndex,
|
|
220
|
+
laneKey,
|
|
221
|
+
cards,
|
|
222
|
+
})}
|
|
223
|
+
</header>
|
|
224
|
+
<div class="hk-kanban-cards">
|
|
225
|
+
{cards.map((card, cardIndex) => {
|
|
226
|
+
const cardKey =
|
|
227
|
+
props.cardKeyOf?.(card, cardIndex) ?? `${laneKey}#${cardIndex}`;
|
|
228
|
+
return (
|
|
229
|
+
<div
|
|
230
|
+
key={cardKey}
|
|
231
|
+
class="hk-kanban-card"
|
|
232
|
+
data-card={cardKey}
|
|
233
|
+
data-card-index={cardIndex}
|
|
234
|
+
draggable={props.draggable || undefined}
|
|
235
|
+
onDragstart={(event: DragEvent) =>
|
|
236
|
+
onDragStart(card, laneKey, cardIndex, event)
|
|
237
|
+
}
|
|
238
|
+
onDragend={onDragEnd}
|
|
239
|
+
>
|
|
240
|
+
{slots.card?.({
|
|
241
|
+
card,
|
|
242
|
+
cardIndex,
|
|
243
|
+
lane,
|
|
244
|
+
laneIndex,
|
|
245
|
+
laneKey,
|
|
246
|
+
})}
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
})}
|
|
250
|
+
{/* The tail keeps the lane's empty space hittable
|
|
251
|
+
(the lane itself owns the drop). */}
|
|
252
|
+
<div class="hk-kanban-lane-tail" />
|
|
253
|
+
</div>
|
|
254
|
+
{slots.laneFooter?.({ lane, laneIndex, laneKey, cards })}
|
|
255
|
+
</section>
|
|
256
|
+
);
|
|
257
|
+
})}
|
|
258
|
+
</div>
|
|
259
|
+
</HScrollContainer>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
},
|
|
264
|
+
});
|