@profpowell/vb-project-planning 0.2.1 → 0.2.3
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/README.md +10 -5
- package/dist/vb-project-planning.css +69 -3
- package/dist/vb-project-planning.js +3191 -2544
- package/dist/vb-project-planning.min.css +1 -1
- package/dist/vb-project-planning.min.js +221 -197
- package/package.json +7 -4
- package/src/lib/board-surface.js +158 -0
- package/src/lib/bundle-registry.js +2 -75
- package/src/lib/dates.js +125 -0
- package/src/lib/escape.js +34 -0
- package/src/lib/hash.js +16 -0
- package/src/lib/portrait-url.js +3 -29
- package/src/lib/src-loader.js +78 -0
- package/src/lib/vb-view-transition.js +7 -1
- package/src/utils/copy-init.js +56 -17
- package/src/web-components/_ux-base.js +409 -24
- package/src/web-components/adr-wc/README.md +18 -16
- package/src/web-components/adr-wc/api.json +7 -5
- package/src/web-components/adr-wc/logic.js +50 -136
- package/src/web-components/burndown-chart/api.json +8 -6
- package/src/web-components/burndown-chart/logic.js +75 -39
- package/src/web-components/empathy-map/api.json +13 -13
- package/src/web-components/empathy-map/logic.js +161 -168
- package/src/web-components/empathy-map/static.html +3 -3
- package/src/web-components/empathy-map/styles.js +1 -0
- package/src/web-components/gantt-chart/README.md +12 -6
- package/src/web-components/gantt-chart/api.json +23 -7
- package/src/web-components/gantt-chart/logic.js +456 -431
- package/src/web-components/gantt-chart/styles.css +14 -1
- package/src/web-components/impact-effort/api.json +9 -7
- package/src/web-components/impact-effort/logic.js +238 -209
- package/src/web-components/impact-effort/static.html +12 -4
- package/src/web-components/iron-triangle/_capacity.js +0 -18
- package/src/web-components/kanban-board/api.json +8 -6
- package/src/web-components/kanban-board/logic.js +289 -333
- package/src/web-components/product-roadmap/api.json +14 -13
- package/src/web-components/product-roadmap/logic.js +393 -246
- package/src/web-components/product-roadmap/styles.css +40 -0
- package/src/web-components/quadrant-grid/api.json +9 -4
- package/src/web-components/quadrant-grid/logic.js +80 -12
- package/src/web-components/quadrant-grid/styles.css +7 -0
- package/src/web-components/quality-target/_quality-utils.js +2 -46
- package/src/web-components/review-surface/README.md +9 -5
- package/src/web-components/review-surface/api.json +10 -8
- package/src/web-components/review-surface/logic.js +349 -218
- package/src/web-components/risk-register/api.json +1 -6
- package/src/web-components/risk-register/logic.js +71 -24
- package/src/web-components/risk-register/styles.css +9 -1
- package/src/web-components/story-map/api.json +8 -6
- package/src/web-components/story-map/logic.js +280 -281
- package/src/web-components/story-map/static.html +15 -5
- package/src/web-components/traceability-matrix/api.json +10 -3
- package/src/web-components/traceability-matrix/logic.js +112 -63
- package/src/web-components/traceability-matrix/styles.css +22 -1
- package/src/web-components/user-journey/api.json +25 -24
- package/src/web-components/user-journey/logic.js +76 -127
- package/src/web-components/user-journey/static.html +2 -2
- package/src/web-components/user-persona/api.json +15 -14
- package/src/web-components/user-persona/logic.js +71 -117
- package/src/web-components/user-persona/styles.js +1 -0
- package/src/web-components/user-story/api.json +13 -11
- package/src/web-components/user-story/logic.js +164 -195
- package/src/web-components/user-story/styles.js +28 -1
- package/src/web-components/work-item/README.md +3 -3
- package/src/web-components/work-item/api.json +13 -11
- package/src/web-components/work-item/logic.js +58 -154
|
@@ -18,8 +18,10 @@
|
|
|
18
18
|
*
|
|
19
19
|
* @fires kanban-board:transfer - Item moved between columns
|
|
20
20
|
* @fires kanban-board:reorder - Item reordered within a column
|
|
21
|
-
* @fires kanban-board:ready -
|
|
22
|
-
* @fires kanban-board:wip-exceeded - Fired when a column
|
|
21
|
+
* @fires kanban-board:ready - Once per element, after the first render (with src: after the JSON renders)
|
|
22
|
+
* @fires kanban-board:wip-exceeded - Fired when a drop takes a column over its WIP limit
|
|
23
|
+
* @fires kanban-board:items-changed - { items, source: 'api' | 'drag' | 'src' }
|
|
24
|
+
* @fires kanban-board:upgraded - Each time the board connects and upgrades
|
|
23
25
|
*
|
|
24
26
|
* @example
|
|
25
27
|
* <kanban-board>
|
|
@@ -36,6 +38,9 @@ import { registerComponent } from '../../lib/bundle-registry.js';
|
|
|
36
38
|
import { VBElement } from '../../lib/vb-element.js';
|
|
37
39
|
import { VBCollection } from '../../lib/vb-collection.js';
|
|
38
40
|
import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
|
|
41
|
+
import {
|
|
42
|
+
SrcLoader, announce, createLiveRegion, createStoryCard, isStoryRecord, itemLabel, syncPlaceholder, titleCase,
|
|
43
|
+
} from '../../lib/board-surface.js';
|
|
39
44
|
|
|
40
45
|
class KanbanBoard extends VBCollection(VBElement) {
|
|
41
46
|
static get observedAttributes() { return ['src', 'compact', 'title']; }
|
|
@@ -51,237 +56,174 @@ class KanbanBoard extends VBCollection(VBElement) {
|
|
|
51
56
|
#columnEls = {};
|
|
52
57
|
/** @type {HTMLDivElement | null} */
|
|
53
58
|
#liveRegion = null;
|
|
59
|
+
/** @type {HTMLHeadingElement | null} */
|
|
60
|
+
#titleEl = null;
|
|
54
61
|
/** @type {string} */
|
|
55
62
|
#groupId = '';
|
|
56
|
-
/** @type {Array<{ id: string, label: string, wip: number | null, color: string | null
|
|
63
|
+
/** @type {Array<{ id: string, label: string, wip: number | null, color: string | null }>} */
|
|
57
64
|
#columns = [];
|
|
58
65
|
/** @type {((item: any) => Element) | null} */
|
|
59
66
|
#renderItem = null;
|
|
67
|
+
/** Shell built (survives disconnect so a moved board keeps its cards). */
|
|
68
|
+
#built = false;
|
|
69
|
+
/** Listeners attached for the current connection. */
|
|
70
|
+
#wired = false;
|
|
71
|
+
#readyFired = false;
|
|
72
|
+
#loader = new SrcLoader();
|
|
60
73
|
|
|
61
74
|
setup() {
|
|
62
|
-
this.#
|
|
75
|
+
this.#ensureGroupId();
|
|
63
76
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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();
|
|
77
|
+
if (!this.#built) {
|
|
78
|
+
this.#built = true;
|
|
79
|
+
this.#buildFromSections();
|
|
83
80
|
}
|
|
84
81
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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);
|
|
82
|
+
if (!this.#wired) {
|
|
83
|
+
this.#wired = true;
|
|
84
|
+
this.listen(this, 'drag-surface:reorder', this.#onReorder);
|
|
85
|
+
this.listen(this, 'drag-surface:transfer', this.#onTransfer);
|
|
86
|
+
}
|
|
111
87
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
88
|
+
// Data mode: fetch once per src value; ready fires after it renders.
|
|
89
|
+
const src = this.getAttribute('src');
|
|
90
|
+
if (src) {
|
|
91
|
+
if (src !== this.#loader.url) this._loadSrc(src);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
119
94
|
|
|
120
|
-
|
|
121
|
-
|
|
95
|
+
if (!this.#readyFired) this.#dispatchReady();
|
|
96
|
+
}
|
|
122
97
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
98
|
+
/**
|
|
99
|
+
* First build: parse the authored `<section data-column>` children,
|
|
100
|
+
* move their cards into drag-surfaces, and add the title + live region.
|
|
101
|
+
*/
|
|
102
|
+
#buildFromSections() {
|
|
103
|
+
const sections = /** @type {HTMLElement[]} */ ([
|
|
104
|
+
...this.querySelectorAll(':scope > section[data-column]')
|
|
105
|
+
]);
|
|
106
|
+
const parsed = sections.map(section => this.#parseSection(section));
|
|
107
|
+
for (const section of sections) section.remove();
|
|
129
108
|
|
|
130
|
-
|
|
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
|
-
}
|
|
109
|
+
this.#syncTitle();
|
|
148
110
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
111
|
+
this.#liveRegion = createLiveRegion('kb-live-region');
|
|
112
|
+
this.#columns = parsed.map(p => p.meta);
|
|
113
|
+
this.#columnsEl = this.#assembleShell(parsed.map(p => p.children));
|
|
114
|
+
this.append(this.#columnsEl, this.#liveRegion);
|
|
115
|
+
this._postRender();
|
|
116
|
+
this.#seedFromDom();
|
|
117
|
+
}
|
|
153
118
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
119
|
+
/**
|
|
120
|
+
* @param {HTMLElement} section
|
|
121
|
+
* @returns {{ meta: { id: string, label: string, wip: number | null, color: string | null }, children: HTMLElement[] }}
|
|
122
|
+
*/
|
|
123
|
+
#parseSection(section) {
|
|
124
|
+
const id = section.getAttribute('data-column') || '';
|
|
125
|
+
const label = section.getAttribute('data-column-label') || titleCase(id);
|
|
126
|
+
const wipAttr = section.getAttribute('data-wip');
|
|
127
|
+
const wip = wipAttr ? parseInt(wipAttr, 10) : null;
|
|
128
|
+
const color = section.getAttribute('data-column-color') || null;
|
|
129
|
+
const children = /** @type {HTMLElement[]} */ ([...section.children]);
|
|
130
|
+
return { meta: { id, label, wip: wip !== null && isNaN(wip) ? null : wip, color }, children };
|
|
131
|
+
}
|
|
159
132
|
|
|
160
|
-
|
|
133
|
+
/** Create, update, or remove the optional heading from the title attribute. */
|
|
134
|
+
#syncTitle() {
|
|
161
135
|
const title = this.getAttribute('title');
|
|
162
136
|
if (title) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
137
|
+
if (!this.#titleEl) {
|
|
138
|
+
this.#titleEl = document.createElement('h2');
|
|
139
|
+
this.#titleEl.className = 'kb-title';
|
|
140
|
+
this.prepend(this.#titleEl);
|
|
141
|
+
}
|
|
142
|
+
this.#titleEl.textContent = title;
|
|
143
|
+
} else if (this.#titleEl) {
|
|
144
|
+
this.#titleEl.remove();
|
|
145
|
+
this.#titleEl = null;
|
|
167
146
|
}
|
|
147
|
+
this.#columnsEl?.setAttribute('aria-label', title || 'Kanban board');
|
|
148
|
+
}
|
|
168
149
|
|
|
169
|
-
|
|
150
|
+
#dispatchReady() {
|
|
151
|
+
this.#readyFired = true;
|
|
152
|
+
this.dispatchEvent(new CustomEvent('kanban-board:ready', {
|
|
153
|
+
bubbles: true,
|
|
154
|
+
detail: {
|
|
155
|
+
columnCount: this.#columns.length,
|
|
156
|
+
itemCount: this.items.length,
|
|
157
|
+
},
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
170
160
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
this.#
|
|
176
|
-
|
|
177
|
-
this.appendChild(this.#liveRegion);
|
|
161
|
+
/** @param {Event} e */
|
|
162
|
+
#onReorder = (e) => {
|
|
163
|
+
const detail = /** @type {CustomEvent} */ (e).detail;
|
|
164
|
+
const surface = /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('drag-surface'));
|
|
165
|
+
const columnId = this.#findColumnForSurface(surface);
|
|
166
|
+
if (!columnId) return;
|
|
178
167
|
|
|
179
|
-
|
|
180
|
-
this
|
|
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;
|
|
168
|
+
this.#updateCount(columnId);
|
|
169
|
+
this.#syncItemsFromDom('drag');
|
|
185
170
|
|
|
186
|
-
|
|
187
|
-
|
|
171
|
+
this.dispatchEvent(new CustomEvent('kanban-board:reorder', {
|
|
172
|
+
bubbles: true,
|
|
173
|
+
detail: {
|
|
174
|
+
itemId: detail.itemId,
|
|
175
|
+
column: columnId,
|
|
176
|
+
oldIndex: detail.oldIndex,
|
|
177
|
+
newIndex: detail.newIndex,
|
|
178
|
+
},
|
|
179
|
+
}));
|
|
180
|
+
};
|
|
188
181
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}));
|
|
198
|
-
});
|
|
182
|
+
/** @param {Event} e */
|
|
183
|
+
#onTransfer = (e) => {
|
|
184
|
+
const detail = /** @type {CustomEvent} */ (e).detail;
|
|
185
|
+
const fromSurface = detail.fromSurface;
|
|
186
|
+
const toSurface = detail.toSurface;
|
|
187
|
+
const fromColumn = this.#findColumnForSurface(fromSurface);
|
|
188
|
+
const toColumn = this.#findColumnForSurface(toSurface);
|
|
189
|
+
if (!fromColumn || !toColumn) return;
|
|
199
190
|
|
|
200
|
-
|
|
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
|
-
}
|
|
191
|
+
if (detail.item) detail.item.setAttribute('data-column', toColumn);
|
|
213
192
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
}
|
|
193
|
+
this.#updateCount(fromColumn);
|
|
194
|
+
this.#updateCount(toColumn);
|
|
195
|
+
syncPlaceholder(toSurface, 'kb-empty', 'No items');
|
|
196
|
+
syncPlaceholder(fromSurface, 'kb-empty', 'No items');
|
|
197
|
+
|
|
198
|
+
announce(this.#liveRegion,
|
|
199
|
+
`${itemLabel(detail.item)} moved from ${this.#columnLabel(fromColumn)} to ${this.#columnLabel(toColumn)}`);
|
|
230
200
|
|
|
231
|
-
|
|
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}`);
|
|
201
|
+
this.#syncItemsFromDom('drag');
|
|
236
202
|
|
|
237
|
-
|
|
203
|
+
this.dispatchEvent(new CustomEvent('kanban-board:transfer', {
|
|
204
|
+
bubbles: true,
|
|
205
|
+
detail: {
|
|
206
|
+
itemId: detail.itemId,
|
|
207
|
+
fromColumn,
|
|
208
|
+
toColumn,
|
|
209
|
+
newIndex: detail.newIndex,
|
|
210
|
+
item: detail.item,
|
|
211
|
+
},
|
|
212
|
+
}));
|
|
238
213
|
|
|
239
|
-
|
|
214
|
+
const toCount = toSurface.querySelectorAll(':scope > [draggable="true"]').length;
|
|
215
|
+
const toColMeta = this.#columns.find(c => c.id === toColumn);
|
|
216
|
+
if (toColMeta && toColMeta.wip != null && toCount > toColMeta.wip) {
|
|
217
|
+
this.dispatchEvent(new CustomEvent('kanban-board:wip-exceeded', {
|
|
240
218
|
bubbles: true,
|
|
241
219
|
detail: {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
newIndex: detail.newIndex,
|
|
246
|
-
item: detail.item,
|
|
220
|
+
column: toColumn,
|
|
221
|
+
limit: toColMeta.wip,
|
|
222
|
+
count: toCount,
|
|
247
223
|
},
|
|
248
224
|
}));
|
|
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
225
|
}
|
|
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
|
-
}
|
|
226
|
+
};
|
|
285
227
|
|
|
286
228
|
/**
|
|
287
229
|
* Walk the rendered drag-surfaces and register each draggable child by
|
|
@@ -335,20 +277,10 @@ class KanbanBoard extends VBCollection(VBElement) {
|
|
|
335
277
|
}
|
|
336
278
|
|
|
337
279
|
teardown() {
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 = [];
|
|
280
|
+
// Listeners are removed by VBElement. The built shell and every card
|
|
281
|
+
// stay in place so a board that is moved in the DOM keeps its content;
|
|
282
|
+
// the next connect only re-attaches listeners.
|
|
283
|
+
this.#wired = false;
|
|
352
284
|
}
|
|
353
285
|
|
|
354
286
|
// ── Data API (HTML-first / JS-first dual contract) ──────────────
|
|
@@ -356,8 +288,10 @@ class KanbanBoard extends VBCollection(VBElement) {
|
|
|
356
288
|
/**
|
|
357
289
|
* The current column definitions. After upgrade this reflects the parsed
|
|
358
290
|
* `<section data-column>` children. Setting replaces all columns and
|
|
359
|
-
* rebuilds the shell
|
|
360
|
-
*
|
|
291
|
+
* rebuilds the shell: existing cards whose `column` still exists are
|
|
292
|
+
* moved (same DOM nodes) into the new columns; cards whose column is
|
|
293
|
+
* gone are removed from the board and from `.items` (an
|
|
294
|
+
* `items-changed` event with source `api` reports the drop).
|
|
361
295
|
*/
|
|
362
296
|
get columns() {
|
|
363
297
|
return this.#columns.map(c => ({
|
|
@@ -367,23 +301,52 @@ class KanbanBoard extends VBCollection(VBElement) {
|
|
|
367
301
|
|
|
368
302
|
set columns(value) {
|
|
369
303
|
const next = (value || []).map(c => ({
|
|
370
|
-
id: c.id, label: c.label ||
|
|
371
|
-
wip: c.wip ?? null, color: c.color ?? null,
|
|
304
|
+
id: c.id, label: c.label || titleCase(c.id),
|
|
305
|
+
wip: c.wip ?? null, color: c.color ?? null,
|
|
372
306
|
}));
|
|
307
|
+
if (!this.#built) {
|
|
308
|
+
// JS-first: columns assigned before the board connected.
|
|
309
|
+
this.#built = true;
|
|
310
|
+
this.#liveRegion = createLiveRegion('kb-live-region');
|
|
311
|
+
this.appendChild(this.#liveRegion);
|
|
312
|
+
}
|
|
373
313
|
const oldShell = this.#columnsEl;
|
|
374
314
|
// Build the new shell off-DOM and update internal refs synchronously
|
|
375
|
-
// so a chained `.items = …`
|
|
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 = {};
|
|
315
|
+
// so a chained `.items = …` sees the new surfaces immediately.
|
|
380
316
|
this.#columns = next;
|
|
381
317
|
const newShell = this.#assembleShell();
|
|
382
318
|
this.#columnsEl = newShell;
|
|
319
|
+
this.#syncTitle();
|
|
320
|
+
|
|
321
|
+
// Re-route existing cards into the new surfaces (node identity kept).
|
|
322
|
+
const before = this.items;
|
|
323
|
+
/** @type {any[]} */
|
|
324
|
+
const kept = [];
|
|
325
|
+
/** @type {Map<unknown, Element>} */
|
|
326
|
+
const nodeMap = new Map();
|
|
327
|
+
for (const it of before) {
|
|
328
|
+
const key = KanbanBoard.keyOf(it);
|
|
329
|
+
const el = this._nodeFor(key);
|
|
330
|
+
const surface = this.#surfaces[it.column];
|
|
331
|
+
if (!el) continue;
|
|
332
|
+
if (surface) {
|
|
333
|
+
surface.appendChild(el);
|
|
334
|
+
kept.push(it);
|
|
335
|
+
nodeMap.set(key, el);
|
|
336
|
+
} else {
|
|
337
|
+
el.remove();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Keep .items in the new column order (matching the DOM).
|
|
341
|
+
const order = new Map(next.map((c, i) => [c.id, i]));
|
|
342
|
+
kept.sort((a, b) => (order.get(a.column) ?? 0) - (order.get(b.column) ?? 0));
|
|
343
|
+
this._seedCollection(kept, nodeMap);
|
|
344
|
+
this._postRender();
|
|
345
|
+
if (kept.length !== before.length) this._emit('items-changed', { items: kept }, 'api');
|
|
383
346
|
|
|
384
347
|
const swap = () => {
|
|
385
348
|
if (oldShell) oldShell.remove();
|
|
386
|
-
this
|
|
349
|
+
this.#attachShell(newShell);
|
|
387
350
|
};
|
|
388
351
|
if (this.hasAttribute('data-upgraded') && oldShell) {
|
|
389
352
|
viewTransitionSwap(this, swap, 'kb-vt');
|
|
@@ -442,19 +405,9 @@ class KanbanBoard extends VBCollection(VBElement) {
|
|
|
442
405
|
* placeholders after every diff.
|
|
443
406
|
*/
|
|
444
407
|
_postRender() {
|
|
445
|
-
for (const id of Object.
|
|
408
|
+
for (const [id, surface] of Object.entries(this.#surfaces)) {
|
|
446
409
|
this.#updateCount(id);
|
|
447
|
-
|
|
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
|
-
}
|
|
410
|
+
syncPlaceholder(surface, 'kb-empty', 'No items');
|
|
458
411
|
}
|
|
459
412
|
}
|
|
460
413
|
|
|
@@ -489,17 +442,27 @@ class KanbanBoard extends VBCollection(VBElement) {
|
|
|
489
442
|
|
|
490
443
|
/**
|
|
491
444
|
* Build the kb-columns shell from the current `#columns` metas and
|
|
492
|
-
* populate `#surfaces` / `#columnEls`.
|
|
493
|
-
*
|
|
494
|
-
*
|
|
445
|
+
* populate `#surfaces` / `#columnEls`. `children[i]` (optional) are the
|
|
446
|
+
* authored cards for column i; they are moved into its drag-surface.
|
|
447
|
+
* Returns the detached shell so the caller decides when to attach it
|
|
448
|
+
* (sync, or inside a View Transition callback).
|
|
449
|
+
*
|
|
450
|
+
* @param {HTMLElement[][]} [children]
|
|
451
|
+
* @returns {HTMLDivElement}
|
|
495
452
|
*/
|
|
496
|
-
#assembleShell() {
|
|
453
|
+
#assembleShell(children = []) {
|
|
454
|
+
// `.columns` may be set before the first connect (JS-first), so the
|
|
455
|
+
// drag-surface group id is assigned here, not only in setup().
|
|
456
|
+
this.#ensureGroupId();
|
|
457
|
+
this.#surfaces = {};
|
|
458
|
+
this.#columnEls = {};
|
|
497
459
|
const shell = document.createElement('div');
|
|
498
460
|
shell.className = 'kb-columns';
|
|
499
461
|
shell.setAttribute('role', 'region');
|
|
500
|
-
shell.setAttribute('aria-label', 'Kanban board');
|
|
462
|
+
shell.setAttribute('aria-label', this.getAttribute('title') || 'Kanban board');
|
|
501
463
|
|
|
502
|
-
|
|
464
|
+
let seq = 0;
|
|
465
|
+
this.#columns.forEach((col, i) => {
|
|
503
466
|
const column = document.createElement('section');
|
|
504
467
|
column.className = 'kb-column';
|
|
505
468
|
column.setAttribute('data-column-id', col.id);
|
|
@@ -513,7 +476,7 @@ class KanbanBoard extends VBCollection(VBElement) {
|
|
|
513
476
|
countBadge.className = 'kb-count';
|
|
514
477
|
countBadge.textContent = '0';
|
|
515
478
|
h3.appendChild(countBadge);
|
|
516
|
-
if (col.wip != null
|
|
479
|
+
if (col.wip != null) {
|
|
517
480
|
const wipBadge = document.createElement('span');
|
|
518
481
|
wipBadge.className = 'kb-wip';
|
|
519
482
|
wipBadge.textContent = `/ ${col.wip}`;
|
|
@@ -528,16 +491,18 @@ class KanbanBoard extends VBCollection(VBElement) {
|
|
|
528
491
|
surface.setAttribute('aria-label', `${col.label} items`);
|
|
529
492
|
surface.setAttribute('data-layout', 'stack');
|
|
530
493
|
surface.setAttribute('data-layout-gap', 'xs');
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
494
|
+
for (const child of children[i] || []) {
|
|
495
|
+
if (!child.hasAttribute('draggable')) child.setAttribute('draggable', 'true');
|
|
496
|
+
if (!child.hasAttribute('data-id')) child.dataset.id = `kb-item-${seq}`;
|
|
497
|
+
seq += 1;
|
|
498
|
+
surface.appendChild(child);
|
|
499
|
+
}
|
|
535
500
|
|
|
536
501
|
column.appendChild(surface);
|
|
537
502
|
shell.appendChild(column);
|
|
538
503
|
this.#surfaces[col.id] = surface;
|
|
539
504
|
this.#columnEls[col.id] = column;
|
|
540
|
-
}
|
|
505
|
+
});
|
|
541
506
|
|
|
542
507
|
return shell;
|
|
543
508
|
}
|
|
@@ -545,72 +510,98 @@ class KanbanBoard extends VBCollection(VBElement) {
|
|
|
545
510
|
// ── Attribute changes ────────────────────────────────
|
|
546
511
|
|
|
547
512
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
548
|
-
if (oldValue === newValue || !this
|
|
549
|
-
if (name === 'src') this._loadSrc(newValue);
|
|
513
|
+
if (oldValue === newValue || !this.#built) return;
|
|
514
|
+
if (name === 'src' && newValue && newValue !== this.#loader.url) this._loadSrc(newValue);
|
|
515
|
+
if (name === 'src' && !newValue) this.#loader.cancel(); // src removed: drop a pending load, keep the cards
|
|
516
|
+
if (name === 'title') this.#syncTitle();
|
|
550
517
|
}
|
|
551
518
|
|
|
552
519
|
// ── JSON loading ────────────────────────────────────
|
|
553
520
|
|
|
521
|
+
/**
|
|
522
|
+
* Fetch `{ columns: [{ id, label?, wip?, color?, items: [...] }] }` and
|
|
523
|
+
* replace the board with it. One request per src value; a newer src
|
|
524
|
+
* aborts an older in-flight one. Once the data has rendered, fires
|
|
525
|
+
* `kanban-board:items-changed` (source 'src'), and `kanban-board:ready`
|
|
526
|
+
* if it has not fired yet (ready is once per element).
|
|
527
|
+
* @param {string} url
|
|
528
|
+
*/
|
|
554
529
|
async _loadSrc(url) {
|
|
555
530
|
if (!url) return;
|
|
556
531
|
try {
|
|
557
|
-
const
|
|
558
|
-
if (!
|
|
559
|
-
const data =
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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();
|
|
532
|
+
const result = await this.#loader.fetchJson(url);
|
|
533
|
+
if (!result) return;
|
|
534
|
+
const { data, isCurrent } = result;
|
|
535
|
+
|
|
536
|
+
/** @type {HTMLElement[][]} */
|
|
537
|
+
const children = [];
|
|
538
|
+
const metas = (data.columns || []).map((/** @type {any} */ col) => {
|
|
539
|
+
children.push((col.items || []).map((/** @type {any} */ item) => this.#cardFromJson(item)));
|
|
540
|
+
const wip = col.wip != null ? parseInt(String(col.wip), 10) : null;
|
|
541
|
+
return {
|
|
542
|
+
id: String(col.id ?? ''),
|
|
543
|
+
label: col.label || titleCase(String(col.id ?? '')),
|
|
544
|
+
wip: wip !== null && isNaN(wip) ? null : wip,
|
|
545
|
+
color: col.color || null,
|
|
546
|
+
};
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const render = () => {
|
|
550
|
+
this.#columnsEl?.remove();
|
|
551
|
+
this.#columns = metas;
|
|
552
|
+
this.#columnsEl = this.#assembleShell(children);
|
|
553
|
+
this.#attachShell(this.#columnsEl);
|
|
554
|
+
this._postRender();
|
|
555
|
+
this.#seedFromDom();
|
|
601
556
|
};
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
}
|
|
557
|
+
// viewTransitionSwap runs render synchronously when it can't animate.
|
|
558
|
+
const tx = viewTransitionSwap(this, render, 'kb-vt');
|
|
559
|
+
if (tx) await tx.updateCallbackDone;
|
|
560
|
+
if (!isCurrent()) return;
|
|
561
|
+
this._emit('items-changed', { items: this.items }, 'src');
|
|
562
|
+
if (!this.#readyFired) this.#dispatchReady();
|
|
607
563
|
} catch (err) {
|
|
608
564
|
console.warn(`[kanban-board] Failed to load src="${url}":`, err);
|
|
609
565
|
}
|
|
610
566
|
}
|
|
611
567
|
|
|
568
|
+
/**
|
|
569
|
+
* JSON item → card element. Story-shaped records become a `<user-story>`
|
|
570
|
+
* with slotted persona/action/benefit/title; anything else a plain card.
|
|
571
|
+
* @param {Record<string, any>} item
|
|
572
|
+
* @returns {HTMLElement}
|
|
573
|
+
*/
|
|
574
|
+
#cardFromJson(item) {
|
|
575
|
+
let el;
|
|
576
|
+
if (isStoryRecord(item)) {
|
|
577
|
+
el = createStoryCard(item, 'minimal');
|
|
578
|
+
} else {
|
|
579
|
+
el = document.createElement('article');
|
|
580
|
+
el.className = 'kb-card';
|
|
581
|
+
el.textContent = item.text || item.label || item.title || '';
|
|
582
|
+
}
|
|
583
|
+
el.setAttribute('draggable', 'true');
|
|
584
|
+
const id = item.id || item.storyId;
|
|
585
|
+
if (id) el.dataset.id = String(id);
|
|
586
|
+
return el;
|
|
587
|
+
}
|
|
588
|
+
|
|
612
589
|
// ── Helpers ──────────────────────────────────────────
|
|
613
590
|
|
|
591
|
+
/** Assign this board's drag-surface group id once. */
|
|
592
|
+
#ensureGroupId() {
|
|
593
|
+
if (!this.#groupId) this.#groupId = `kb-${++KanbanBoard.#instanceCount}`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Insert the columns shell just before the live region (after the title).
|
|
598
|
+
* @param {HTMLElement} shell
|
|
599
|
+
*/
|
|
600
|
+
#attachShell(shell) {
|
|
601
|
+
const region = this.#liveRegion;
|
|
602
|
+
this.insertBefore(shell, region?.parentNode ? region : null);
|
|
603
|
+
}
|
|
604
|
+
|
|
614
605
|
/**
|
|
615
606
|
* Reverse-lookup: find the column id that owns a given drag-surface.
|
|
616
607
|
* @param {HTMLElement | null} surface
|
|
@@ -655,42 +646,7 @@ class KanbanBoard extends VBCollection(VBElement) {
|
|
|
655
646
|
*/
|
|
656
647
|
#columnLabel(columnId) {
|
|
657
648
|
const col = this.#columns.find(c => c.id === columnId);
|
|
658
|
-
return col?.label ||
|
|
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());
|
|
649
|
+
return col?.label || titleCase(columnId);
|
|
694
650
|
}
|
|
695
651
|
}
|
|
696
652
|
|