@profpowell/vb-project-planning 0.1.0
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/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/vb-project-planning.css +1603 -0
- package/dist/vb-project-planning.js +11138 -0
- package/dist/vb-project-planning.min.css +1 -0
- package/dist/vb-project-planning.min.js +3312 -0
- package/package.json +80 -0
- package/src/index.css +14 -0
- package/src/index.js +26 -0
- package/src/lib/bundle-registry.js +156 -0
- package/src/lib/diff-by-key.js +86 -0
- package/src/lib/portrait-url.js +87 -0
- package/src/lib/vb-collection.js +122 -0
- package/src/lib/vb-element.js +96 -0
- package/src/lib/vb-store.js +200 -0
- package/src/lib/vb-view-transition.js +44 -0
- package/src/utils/copy-init.js +287 -0
- package/src/web-components/_ux-base.js +106 -0
- package/src/web-components/adr-wc/README.md +77 -0
- package/src/web-components/adr-wc/api.json +26 -0
- package/src/web-components/adr-wc/logic.js +376 -0
- package/src/web-components/adr-wc/static.html +17 -0
- package/src/web-components/adr-wc/styles.js +338 -0
- package/src/web-components/burndown-chart/api.json +74 -0
- package/src/web-components/burndown-chart/logic.js +198 -0
- package/src/web-components/burndown-chart/static.html +31 -0
- package/src/web-components/burndown-chart/styles.css +59 -0
- package/src/web-components/capacity-plan/_capacity-utils.js +56 -0
- package/src/web-components/capacity-plan/api.json +20 -0
- package/src/web-components/capacity-plan/logic.js +222 -0
- package/src/web-components/capacity-plan/static.html +24 -0
- package/src/web-components/capacity-plan/styles.css +84 -0
- package/src/web-components/empathy-map/api.json +31 -0
- package/src/web-components/empathy-map/logic.js +502 -0
- package/src/web-components/empathy-map/static.html +32 -0
- package/src/web-components/empathy-map/styles.js +534 -0
- package/src/web-components/gantt-chart/README.md +91 -0
- package/src/web-components/gantt-chart/api.json +20 -0
- package/src/web-components/gantt-chart/logic.js +812 -0
- package/src/web-components/gantt-chart/static.html +60 -0
- package/src/web-components/gantt-chart/styles.css +391 -0
- package/src/web-components/impact-effort/api.json +21 -0
- package/src/web-components/impact-effort/logic.js +392 -0
- package/src/web-components/impact-effort/static.html +23 -0
- package/src/web-components/impact-effort/styles.css +193 -0
- package/src/web-components/iron-triangle/_capacity.js +83 -0
- package/src/web-components/iron-triangle/_triangle-geometry.js +322 -0
- package/src/web-components/iron-triangle/api.json +26 -0
- package/src/web-components/iron-triangle/logic.js +474 -0
- package/src/web-components/iron-triangle/static.html +28 -0
- package/src/web-components/iron-triangle/styles.css +158 -0
- package/src/web-components/kanban-board/api.json +24 -0
- package/src/web-components/kanban-board/logic.js +698 -0
- package/src/web-components/kanban-board/static.html +20 -0
- package/src/web-components/kanban-board/styles.css +242 -0
- package/src/web-components/product-roadmap/api.json +118 -0
- package/src/web-components/product-roadmap/logic.js +460 -0
- package/src/web-components/product-roadmap/static.html +44 -0
- package/src/web-components/product-roadmap/styles.css +158 -0
- package/src/web-components/quadrant-grid/api.json +125 -0
- package/src/web-components/quadrant-grid/logic.js +397 -0
- package/src/web-components/quadrant-grid/static.html +49 -0
- package/src/web-components/quadrant-grid/styles.css +184 -0
- package/src/web-components/quality-target/_quality-utils.js +460 -0
- package/src/web-components/quality-target/api.json +30 -0
- package/src/web-components/quality-target/logic.js +516 -0
- package/src/web-components/quality-target/static.html +22 -0
- package/src/web-components/quality-target/styles.css +203 -0
- package/src/web-components/requirement-card/api.json +25 -0
- package/src/web-components/requirement-card/logic.js +166 -0
- package/src/web-components/requirement-card/static.html +36 -0
- package/src/web-components/requirement-card/styles.js +114 -0
- package/src/web-components/review-surface/README.md +178 -0
- package/src/web-components/review-surface/api.json +30 -0
- package/src/web-components/review-surface/logic.js +807 -0
- package/src/web-components/review-surface/static.html +33 -0
- package/src/web-components/review-surface/styles.js +459 -0
- package/src/web-components/risk-register/api.json +47 -0
- package/src/web-components/risk-register/logic.js +180 -0
- package/src/web-components/risk-register/static.html +46 -0
- package/src/web-components/risk-register/styles.css +37 -0
- package/src/web-components/story-map/api.json +22 -0
- package/src/web-components/story-map/logic.js +445 -0
- package/src/web-components/story-map/static.html +35 -0
- package/src/web-components/story-map/styles.css +169 -0
- package/src/web-components/traceability-matrix/api.json +84 -0
- package/src/web-components/traceability-matrix/logic.js +243 -0
- package/src/web-components/traceability-matrix/static.html +41 -0
- package/src/web-components/traceability-matrix/styles.css +94 -0
- package/src/web-components/user-journey/api.json +84 -0
- package/src/web-components/user-journey/logic.js +368 -0
- package/src/web-components/user-journey/static.html +14 -0
- package/src/web-components/user-journey/styles.js +317 -0
- package/src/web-components/user-persona/api.json +35 -0
- package/src/web-components/user-persona/logic.js +502 -0
- package/src/web-components/user-persona/static.html +20 -0
- package/src/web-components/user-persona/styles.js +299 -0
- package/src/web-components/user-story/api.json +35 -0
- package/src/web-components/user-story/logic.js +567 -0
- package/src/web-components/user-story/static.html +27 -0
- package/src/web-components/user-story/styles.js +332 -0
- package/src/web-components/work-item/README.md +85 -0
- package/src/web-components/work-item/api.json +30 -0
- package/src/web-components/work-item/logic.js +414 -0
- package/src/web-components/work-item/static.html +24 -0
- package/src/web-components/work-item/styles.js +376 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kanban-board: Columnar drag-and-drop board
|
|
3
|
+
*
|
|
4
|
+
* Light DOM component that creates a board with user-defined columns,
|
|
5
|
+
* each containing a <drag-surface> for cross-column drag-and-drop.
|
|
6
|
+
* Works with any draggable content — user-story cards, articles, or
|
|
7
|
+
* any element with draggable="true" and data-id.
|
|
8
|
+
*
|
|
9
|
+
* @attr {string} src - URL to JSON data for columns and items
|
|
10
|
+
* @attr {string} title - Optional heading above the board
|
|
11
|
+
* @attr {boolean} compact - Reduced spacing variant
|
|
12
|
+
*
|
|
13
|
+
* Children use:
|
|
14
|
+
* @attr {string} data-column - Column identifier (on <section>)
|
|
15
|
+
* @attr {string} data-column-label - Display label for the column header (on <section>)
|
|
16
|
+
* @attr {number} data-wip - Optional WIP limit for the column (on <section>)
|
|
17
|
+
* @attr {string} data-column-color - Optional color token for column tint (on <section>)
|
|
18
|
+
*
|
|
19
|
+
* @fires kanban-board:transfer - Item moved between columns
|
|
20
|
+
* @fires kanban-board:reorder - Item reordered within a column
|
|
21
|
+
* @fires kanban-board:ready - Fired after component initializes
|
|
22
|
+
* @fires kanban-board:wip-exceeded - Fired when a column exceeds its WIP limit
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* <kanban-board>
|
|
26
|
+
* <section data-column="backlog" data-column-label="Backlog">
|
|
27
|
+
* <article draggable="true" data-id="task-1">Design review</article>
|
|
28
|
+
* </section>
|
|
29
|
+
* <section data-column="doing" data-column-label="In Progress" data-wip="3">
|
|
30
|
+
* <article draggable="true" data-id="task-2">API integration</article>
|
|
31
|
+
* </section>
|
|
32
|
+
* </kanban-board>
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
36
|
+
import { VBElement } from '../../lib/vb-element.js';
|
|
37
|
+
import { VBCollection } from '../../lib/vb-collection.js';
|
|
38
|
+
import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
|
|
39
|
+
|
|
40
|
+
class KanbanBoard extends VBCollection(VBElement) {
|
|
41
|
+
static get observedAttributes() { return ['src', 'compact', 'title']; }
|
|
42
|
+
static keyOf(item) { return item.id ?? item.storyId; }
|
|
43
|
+
|
|
44
|
+
static #instanceCount = 0;
|
|
45
|
+
|
|
46
|
+
/** @type {HTMLDivElement | null} */
|
|
47
|
+
#columnsEl = null;
|
|
48
|
+
/** @type {Record<string, HTMLElement>} */
|
|
49
|
+
#surfaces = {};
|
|
50
|
+
/** @type {Record<string, HTMLElement>} */
|
|
51
|
+
#columnEls = {};
|
|
52
|
+
/** @type {HTMLDivElement | null} */
|
|
53
|
+
#liveRegion = null;
|
|
54
|
+
/** @type {string} */
|
|
55
|
+
#groupId = '';
|
|
56
|
+
/** @type {Array<{ id: string, label: string, wip: number | null, color: string | null, children: HTMLElement[] }>} */
|
|
57
|
+
#columns = [];
|
|
58
|
+
/** @type {((item: any) => Element) | null} */
|
|
59
|
+
#renderItem = null;
|
|
60
|
+
|
|
61
|
+
setup() {
|
|
62
|
+
this.#groupId = `kb-${++KanbanBoard.#instanceCount}`;
|
|
63
|
+
|
|
64
|
+
// 1. Snapshot <section data-column> children
|
|
65
|
+
const sections = /** @type {HTMLElement[]} */ ([
|
|
66
|
+
...this.querySelectorAll(':scope > section[data-column]')
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
this.#columns = sections.map(section => {
|
|
70
|
+
const id = section.getAttribute('data-column') || '';
|
|
71
|
+
const rawLabel = section.getAttribute('data-column-label') || '';
|
|
72
|
+
const label = rawLabel || this.#titleCase(id);
|
|
73
|
+
const wipAttr = section.getAttribute('data-wip');
|
|
74
|
+
const wip = wipAttr ? parseInt(wipAttr, 10) : null;
|
|
75
|
+
const color = section.getAttribute('data-column-color') || null;
|
|
76
|
+
const children = /** @type {HTMLElement[]} */ ([...section.children]);
|
|
77
|
+
return { id, label, wip, color, children };
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// 2. Remove original sections (children already captured)
|
|
81
|
+
for (const section of sections) {
|
|
82
|
+
section.remove();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 3. Build columns container
|
|
86
|
+
this.#columnsEl = document.createElement('div');
|
|
87
|
+
this.#columnsEl.className = 'kb-columns';
|
|
88
|
+
this.#columnsEl.setAttribute('role', 'region');
|
|
89
|
+
this.#columnsEl.setAttribute('aria-label', 'Kanban board');
|
|
90
|
+
|
|
91
|
+
// 4. Build each column
|
|
92
|
+
let totalItems = 0;
|
|
93
|
+
|
|
94
|
+
for (const col of this.#columns) {
|
|
95
|
+
const column = document.createElement('section');
|
|
96
|
+
column.className = 'kb-column';
|
|
97
|
+
column.setAttribute('data-column-id', col.id);
|
|
98
|
+
if (col.color) column.setAttribute('data-column-tint', col.color);
|
|
99
|
+
|
|
100
|
+
// Header with count badge and optional WIP
|
|
101
|
+
const header = document.createElement('header');
|
|
102
|
+
header.className = 'kb-column-header';
|
|
103
|
+
|
|
104
|
+
const h3 = document.createElement('h3');
|
|
105
|
+
h3.textContent = col.label;
|
|
106
|
+
|
|
107
|
+
const countBadge = document.createElement('output');
|
|
108
|
+
countBadge.className = 'kb-count';
|
|
109
|
+
countBadge.textContent = String(col.children.length);
|
|
110
|
+
h3.appendChild(countBadge);
|
|
111
|
+
|
|
112
|
+
if (col.wip !== null && !isNaN(col.wip)) {
|
|
113
|
+
const wipBadge = document.createElement('span');
|
|
114
|
+
wipBadge.className = 'kb-wip';
|
|
115
|
+
wipBadge.textContent = `/ ${col.wip}`;
|
|
116
|
+
wipBadge.setAttribute('aria-label', `WIP limit ${col.wip}`);
|
|
117
|
+
h3.appendChild(wipBadge);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
header.appendChild(h3);
|
|
121
|
+
column.appendChild(header);
|
|
122
|
+
|
|
123
|
+
// Drag surface
|
|
124
|
+
const surface = document.createElement('drag-surface');
|
|
125
|
+
surface.setAttribute('group', this.#groupId);
|
|
126
|
+
surface.setAttribute('aria-label', `${col.label} items`);
|
|
127
|
+
surface.setAttribute('data-layout', 'stack');
|
|
128
|
+
surface.setAttribute('data-layout-gap', 'xs');
|
|
129
|
+
|
|
130
|
+
// Move captured children into the drag-surface
|
|
131
|
+
if (col.children.length > 0) {
|
|
132
|
+
for (const child of col.children) {
|
|
133
|
+
if (!child.hasAttribute('draggable')) {
|
|
134
|
+
child.setAttribute('draggable', 'true');
|
|
135
|
+
}
|
|
136
|
+
if (!child.hasAttribute('data-id')) {
|
|
137
|
+
child.dataset.id = `kb-item-${totalItems}`;
|
|
138
|
+
}
|
|
139
|
+
surface.appendChild(child);
|
|
140
|
+
}
|
|
141
|
+
totalItems += col.children.length;
|
|
142
|
+
} else {
|
|
143
|
+
const placeholder = document.createElement('p');
|
|
144
|
+
placeholder.className = 'kb-empty';
|
|
145
|
+
placeholder.textContent = 'No items';
|
|
146
|
+
surface.appendChild(placeholder);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
column.appendChild(surface);
|
|
150
|
+
this.#columnsEl.appendChild(column);
|
|
151
|
+
this.#surfaces[col.id] = surface;
|
|
152
|
+
this.#columnEls[col.id] = column;
|
|
153
|
+
|
|
154
|
+
// Check initial WIP state
|
|
155
|
+
if (col.wip !== null && col.children.length > col.wip) {
|
|
156
|
+
column.setAttribute('data-wip-exceeded', '');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 5. Assemble: title + columns, append to host
|
|
161
|
+
const title = this.getAttribute('title');
|
|
162
|
+
if (title) {
|
|
163
|
+
const heading = document.createElement('h2');
|
|
164
|
+
heading.className = 'kb-title';
|
|
165
|
+
heading.textContent = title;
|
|
166
|
+
this.appendChild(heading);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this.appendChild(this.#columnsEl);
|
|
170
|
+
|
|
171
|
+
// 6. Create live region
|
|
172
|
+
this.#liveRegion = document.createElement('div');
|
|
173
|
+
this.#liveRegion.className = 'kb-live-region';
|
|
174
|
+
this.#liveRegion.setAttribute('role', 'status');
|
|
175
|
+
this.#liveRegion.setAttribute('aria-live', 'polite');
|
|
176
|
+
this.#liveRegion.setAttribute('aria-atomic', 'true');
|
|
177
|
+
this.appendChild(this.#liveRegion);
|
|
178
|
+
|
|
179
|
+
// 7. Listen for drag-surface:reorder
|
|
180
|
+
this.listen(this, 'drag-surface:reorder', (e) => {
|
|
181
|
+
const detail = /** @type {CustomEvent} */ (e).detail;
|
|
182
|
+
const surface = /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('drag-surface'));
|
|
183
|
+
const columnId = this.#findColumnForSurface(surface);
|
|
184
|
+
if (!columnId) return;
|
|
185
|
+
|
|
186
|
+
this.#updateCount(columnId);
|
|
187
|
+
this.#syncItemsFromDom('drag');
|
|
188
|
+
|
|
189
|
+
this.dispatchEvent(new CustomEvent('kanban-board:reorder', {
|
|
190
|
+
bubbles: true,
|
|
191
|
+
detail: {
|
|
192
|
+
itemId: detail.itemId,
|
|
193
|
+
column: columnId,
|
|
194
|
+
oldIndex: detail.oldIndex,
|
|
195
|
+
newIndex: detail.newIndex,
|
|
196
|
+
},
|
|
197
|
+
}));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// 8. Listen for drag-surface:transfer
|
|
201
|
+
this.listen(this, 'drag-surface:transfer', (e) => {
|
|
202
|
+
const detail = /** @type {CustomEvent} */ (e).detail;
|
|
203
|
+
const fromSurface = detail.fromSurface;
|
|
204
|
+
const toSurface = detail.toSurface;
|
|
205
|
+
const fromColumn = this.#findColumnForSurface(fromSurface);
|
|
206
|
+
const toColumn = this.#findColumnForSurface(toSurface);
|
|
207
|
+
if (!fromColumn || !toColumn) return;
|
|
208
|
+
|
|
209
|
+
// Update data-column attribute on the transferred item
|
|
210
|
+
if (detail.item) {
|
|
211
|
+
detail.item.setAttribute('data-column', toColumn);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Update count badges and WIP state
|
|
215
|
+
this.#updateCount(fromColumn);
|
|
216
|
+
this.#updateCount(toColumn);
|
|
217
|
+
|
|
218
|
+
// Remove placeholder from receiving column if it had one
|
|
219
|
+
const toPlaceholder = toSurface.querySelector('.kb-empty');
|
|
220
|
+
if (toPlaceholder) toPlaceholder.remove();
|
|
221
|
+
|
|
222
|
+
// Add placeholder to sending column if now empty
|
|
223
|
+
const fromDraggables = fromSurface.querySelectorAll(':scope > [draggable="true"]');
|
|
224
|
+
if (fromDraggables.length === 0 && !fromSurface.querySelector('.kb-empty')) {
|
|
225
|
+
const placeholder = document.createElement('p');
|
|
226
|
+
placeholder.className = 'kb-empty';
|
|
227
|
+
placeholder.textContent = 'No items';
|
|
228
|
+
fromSurface.appendChild(placeholder);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Announce via live region
|
|
232
|
+
const itemLabel = this.#itemLabel(detail.item);
|
|
233
|
+
const fromLabel = this.#columnLabel(fromColumn);
|
|
234
|
+
const toLabel = this.#columnLabel(toColumn);
|
|
235
|
+
this.#announce(`${itemLabel} moved from ${fromLabel} to ${toLabel}`);
|
|
236
|
+
|
|
237
|
+
this.#syncItemsFromDom('drag');
|
|
238
|
+
|
|
239
|
+
this.dispatchEvent(new CustomEvent('kanban-board:transfer', {
|
|
240
|
+
bubbles: true,
|
|
241
|
+
detail: {
|
|
242
|
+
itemId: detail.itemId,
|
|
243
|
+
fromColumn,
|
|
244
|
+
toColumn,
|
|
245
|
+
newIndex: detail.newIndex,
|
|
246
|
+
item: detail.item,
|
|
247
|
+
},
|
|
248
|
+
}));
|
|
249
|
+
|
|
250
|
+
// Check WIP limit on receiving column
|
|
251
|
+
const toCount = toSurface.querySelectorAll(':scope > [draggable="true"]').length;
|
|
252
|
+
const toColMeta = this.#columns.find(c => c.id === toColumn);
|
|
253
|
+
if (toColMeta?.wip !== null && toColMeta?.wip !== undefined && toCount > toColMeta.wip) {
|
|
254
|
+
this.dispatchEvent(new CustomEvent('kanban-board:wip-exceeded', {
|
|
255
|
+
bubbles: true,
|
|
256
|
+
detail: {
|
|
257
|
+
column: toColumn,
|
|
258
|
+
limit: toColMeta.wip,
|
|
259
|
+
count: toCount,
|
|
260
|
+
},
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// 9. Seed VBCollection state from existing rendered children so future
|
|
266
|
+
// .items assignments diff against them instead of recreating.
|
|
267
|
+
this.#seedFromDom();
|
|
268
|
+
|
|
269
|
+
// 10. Handle src attribute for data mode
|
|
270
|
+
const src = this.getAttribute('src');
|
|
271
|
+
if (src) {
|
|
272
|
+
this._loadSrc(src);
|
|
273
|
+
return; // ready event fires after src loads
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 11. Dispatch ready event
|
|
277
|
+
this.dispatchEvent(new CustomEvent('kanban-board:ready', {
|
|
278
|
+
bubbles: true,
|
|
279
|
+
detail: {
|
|
280
|
+
columnCount: this.#columns.length,
|
|
281
|
+
itemCount: totalItems,
|
|
282
|
+
},
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Walk the rendered drag-surfaces and register each draggable child by
|
|
288
|
+
* its data-id so VBCollection's diff treats them as managed nodes.
|
|
289
|
+
*/
|
|
290
|
+
#seedFromDom() {
|
|
291
|
+
/** @type {any[]} */
|
|
292
|
+
const items = [];
|
|
293
|
+
/** @type {Map<unknown, Element>} */
|
|
294
|
+
const nodeMap = new Map();
|
|
295
|
+
for (const [columnId, surface] of Object.entries(this.#surfaces)) {
|
|
296
|
+
const draggables = surface.querySelectorAll(':scope > [draggable="true"]');
|
|
297
|
+
for (const el of draggables) {
|
|
298
|
+
const id = el.getAttribute('data-id');
|
|
299
|
+
if (!id) continue;
|
|
300
|
+
items.push({ id, column: columnId });
|
|
301
|
+
nodeMap.set(id, el);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
this._seedCollection(items, nodeMap);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Re-derive the internal items array from current DOM order (used after
|
|
309
|
+
* a drag mutates the DOM directly). Preserves consumer-supplied fields
|
|
310
|
+
* by carrying over each item's prior shape and just updating column.
|
|
311
|
+
* Emits `kanban-board:items-changed` with the given source.
|
|
312
|
+
*
|
|
313
|
+
* @param {'drag' | 'api' | 'attribute'} source
|
|
314
|
+
*/
|
|
315
|
+
#syncItemsFromDom(source) {
|
|
316
|
+
/** @type {any[]} */
|
|
317
|
+
const next = [];
|
|
318
|
+
/** @type {Map<string, any>} */
|
|
319
|
+
const previous = new Map();
|
|
320
|
+
for (const it of this.items) {
|
|
321
|
+
const k = String(it.id ?? it.storyId ?? '');
|
|
322
|
+
if (k) previous.set(k, it);
|
|
323
|
+
}
|
|
324
|
+
for (const [columnId, surface] of Object.entries(this.#surfaces)) {
|
|
325
|
+
const draggables = surface.querySelectorAll(':scope > [draggable="true"]');
|
|
326
|
+
for (const el of draggables) {
|
|
327
|
+
const id = el.getAttribute('data-id');
|
|
328
|
+
if (!id) continue;
|
|
329
|
+
const prior = previous.get(id);
|
|
330
|
+
next.push(prior ? { ...prior, column: columnId } : { id, column: columnId });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
this._setItemsSilently(next);
|
|
334
|
+
this._emit('items-changed', { items: next }, source);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
teardown() {
|
|
338
|
+
// Remove title if we created one
|
|
339
|
+
const title = this.querySelector('.kb-title');
|
|
340
|
+
if (title) title.remove();
|
|
341
|
+
if (this.#columnsEl) {
|
|
342
|
+
this.#columnsEl.remove();
|
|
343
|
+
this.#columnsEl = null;
|
|
344
|
+
}
|
|
345
|
+
if (this.#liveRegion) {
|
|
346
|
+
this.#liveRegion.remove();
|
|
347
|
+
this.#liveRegion = null;
|
|
348
|
+
}
|
|
349
|
+
this.#surfaces = {};
|
|
350
|
+
this.#columnEls = {};
|
|
351
|
+
this.#columns = [];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── Data API (HTML-first / JS-first dual contract) ──────────────
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The current column definitions. After upgrade this reflects the parsed
|
|
358
|
+
* `<section data-column>` children. Setting replaces all columns and
|
|
359
|
+
* rebuilds the shell — items already in nodes get re-routed to the new
|
|
360
|
+
* surfaces; orphaned items (column id no longer present) are dropped.
|
|
361
|
+
*/
|
|
362
|
+
get columns() {
|
|
363
|
+
return this.#columns.map(c => ({
|
|
364
|
+
id: c.id, label: c.label, wip: c.wip ?? undefined, color: c.color ?? undefined,
|
|
365
|
+
}));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
set columns(value) {
|
|
369
|
+
const next = (value || []).map(c => ({
|
|
370
|
+
id: c.id, label: c.label || this.#titleCase(c.id),
|
|
371
|
+
wip: c.wip ?? null, color: c.color ?? null, children: [],
|
|
372
|
+
}));
|
|
373
|
+
const oldShell = this.#columnsEl;
|
|
374
|
+
// Build the new shell off-DOM and update internal refs synchronously
|
|
375
|
+
// so a chained `.items = …` lookup sees the new surfaces immediately
|
|
376
|
+
// (the diff appends into the detached shell; both come together when
|
|
377
|
+
// the View Transition callback attaches the new shell to the host).
|
|
378
|
+
this.#surfaces = {};
|
|
379
|
+
this.#columnEls = {};
|
|
380
|
+
this.#columns = next;
|
|
381
|
+
const newShell = this.#assembleShell();
|
|
382
|
+
this.#columnsEl = newShell;
|
|
383
|
+
|
|
384
|
+
const swap = () => {
|
|
385
|
+
if (oldShell) oldShell.remove();
|
|
386
|
+
this.appendChild(newShell);
|
|
387
|
+
};
|
|
388
|
+
if (this.hasAttribute('data-upgraded') && oldShell) {
|
|
389
|
+
viewTransitionSwap(this, swap, 'kb-vt');
|
|
390
|
+
} else {
|
|
391
|
+
swap();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Optional custom item renderer. When set, replaces the default
|
|
397
|
+
* `<work-item>` factory used by the `.items` setter.
|
|
398
|
+
*/
|
|
399
|
+
get renderItem() { return this.#renderItem; }
|
|
400
|
+
set renderItem(fn) { this.#renderItem = typeof fn === 'function' ? fn : null; }
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* VBCollection hook: build a fresh element for a new key.
|
|
404
|
+
* Default: a `<work-item>` with the item's fields applied via .data.
|
|
405
|
+
* Always returns a draggable element with data-id set for drag-surface.
|
|
406
|
+
*/
|
|
407
|
+
_renderItem(item) {
|
|
408
|
+
let el;
|
|
409
|
+
if (this.#renderItem) {
|
|
410
|
+
const out = this.#renderItem(item);
|
|
411
|
+
if (!(out instanceof Element)) {
|
|
412
|
+
throw new Error('kanban-board: renderItem must return an Element');
|
|
413
|
+
}
|
|
414
|
+
el = out;
|
|
415
|
+
} else {
|
|
416
|
+
el = this.#defaultRenderItem(item);
|
|
417
|
+
}
|
|
418
|
+
if (!el.hasAttribute('draggable')) el.setAttribute('draggable', 'true');
|
|
419
|
+
if (!el.hasAttribute('data-id')) el.setAttribute('data-id', String(KanbanBoard.keyOf(item)));
|
|
420
|
+
return el;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* VBCollection hook: route this item to the drag-surface for its column.
|
|
425
|
+
* @param {any} item
|
|
426
|
+
* @param {any} _existing
|
|
427
|
+
* @returns {any}
|
|
428
|
+
*/
|
|
429
|
+
_containerFor(item, _existing) {
|
|
430
|
+
const surface = this.#surfaces[item.column];
|
|
431
|
+
if (!surface) {
|
|
432
|
+
throw new Error(`kanban-board: no column "${item.column}" — set .columns first or include item.column matching an existing column id`);
|
|
433
|
+
}
|
|
434
|
+
// Remove the empty placeholder before inserting managed children.
|
|
435
|
+
const placeholder = surface.querySelector(':scope > .kb-empty');
|
|
436
|
+
if (placeholder) placeholder.remove();
|
|
437
|
+
return surface;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* VBCollection hook: refresh count badges, WIP markers, and empty
|
|
442
|
+
* placeholders after every diff.
|
|
443
|
+
*/
|
|
444
|
+
_postRender() {
|
|
445
|
+
for (const id of Object.keys(this.#surfaces)) {
|
|
446
|
+
this.#updateCount(id);
|
|
447
|
+
const surface = this.#surfaces[id];
|
|
448
|
+
const draggables = surface.querySelectorAll(':scope > [draggable="true"]');
|
|
449
|
+
const placeholder = surface.querySelector(':scope > .kb-empty');
|
|
450
|
+
if (draggables.length === 0 && !placeholder) {
|
|
451
|
+
const p = document.createElement('p');
|
|
452
|
+
p.className = 'kb-empty';
|
|
453
|
+
p.textContent = 'No items';
|
|
454
|
+
surface.appendChild(p);
|
|
455
|
+
} else if (draggables.length > 0 && placeholder) {
|
|
456
|
+
placeholder.remove();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Default renderer: build a `<work-item>` and apply item fields via .data.
|
|
463
|
+
* Falls back to a plain `<article>` if work-item isn't registered.
|
|
464
|
+
*/
|
|
465
|
+
#defaultRenderItem(item) {
|
|
466
|
+
const tag = customElements.get('work-item') ? 'work-item' : 'article';
|
|
467
|
+
const el = /** @type {any} */ (document.createElement(tag));
|
|
468
|
+
if (tag === 'work-item') {
|
|
469
|
+
// Map the kanban item shape to work-item's data shape.
|
|
470
|
+
el.data = {
|
|
471
|
+
itemId: item.id ?? item.storyId,
|
|
472
|
+
type: item.type,
|
|
473
|
+
priority: item.priority,
|
|
474
|
+
status: item.status,
|
|
475
|
+
estimate: item.estimate != null ? String(item.estimate) : undefined,
|
|
476
|
+
assignee: item.assignee,
|
|
477
|
+
title: item.title,
|
|
478
|
+
description: item.description,
|
|
479
|
+
checklist: item.checklist,
|
|
480
|
+
notes: item.notes,
|
|
481
|
+
detail: item.detail,
|
|
482
|
+
};
|
|
483
|
+
} else {
|
|
484
|
+
el.className = 'kb-card';
|
|
485
|
+
el.textContent = item.title || item.label || item.id;
|
|
486
|
+
}
|
|
487
|
+
return el;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Build the kb-columns shell from the current `#columns` metas and
|
|
492
|
+
* populate `#surfaces` / `#columnEls`. Returns the detached shell so
|
|
493
|
+
* the caller decides when to attach it (sync, or inside a View
|
|
494
|
+
* Transition callback).
|
|
495
|
+
*/
|
|
496
|
+
#assembleShell() {
|
|
497
|
+
const shell = document.createElement('div');
|
|
498
|
+
shell.className = 'kb-columns';
|
|
499
|
+
shell.setAttribute('role', 'region');
|
|
500
|
+
shell.setAttribute('aria-label', 'Kanban board');
|
|
501
|
+
|
|
502
|
+
for (const col of this.#columns) {
|
|
503
|
+
const column = document.createElement('section');
|
|
504
|
+
column.className = 'kb-column';
|
|
505
|
+
column.setAttribute('data-column-id', col.id);
|
|
506
|
+
if (col.color) column.setAttribute('data-column-tint', col.color);
|
|
507
|
+
|
|
508
|
+
const header = document.createElement('header');
|
|
509
|
+
header.className = 'kb-column-header';
|
|
510
|
+
const h3 = document.createElement('h3');
|
|
511
|
+
h3.textContent = col.label;
|
|
512
|
+
const countBadge = document.createElement('output');
|
|
513
|
+
countBadge.className = 'kb-count';
|
|
514
|
+
countBadge.textContent = '0';
|
|
515
|
+
h3.appendChild(countBadge);
|
|
516
|
+
if (col.wip != null && !isNaN(col.wip)) {
|
|
517
|
+
const wipBadge = document.createElement('span');
|
|
518
|
+
wipBadge.className = 'kb-wip';
|
|
519
|
+
wipBadge.textContent = `/ ${col.wip}`;
|
|
520
|
+
wipBadge.setAttribute('aria-label', `WIP limit ${col.wip}`);
|
|
521
|
+
h3.appendChild(wipBadge);
|
|
522
|
+
}
|
|
523
|
+
header.appendChild(h3);
|
|
524
|
+
column.appendChild(header);
|
|
525
|
+
|
|
526
|
+
const surface = document.createElement('drag-surface');
|
|
527
|
+
surface.setAttribute('group', this.#groupId);
|
|
528
|
+
surface.setAttribute('aria-label', `${col.label} items`);
|
|
529
|
+
surface.setAttribute('data-layout', 'stack');
|
|
530
|
+
surface.setAttribute('data-layout-gap', 'xs');
|
|
531
|
+
const placeholder = document.createElement('p');
|
|
532
|
+
placeholder.className = 'kb-empty';
|
|
533
|
+
placeholder.textContent = 'No items';
|
|
534
|
+
surface.appendChild(placeholder);
|
|
535
|
+
|
|
536
|
+
column.appendChild(surface);
|
|
537
|
+
shell.appendChild(column);
|
|
538
|
+
this.#surfaces[col.id] = surface;
|
|
539
|
+
this.#columnEls[col.id] = column;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return shell;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ── Attribute changes ────────────────────────────────
|
|
546
|
+
|
|
547
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
548
|
+
if (oldValue === newValue || !this.isConnected) return;
|
|
549
|
+
if (name === 'src') this._loadSrc(newValue);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ── JSON loading ────────────────────────────────────
|
|
553
|
+
|
|
554
|
+
async _loadSrc(url) {
|
|
555
|
+
if (!url) return;
|
|
556
|
+
try {
|
|
557
|
+
const res = await fetch(url);
|
|
558
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
559
|
+
const data = await res.json();
|
|
560
|
+
|
|
561
|
+
// Clear existing children
|
|
562
|
+
while (this.firstChild) this.firstChild.remove();
|
|
563
|
+
|
|
564
|
+
// Create sections from columns
|
|
565
|
+
for (const col of (data.columns || [])) {
|
|
566
|
+
const section = document.createElement('section');
|
|
567
|
+
section.setAttribute('data-column', col.id);
|
|
568
|
+
if (col.label) section.setAttribute('data-column-label', col.label);
|
|
569
|
+
if (col.wip != null) section.setAttribute('data-wip', String(col.wip));
|
|
570
|
+
if (col.color) section.setAttribute('data-column-color', col.color);
|
|
571
|
+
|
|
572
|
+
for (const item of (col.items || [])) {
|
|
573
|
+
let el;
|
|
574
|
+
if (item.persona || item.action || item.storyId) {
|
|
575
|
+
el = document.createElement('user-story');
|
|
576
|
+
el.setAttribute('detail', item.detail || 'minimal');
|
|
577
|
+
if (item.storyId) el.setAttribute('story-id', item.storyId);
|
|
578
|
+
if (item.persona) el.setAttribute('persona', item.persona);
|
|
579
|
+
if (item.action) el.setAttribute('action', item.action);
|
|
580
|
+
if (item.benefit) el.setAttribute('benefit', item.benefit);
|
|
581
|
+
if (item.priority) el.setAttribute('priority', item.priority);
|
|
582
|
+
if (item.status) el.setAttribute('status', item.status);
|
|
583
|
+
if (item.points) el.setAttribute('points', String(item.points));
|
|
584
|
+
} else {
|
|
585
|
+
el = document.createElement('article');
|
|
586
|
+
el.className = 'kb-card';
|
|
587
|
+
el.textContent = item.text || item.label || '';
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
el.setAttribute('draggable', 'true');
|
|
591
|
+
el.dataset.id = item.id || item.storyId || '';
|
|
592
|
+
section.appendChild(el);
|
|
593
|
+
}
|
|
594
|
+
this.appendChild(section);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const swap = () => {
|
|
598
|
+
this.teardown();
|
|
599
|
+
this.removeAttribute('data-upgraded');
|
|
600
|
+
this.setup();
|
|
601
|
+
};
|
|
602
|
+
if (this.hasAttribute('data-upgraded') && this.#columnsEl) {
|
|
603
|
+
viewTransitionSwap(this, swap, 'kb-vt');
|
|
604
|
+
} else {
|
|
605
|
+
swap();
|
|
606
|
+
}
|
|
607
|
+
} catch (err) {
|
|
608
|
+
console.warn(`[kanban-board] Failed to load src="${url}":`, err);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ── Helpers ──────────────────────────────────────────
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Reverse-lookup: find the column id that owns a given drag-surface.
|
|
616
|
+
* @param {HTMLElement | null} surface
|
|
617
|
+
* @returns {string | null}
|
|
618
|
+
*/
|
|
619
|
+
#findColumnForSurface(surface) {
|
|
620
|
+
if (!surface) return null;
|
|
621
|
+
for (const [id, ref] of Object.entries(this.#surfaces)) {
|
|
622
|
+
if (ref === surface) return id;
|
|
623
|
+
}
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Update the count badge and WIP state for a given column.
|
|
629
|
+
* @param {string} columnId
|
|
630
|
+
*/
|
|
631
|
+
#updateCount(columnId) {
|
|
632
|
+
const surface = this.#surfaces[columnId];
|
|
633
|
+
const columnEl = this.#columnEls[columnId];
|
|
634
|
+
if (!surface || !columnEl) return;
|
|
635
|
+
|
|
636
|
+
const count = surface.querySelectorAll(':scope > [draggable="true"]').length;
|
|
637
|
+
const badge = columnEl.querySelector('.kb-count');
|
|
638
|
+
if (badge) badge.textContent = String(count);
|
|
639
|
+
|
|
640
|
+
// Update WIP state
|
|
641
|
+
const colMeta = this.#columns.find(c => c.id === columnId);
|
|
642
|
+
if (colMeta?.wip !== null && colMeta?.wip !== undefined) {
|
|
643
|
+
if (count > colMeta.wip) {
|
|
644
|
+
columnEl.setAttribute('data-wip-exceeded', '');
|
|
645
|
+
} else {
|
|
646
|
+
columnEl.removeAttribute('data-wip-exceeded');
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Get the display label for a column.
|
|
653
|
+
* @param {string} columnId
|
|
654
|
+
* @returns {string}
|
|
655
|
+
*/
|
|
656
|
+
#columnLabel(columnId) {
|
|
657
|
+
const col = this.#columns.find(c => c.id === columnId);
|
|
658
|
+
return col?.label || this.#titleCase(columnId);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Announce a message via the live region.
|
|
663
|
+
* @param {string} msg
|
|
664
|
+
*/
|
|
665
|
+
#announce(msg) {
|
|
666
|
+
if (!this.#liveRegion) return;
|
|
667
|
+
this.#liveRegion.textContent = '';
|
|
668
|
+
requestAnimationFrame(() => {
|
|
669
|
+
if (this.#liveRegion) this.#liveRegion.textContent = msg;
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Get a human-readable label for a board item.
|
|
675
|
+
* @param {HTMLElement} item
|
|
676
|
+
* @returns {string}
|
|
677
|
+
*/
|
|
678
|
+
#itemLabel(item) {
|
|
679
|
+
return item.getAttribute('story-id')
|
|
680
|
+
|| item.getAttribute('data-id')
|
|
681
|
+
|| item.textContent?.trim().slice(0, 40)
|
|
682
|
+
|| 'item';
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Convert a hyphenated or lowercase id to Title Case.
|
|
687
|
+
* @param {string} str
|
|
688
|
+
* @returns {string}
|
|
689
|
+
*/
|
|
690
|
+
#titleCase(str) {
|
|
691
|
+
return str
|
|
692
|
+
.replace(/[-_]/g, ' ')
|
|
693
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
registerComponent('kanban-board', /** @type {any} */ (KanbanBoard));
|
|
698
|
+
export { KanbanBoard };
|