@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
|
@@ -107,6 +107,14 @@ gantt-chart:not([data-upgraded]) > table {
|
|
|
107
107
|
text-overflow: ellipsis;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
.gc-task-status {
|
|
111
|
+
flex: none;
|
|
112
|
+
margin-inline-start: auto;
|
|
113
|
+
font-size: 11px;
|
|
114
|
+
font-weight: 600;
|
|
115
|
+
color: var(--color-text-muted, #666666);
|
|
116
|
+
}
|
|
117
|
+
|
|
110
118
|
.gc-task-dates {
|
|
111
119
|
position: absolute;
|
|
112
120
|
width: 1px;
|
|
@@ -291,12 +299,16 @@ gantt-chart:not([data-upgraded]) > table {
|
|
|
291
299
|
fill: none;
|
|
292
300
|
stroke: var(--color-text-muted, #666666);
|
|
293
301
|
stroke-width: 1.5;
|
|
294
|
-
marker-end
|
|
302
|
+
/* marker-end is set per path to this chart's own marker id. */
|
|
295
303
|
}
|
|
296
304
|
|
|
297
305
|
/* ── Visually hidden (original table + live region) ── */
|
|
306
|
+
/* Visually hidden, still read by screen readers. table-layout: fixed is
|
|
307
|
+
required: a table ignores width: 1px and grows to its content, which made
|
|
308
|
+
the page scroll sideways on phones. */
|
|
298
309
|
.gc-sr-only {
|
|
299
310
|
position: absolute;
|
|
311
|
+
table-layout: fixed;
|
|
300
312
|
width: 1px;
|
|
301
313
|
height: 1px;
|
|
302
314
|
padding: 0;
|
|
@@ -369,6 +381,7 @@ gantt-chart[compact] .gc-group-spacer {
|
|
|
369
381
|
|
|
370
382
|
gantt-chart[data-upgraded] .gc-sr-only {
|
|
371
383
|
position: static;
|
|
384
|
+
table-layout: auto;
|
|
372
385
|
width: auto;
|
|
373
386
|
height: auto;
|
|
374
387
|
margin: 0;
|
|
@@ -5,17 +5,19 @@
|
|
|
5
5
|
"description": "2x2 prioritization matrix with drag-and-drop between quadrants (Quick Wins, Big Bets, Fill-Ins, Money Pit)",
|
|
6
6
|
"htmlvalidate": { "flow": true, "permittedContent": ["@flow"] },
|
|
7
7
|
"attributes": [
|
|
8
|
-
{ "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL to JSON
|
|
8
|
+
{ "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL to a JSON array of items ({ id, quadrant, label }). Fetched once per value; a new value replaces the items and aborts an older in-flight request" },
|
|
9
9
|
{ "name": "title", "kind": "host-api", "purpose": "config", "type": "string", "description": "Optional heading above the matrix" },
|
|
10
|
-
{ "name": "compact", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "
|
|
10
|
+
{ "name": "compact", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Reduces grid height, padding, and label sizes for denser layouts" }
|
|
11
11
|
],
|
|
12
12
|
"childAttributes": [
|
|
13
|
-
{ "name": "data-quadrant", "on": "any child", "type": "enum", "values": ["quick-wins", "big-bets", "fill-ins", "money-pit"], "description": "Target quadrant for this item" },
|
|
14
|
-
{ "name": "draggable", "on": "any child", "type": "boolean", "description": "Required for drag capability" },
|
|
15
|
-
{ "name": "data-id", "on": "any child", "type": "string", "description": "Stable identifier for the item" }
|
|
13
|
+
{ "name": "data-quadrant", "on": "any child", "type": "enum", "values": ["quick-wins", "big-bets", "fill-ins", "money-pit"], "description": "Target quadrant for this item (default quick-wins); kept up to date as the item is dragged" },
|
|
14
|
+
{ "name": "draggable", "on": "any child", "type": "boolean", "description": "Required for drag capability (added if missing)" },
|
|
15
|
+
{ "name": "data-id", "on": "any child", "type": "string", "description": "Stable identifier for the item, used as the .items key (generated if missing)" }
|
|
16
16
|
],
|
|
17
17
|
"events": [
|
|
18
|
-
{ "name": "impact-effort:move",
|
|
19
|
-
{ "name": "impact-effort:ready",
|
|
18
|
+
{ "name": "impact-effort:move", "detail": "{ itemId, from, to, item }", "description": "Fired when an item is dragged between quadrants; from and to are quadrant names" },
|
|
19
|
+
{ "name": "impact-effort:ready", "detail": "{ quadrantCounts }", "description": "Fired once after the first render, with the number of items per quadrant. With src, fired once the loaded cards are in the DOM (after the View Transition's update). Once per element: not re-fired by a later src change (watch items-changed with source 'src') or when the matrix is moved in the DOM" },
|
|
20
|
+
{ "name": "impact-effort:items-changed", "detail": "{ items, source: 'property' | 'src' }", "description": "Fired after each .items assignment (source 'property') and after each src load's cards are in the DOM (source 'src')" },
|
|
21
|
+
{ "name": "impact-effort:upgraded", "detail": "", "description": "Fired (in a microtask) each time the matrix connects and upgrades" }
|
|
20
22
|
]
|
|
21
23
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* registered by the main bundle or pack entry.
|
|
10
10
|
*
|
|
11
11
|
* @attr {string} src - URL to JSON data for items
|
|
12
|
+
* @attr {string} title - Optional heading above the matrix
|
|
12
13
|
* @attr {boolean} compact - Reduced spacing variant
|
|
13
14
|
*
|
|
14
15
|
* Children use:
|
|
@@ -17,13 +18,16 @@
|
|
|
17
18
|
* @attr {string} data-id - Stable identifier for the item
|
|
18
19
|
*
|
|
19
20
|
* @fires impact-effort:move - When an item is dragged between quadrants
|
|
20
|
-
* @fires impact-effort:ready -
|
|
21
|
+
* @fires impact-effort:ready - Once per element, after the first render (with src: after the loaded cards are in the DOM)
|
|
22
|
+
* @fires impact-effort:items-changed - { items, source: 'property' | 'src' } after .items is set or src loads
|
|
23
|
+
* @fires impact-effort:upgraded - Each time the matrix connects and upgrades
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
26
|
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
24
27
|
import { VBElement } from '../../lib/vb-element.js';
|
|
25
28
|
import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
|
|
26
29
|
import { diffByKey } from '../../lib/diff-by-key.js';
|
|
30
|
+
import { SrcLoader, announce, createLiveRegion, createStoryCard, isStoryRecord, itemLabel } from '../../lib/board-surface.js';
|
|
27
31
|
|
|
28
32
|
class ImpactEffort extends VBElement {
|
|
29
33
|
static QUADRANTS = ['quick-wins', 'big-bets', 'fill-ins', 'money-pit'];
|
|
@@ -36,10 +40,10 @@ class ImpactEffort extends VBElement {
|
|
|
36
40
|
};
|
|
37
41
|
|
|
38
42
|
static DESCRIPTIONS = {
|
|
39
|
-
'quick-wins': 'High impact
|
|
40
|
-
'big-bets': 'High impact
|
|
41
|
-
'fill-ins': 'Low impact
|
|
42
|
-
'money-pit': 'Low impact
|
|
43
|
+
'quick-wins': 'High impact · Low effort',
|
|
44
|
+
'big-bets': 'High impact · High effort',
|
|
45
|
+
'fill-ins': 'Low impact · Low effort',
|
|
46
|
+
'money-pit': 'Low impact · High effort',
|
|
43
47
|
};
|
|
44
48
|
|
|
45
49
|
static #instanceCount = 0;
|
|
@@ -48,20 +52,68 @@ class ImpactEffort extends VBElement {
|
|
|
48
52
|
return ['src', 'compact', 'title'];
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
/** @type {HTMLElement | null} */
|
|
52
|
-
#grid = null;
|
|
53
55
|
/** @type {Record<string, HTMLElement>} */
|
|
54
56
|
#surfaces = {};
|
|
55
57
|
/** @type {HTMLElement | null} */
|
|
56
58
|
#liveRegion = null;
|
|
59
|
+
/** @type {HTMLHeadingElement | null} */
|
|
60
|
+
#titleEl = null;
|
|
57
61
|
/** @type {string} */
|
|
58
62
|
#groupId = '';
|
|
59
|
-
/**
|
|
60
|
-
#
|
|
63
|
+
/** Shell built (survives disconnect so a moved matrix keeps its cards). */
|
|
64
|
+
#built = false;
|
|
65
|
+
/** Listeners attached for the current connection. */
|
|
66
|
+
#wired = false;
|
|
67
|
+
#readyFired = false;
|
|
68
|
+
#loader = new SrcLoader();
|
|
69
|
+
/** Items assigned before upgrade, applied on first setup. */
|
|
70
|
+
/** @type {any[] | null} */
|
|
71
|
+
#pending = null;
|
|
72
|
+
|
|
73
|
+
/** key → rendered card (authored, assigned, or loaded). */
|
|
74
|
+
/** @type {Map<unknown, Element>} */
|
|
75
|
+
#nodes = new Map();
|
|
76
|
+
/** key → last record assigned via `.items` or `src`. */
|
|
77
|
+
/** @type {Map<unknown, any>} */
|
|
78
|
+
#records = new Map();
|
|
61
79
|
|
|
62
80
|
setup() {
|
|
63
|
-
this.#groupId = `ie-${++ImpactEffort.#instanceCount}`;
|
|
81
|
+
if (!this.#groupId) this.#groupId = `ie-${++ImpactEffort.#instanceCount}`;
|
|
82
|
+
|
|
83
|
+
if (!this.#built) {
|
|
84
|
+
this.#built = true;
|
|
85
|
+
this.#build();
|
|
86
|
+
if (this.#pending) {
|
|
87
|
+
const pending = this.#pending;
|
|
88
|
+
this.#pending = null;
|
|
89
|
+
this.items = pending;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
64
92
|
|
|
93
|
+
if (!this.#wired) {
|
|
94
|
+
this.#wired = true;
|
|
95
|
+
this.listen(this, 'drag-surface:transfer', this.#onTransfer);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Data mode: fetch once per src value; ready fires after it renders.
|
|
99
|
+
const src = this.getAttribute('src');
|
|
100
|
+
if (src) {
|
|
101
|
+
if (src !== this.#loader.url) this.#loadFromSrc(src);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!this.#readyFired) this.#dispatchReady();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
teardown() {
|
|
109
|
+
// Listeners are removed by VBElement. The grid, title and cards stay
|
|
110
|
+
// in place so a matrix that is moved in the DOM keeps its content;
|
|
111
|
+
// the next connect only re-attaches listeners.
|
|
112
|
+
this.#wired = false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** First build: wrapper, quadrants, live region; authored cards moved in. */
|
|
116
|
+
#build() {
|
|
65
117
|
// 1. Snapshot children that belong in quadrants
|
|
66
118
|
const children = [...this.querySelectorAll(':scope > [data-quadrant], :scope > [draggable]')];
|
|
67
119
|
|
|
@@ -72,7 +124,7 @@ class ImpactEffort extends VBElement {
|
|
|
72
124
|
const yLabel = document.createElement('div');
|
|
73
125
|
yLabel.className = 'ie-y-label';
|
|
74
126
|
yLabel.setAttribute('aria-hidden', 'true');
|
|
75
|
-
yLabel.textContent = 'Impact
|
|
127
|
+
yLabel.textContent = 'Impact ↑';
|
|
76
128
|
|
|
77
129
|
const column = document.createElement('div');
|
|
78
130
|
column.style.cssText = 'display:flex;flex-direction:column;flex:1;min-width:0;';
|
|
@@ -85,7 +137,7 @@ class ImpactEffort extends VBElement {
|
|
|
85
137
|
const xLabel = document.createElement('div');
|
|
86
138
|
xLabel.className = 'ie-x-label';
|
|
87
139
|
xLabel.setAttribute('aria-hidden', 'true');
|
|
88
|
-
xLabel.textContent = 'Effort
|
|
140
|
+
xLabel.textContent = 'Effort →';
|
|
89
141
|
|
|
90
142
|
// 3. Build quadrant sections
|
|
91
143
|
for (const q of ImpactEffort.QUADRANTS) {
|
|
@@ -96,8 +148,10 @@ class ImpactEffort extends VBElement {
|
|
|
96
148
|
|
|
97
149
|
const header = document.createElement('header');
|
|
98
150
|
header.className = 'ie-quadrant-label';
|
|
99
|
-
|
|
100
|
-
|
|
151
|
+
const desc = document.createElement('span');
|
|
152
|
+
desc.className = 'ie-quadrant-desc';
|
|
153
|
+
desc.textContent = ImpactEffort.DESCRIPTIONS[q];
|
|
154
|
+
header.append(ImpactEffort.LABELS[q], document.createElement('br'), desc);
|
|
101
155
|
|
|
102
156
|
const surface = document.createElement('drag-surface');
|
|
103
157
|
surface.setAttribute('group', this.#groupId);
|
|
@@ -112,124 +166,88 @@ class ImpactEffort extends VBElement {
|
|
|
112
166
|
this.#surfaces[q] = surface;
|
|
113
167
|
}
|
|
114
168
|
|
|
115
|
-
// 4. Distribute captured children into matching surfaces
|
|
169
|
+
// 4. Distribute captured children into matching surfaces and register
|
|
170
|
+
// them so a later `.items` assignment diffs against them.
|
|
116
171
|
children.forEach((c, i) => {
|
|
117
172
|
const child = /** @type {HTMLElement} */ (c);
|
|
118
|
-
const quadrant = child.getAttribute('data-quadrant') || 'quick-wins';
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
if (!child.hasAttribute('data-id')) {
|
|
126
|
-
child.dataset.id = `ie-item-${i}`;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
target.appendChild(child);
|
|
173
|
+
const quadrant = this.#surfaces[child.getAttribute('data-quadrant') || ''] ? child.getAttribute('data-quadrant') : 'quick-wins';
|
|
174
|
+
child.setAttribute('data-quadrant', /** @type {string} */ (quadrant));
|
|
175
|
+
if (!child.hasAttribute('draggable')) child.setAttribute('draggable', 'true');
|
|
176
|
+
if (!child.hasAttribute('data-id')) child.dataset.id = `ie-item-${i}`;
|
|
177
|
+
this.#surfaces[/** @type {string} */ (quadrant)].appendChild(child);
|
|
178
|
+
this.#nodes.set(child.dataset.id, child);
|
|
130
179
|
});
|
|
131
180
|
|
|
132
|
-
// 5.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
liveRegion.setAttribute('role', 'status');
|
|
136
|
-
liveRegion.setAttribute('aria-live', 'polite');
|
|
137
|
-
liveRegion.setAttribute('aria-atomic', 'true');
|
|
138
|
-
|
|
139
|
-
// 6. Assemble DOM
|
|
140
|
-
const title = this.getAttribute('title');
|
|
141
|
-
if (title) {
|
|
142
|
-
const heading = document.createElement('h3');
|
|
143
|
-
heading.className = 'ie-title';
|
|
144
|
-
heading.textContent = title;
|
|
145
|
-
this.prepend(heading);
|
|
146
|
-
}
|
|
147
|
-
|
|
181
|
+
// 5. Assemble DOM
|
|
182
|
+
this.#liveRegion = createLiveRegion('ie-live-region');
|
|
183
|
+
this.#syncTitle();
|
|
148
184
|
column.appendChild(grid);
|
|
149
185
|
column.appendChild(xLabel);
|
|
150
186
|
wrapper.appendChild(yLabel);
|
|
151
187
|
wrapper.appendChild(column);
|
|
152
|
-
this.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
this.#grid = grid;
|
|
156
|
-
this.#liveRegion = liveRegion;
|
|
157
|
-
this.#wrapper = wrapper;
|
|
158
|
-
|
|
159
|
-
// 7. Listen for cross-surface transfers
|
|
160
|
-
this.listen(this, 'drag-surface:transfer', (/** @type {CustomEvent} */ e) => {
|
|
161
|
-
const { item, fromSurface, toSurface } = e.detail;
|
|
162
|
-
const fromQuadrant = this.#findQuadrantForSurface(fromSurface);
|
|
163
|
-
const toQuadrant = this.#findQuadrantForSurface(toSurface);
|
|
164
|
-
|
|
165
|
-
if (!fromQuadrant || !toQuadrant) return;
|
|
188
|
+
this.append(wrapper, this.#liveRegion);
|
|
189
|
+
}
|
|
166
190
|
|
|
167
|
-
|
|
168
|
-
|
|
191
|
+
/** Create, update, or remove the optional heading from the title attribute. */
|
|
192
|
+
#syncTitle() {
|
|
193
|
+
const title = this.getAttribute('title');
|
|
194
|
+
if (title) {
|
|
195
|
+
if (!this.#titleEl) {
|
|
196
|
+
this.#titleEl = document.createElement('h3');
|
|
197
|
+
this.#titleEl.className = 'ie-title';
|
|
198
|
+
this.prepend(this.#titleEl);
|
|
199
|
+
}
|
|
200
|
+
this.#titleEl.textContent = title;
|
|
201
|
+
} else if (this.#titleEl) {
|
|
202
|
+
this.#titleEl.remove();
|
|
203
|
+
this.#titleEl = null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
169
206
|
|
|
170
|
-
|
|
171
|
-
|
|
207
|
+
/** @param {Event} e */
|
|
208
|
+
#onTransfer = (e) => {
|
|
209
|
+
const { item, fromSurface, toSurface } = /** @type {CustomEvent} */ (e).detail;
|
|
210
|
+
const fromQuadrant = this.#findQuadrantForSurface(fromSurface);
|
|
211
|
+
const toQuadrant = this.#findQuadrantForSurface(toSurface);
|
|
172
212
|
|
|
173
|
-
|
|
174
|
-
this.dispatchEvent(new CustomEvent('impact-effort:move', {
|
|
175
|
-
bubbles: true,
|
|
176
|
-
detail: {
|
|
177
|
-
itemId: item.dataset.id,
|
|
178
|
-
from: fromQuadrant,
|
|
179
|
-
to: toQuadrant,
|
|
180
|
-
item,
|
|
181
|
-
},
|
|
182
|
-
}));
|
|
183
|
-
});
|
|
213
|
+
if (!fromQuadrant || !toQuadrant) return;
|
|
184
214
|
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
if (src) {
|
|
188
|
-
this.#loadFromSrc(src);
|
|
189
|
-
}
|
|
215
|
+
// Update the item's quadrant attribute
|
|
216
|
+
item.setAttribute('data-quadrant', toQuadrant);
|
|
190
217
|
|
|
191
|
-
|
|
192
|
-
this.#dispatchReady();
|
|
193
|
-
}
|
|
218
|
+
announce(this.#liveRegion, `Moved ${itemLabel(item)} to ${ImpactEffort.LABELS[toQuadrant]}`);
|
|
194
219
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
this.#surfaces = {};
|
|
206
|
-
this.#nodes.clear();
|
|
207
|
-
this.#items = [];
|
|
208
|
-
}
|
|
220
|
+
this.dispatchEvent(new CustomEvent('impact-effort:move', {
|
|
221
|
+
bubbles: true,
|
|
222
|
+
detail: {
|
|
223
|
+
itemId: item.dataset.id,
|
|
224
|
+
from: fromQuadrant,
|
|
225
|
+
to: toQuadrant,
|
|
226
|
+
item,
|
|
227
|
+
},
|
|
228
|
+
}));
|
|
229
|
+
};
|
|
209
230
|
|
|
210
231
|
// ── Data API (HTML-first / JS-first dual contract) ──────────────
|
|
211
232
|
|
|
212
|
-
/** @type {any[]} */
|
|
213
|
-
#items = [];
|
|
214
|
-
/** @type {Map<unknown, Element>} */
|
|
215
|
-
#nodes = new Map();
|
|
216
|
-
|
|
217
233
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
234
|
+
* The matrix items in DOM order, reflecting drags. Each entry is the
|
|
235
|
+
* record last assigned for that id (via `.items` or `src`) with its
|
|
236
|
+
* current `quadrant`; authored cards read as `{ id, quadrant, label }`.
|
|
237
|
+
* Assigning the result back to `.items` keeps every card in place.
|
|
222
238
|
*/
|
|
223
239
|
get items() {
|
|
224
|
-
if (this.#items.length) return this.#items;
|
|
225
240
|
const result = [];
|
|
226
241
|
for (const [q, surface] of Object.entries(this.#surfaces)) {
|
|
227
242
|
for (const el of surface.querySelectorAll(':scope > [draggable="true"]')) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
243
|
+
const id = el.getAttribute('data-id') || undefined;
|
|
244
|
+
const key = this.#keyForNode(el);
|
|
245
|
+
const known = key !== undefined ? this.#records.get(key) : undefined;
|
|
246
|
+
if (known) {
|
|
247
|
+
result.push({ ...known, quadrant: q });
|
|
248
|
+
} else {
|
|
249
|
+
result.push({ id, quadrant: q, label: itemLabel(el) });
|
|
250
|
+
}
|
|
233
251
|
}
|
|
234
252
|
}
|
|
235
253
|
return result;
|
|
@@ -237,111 +255,136 @@ class ImpactEffort extends VBElement {
|
|
|
237
255
|
|
|
238
256
|
/**
|
|
239
257
|
* Replace the matrix items and route each into its quadrant surface.
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
258
|
+
* Each item: `{ id, quadrant, label }` (`text` is accepted as an alias of
|
|
259
|
+
* `label`; story fields — persona, action, benefit, storyId — render a
|
|
260
|
+
* `<user-story>` card). Runs a keyed diff by `id`, so cards whose id
|
|
261
|
+
* persists (including authored ones) are moved, not recreated. Items
|
|
262
|
+
* without an `id` are keyed by their label (the nth repeat of a label
|
|
263
|
+
* keeps the nth such card), so they also survive drags and round-trips;
|
|
264
|
+
* give items an `id` if their labels change. New ids
|
|
265
|
+
* render as `<article class="ie-card">` by default; override via
|
|
243
266
|
* `.renderItem`.
|
|
244
267
|
*
|
|
245
268
|
* Emits impact-effort:items-changed { items, source: 'property' }.
|
|
246
269
|
*/
|
|
247
270
|
set items(value) {
|
|
248
|
-
if (!this.#grid) return;
|
|
249
271
|
const next = Array.isArray(value) ? value : [];
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const self = /** @type {any} */ (this);
|
|
256
|
-
if (typeof self.renderItem === 'function') {
|
|
257
|
-
const out = self.renderItem(it);
|
|
258
|
-
if (out instanceof Element) {
|
|
259
|
-
if (!out.hasAttribute('draggable')) out.setAttribute('draggable', 'true');
|
|
260
|
-
if (!out.hasAttribute('data-id')) out.setAttribute('data-id', String(it.id ?? ''));
|
|
261
|
-
return out;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
const article = document.createElement('article');
|
|
265
|
-
article.className = 'ie-card';
|
|
266
|
-
article.setAttribute('draggable', 'true');
|
|
267
|
-
if (it.id) article.setAttribute('data-id', String(it.id));
|
|
268
|
-
article.textContent = it.text || it.id || '';
|
|
269
|
-
return article;
|
|
270
|
-
},
|
|
271
|
-
containerFor: (it) => this.#surfaces[it.quadrant] || this.#surfaces['quick-wins'],
|
|
272
|
-
});
|
|
273
|
-
this.#items = next;
|
|
272
|
+
if (!this.#built) {
|
|
273
|
+
this.#pending = next;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
this.#applyItems(next);
|
|
274
277
|
this.dispatchEvent(new CustomEvent('impact-effort:items-changed', {
|
|
275
278
|
detail: { items: next, source: 'property' },
|
|
276
279
|
bubbles: true,
|
|
277
280
|
}));
|
|
278
281
|
}
|
|
279
282
|
|
|
280
|
-
|
|
281
|
-
|
|
283
|
+
/**
|
|
284
|
+
* Diff keys for a list of items: the `id` when present; otherwise the
|
|
285
|
+
* label (`label` or `text`), with a counter for repeats, so the nth
|
|
286
|
+
* id-less item with a given label keeps the nth such card. The quadrant
|
|
287
|
+
* is not part of the key, so a dragged id-less card survives an
|
|
288
|
+
* `.items` round-trip.
|
|
289
|
+
* @param {any[]} list
|
|
290
|
+
* @returns {Map<any, unknown>}
|
|
291
|
+
*/
|
|
292
|
+
static #keysFor(list) {
|
|
293
|
+
/** @type {Map<string, number>} */
|
|
294
|
+
const seen = new Map();
|
|
295
|
+
/** @type {Map<any, unknown>} */
|
|
296
|
+
const keys = new Map();
|
|
297
|
+
for (const it of list) {
|
|
298
|
+
if (it.id != null) { keys.set(it, it.id); continue; }
|
|
299
|
+
const label = String(it.label ?? it.text ?? '');
|
|
300
|
+
const n = seen.get(label) ?? 0;
|
|
301
|
+
seen.set(label, n + 1);
|
|
302
|
+
keys.set(it, n ? `label:${label}#${n}` : `label:${label}`);
|
|
303
|
+
}
|
|
304
|
+
return keys;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** @param {Element} el */
|
|
308
|
+
#keyForNode(el) {
|
|
309
|
+
for (const [key, node] of this.#nodes) if (node === el) return key;
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
282
312
|
|
|
283
|
-
|
|
284
|
-
|
|
313
|
+
/** @param {any[]} next */
|
|
314
|
+
#applyItems(next) {
|
|
315
|
+
const keys = ImpactEffort.#keysFor(next);
|
|
316
|
+
diffByKey({
|
|
317
|
+
newItems: next,
|
|
318
|
+
nodes: this.#nodes,
|
|
319
|
+
keyOf: (it) => keys.get(it),
|
|
320
|
+
renderItem: (it) => this.#renderCard(it),
|
|
321
|
+
containerFor: (it) => this.#surfaces[it.quadrant] || this.#surfaces['quick-wins'],
|
|
322
|
+
});
|
|
323
|
+
this.#records.clear();
|
|
324
|
+
for (const it of next) {
|
|
325
|
+
const key = keys.get(it);
|
|
326
|
+
this.#records.set(key, it);
|
|
327
|
+
const el = this.#nodes.get(key);
|
|
328
|
+
el?.setAttribute('data-quadrant', this.#surfaces[it.quadrant] ? it.quadrant : 'quick-wins');
|
|
285
329
|
}
|
|
286
330
|
}
|
|
287
331
|
|
|
332
|
+
/**
|
|
333
|
+
* @param {any} it
|
|
334
|
+
* @returns {Element}
|
|
335
|
+
*/
|
|
336
|
+
#renderCard(it) {
|
|
337
|
+
const self = /** @type {any} */ (this);
|
|
338
|
+
let el;
|
|
339
|
+
if (typeof self.renderItem === 'function') {
|
|
340
|
+
const out = self.renderItem(it);
|
|
341
|
+
if (out instanceof Element) el = out;
|
|
342
|
+
}
|
|
343
|
+
if (!el && isStoryRecord(it)) el = createStoryCard(it, 'minimal');
|
|
344
|
+
if (!el) {
|
|
345
|
+
el = document.createElement('article');
|
|
346
|
+
el.className = 'ie-card';
|
|
347
|
+
el.textContent = it.label ?? it.text ?? it.id ?? '';
|
|
348
|
+
}
|
|
349
|
+
if (!el.hasAttribute('draggable')) el.setAttribute('draggable', 'true');
|
|
350
|
+
if (it.id != null && !el.hasAttribute('data-id')) el.setAttribute('data-id', String(it.id));
|
|
351
|
+
return el;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
attributeChangedCallback(name, oldVal, newVal) {
|
|
355
|
+
if (oldVal === newVal || !this.#built) return;
|
|
356
|
+
if (name === 'src' && newVal && newVal !== this.#loader.url) this.#loadFromSrc(newVal);
|
|
357
|
+
if (name === 'src' && !newVal) this.#loader.cancel(); // src removed: drop a pending load, keep the cards
|
|
358
|
+
if (name === 'title') this.#syncTitle();
|
|
359
|
+
}
|
|
360
|
+
|
|
288
361
|
// ── Data loading ──────────────────────────────────────────────────────────
|
|
289
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Fetch an array of items (same shape as `.items`) and replace the
|
|
365
|
+
* matrix with it. One request per src value; a newer src aborts an
|
|
366
|
+
* older in-flight one. Once the cards are in the DOM (after the View
|
|
367
|
+
* Transition's update callback) `impact-effort:items-changed` fires with
|
|
368
|
+
* source 'src', and `impact-effort:ready` if it has not fired yet.
|
|
369
|
+
* @param {string} url
|
|
370
|
+
*/
|
|
290
371
|
async #loadFromSrc(url) {
|
|
291
372
|
try {
|
|
292
|
-
const
|
|
293
|
-
if (!
|
|
294
|
-
const data =
|
|
295
|
-
|
|
373
|
+
const result = await this.#loader.fetchJson(url);
|
|
374
|
+
if (!result) return;
|
|
375
|
+
const { data, isCurrent } = result;
|
|
296
376
|
if (!Array.isArray(data)) return;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
// Create and distribute items — use <user-story> when story data present
|
|
309
|
-
data.forEach((entry, i) => {
|
|
310
|
-
const quadrant = entry.quadrant || 'quick-wins';
|
|
311
|
-
const id = entry.id || `ie-item-${i}`;
|
|
312
|
-
let el;
|
|
313
|
-
|
|
314
|
-
if (entry.persona || entry.action || entry.storyId) {
|
|
315
|
-
el = document.createElement('user-story');
|
|
316
|
-
el.setAttribute('detail', 'minimal');
|
|
317
|
-
if (entry.storyId) el.setAttribute('story-id', entry.storyId);
|
|
318
|
-
if (entry.persona) el.setAttribute('persona', entry.persona);
|
|
319
|
-
if (entry.action) el.setAttribute('action', entry.action);
|
|
320
|
-
if (entry.benefit) el.setAttribute('benefit', entry.benefit);
|
|
321
|
-
if (entry.priority) el.setAttribute('priority', entry.priority);
|
|
322
|
-
if (entry.status) el.setAttribute('status', entry.status);
|
|
323
|
-
if (entry.points) el.setAttribute('points', String(entry.points));
|
|
324
|
-
} else {
|
|
325
|
-
el = document.createElement('article');
|
|
326
|
-
el.textContent = entry.label || entry.text || '';
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
el.setAttribute('draggable', 'true');
|
|
330
|
-
el.dataset.id = id;
|
|
331
|
-
el.dataset.quadrant = quadrant;
|
|
332
|
-
|
|
333
|
-
const target = this.#surfaces[quadrant] || this.#surfaces['quick-wins'];
|
|
334
|
-
target.appendChild(el);
|
|
335
|
-
});
|
|
336
|
-
};
|
|
337
|
-
|
|
338
|
-
if (this.hasAttribute('data-upgraded') && this.#grid) {
|
|
339
|
-
viewTransitionSwap(this, swap, 'ie-vt');
|
|
340
|
-
} else {
|
|
341
|
-
swap();
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
this.#dispatchReady();
|
|
377
|
+
const entries = data.map((entry, i) => ({ ...entry, id: entry.id ?? entry.storyId ?? `ie-item-${i}` }));
|
|
378
|
+
|
|
379
|
+
// viewTransitionSwap runs the swap synchronously when it can't animate.
|
|
380
|
+
const tx = viewTransitionSwap(this, () => this.#applyItems(entries), 'ie-vt');
|
|
381
|
+
if (tx) await tx.updateCallbackDone;
|
|
382
|
+
if (!isCurrent()) return;
|
|
383
|
+
this.dispatchEvent(new CustomEvent('impact-effort:items-changed', {
|
|
384
|
+
detail: { items: this.items, source: 'src' },
|
|
385
|
+
bubbles: true,
|
|
386
|
+
}));
|
|
387
|
+
if (!this.#readyFired) this.#dispatchReady();
|
|
345
388
|
} catch (err) {
|
|
346
389
|
console.warn(`[impact-effort] Failed to load src="${url}":`, err);
|
|
347
390
|
}
|
|
@@ -357,28 +400,14 @@ class ImpactEffort extends VBElement {
|
|
|
357
400
|
return null;
|
|
358
401
|
}
|
|
359
402
|
|
|
360
|
-
/** Set live region text content (with clear-and-set for re-announcement) */
|
|
361
|
-
#announce(msg) {
|
|
362
|
-
const region = this.#liveRegion;
|
|
363
|
-
if (!region) return;
|
|
364
|
-
region.textContent = '';
|
|
365
|
-
requestAnimationFrame(() => {
|
|
366
|
-
region.textContent = msg;
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/** Derive a label for an item from data-id or textContent */
|
|
371
|
-
#itemLabel(item) {
|
|
372
|
-
return item.dataset.id || item.textContent.trim().slice(0, 40);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
403
|
/** Dispatch the ready event with counts per quadrant */
|
|
376
404
|
#dispatchReady() {
|
|
405
|
+
this.#readyFired = true;
|
|
377
406
|
const quadrantCounts = {};
|
|
378
407
|
for (const q of ImpactEffort.QUADRANTS) {
|
|
379
408
|
const surface = this.#surfaces[q];
|
|
380
409
|
quadrantCounts[q] = surface
|
|
381
|
-
? surface.querySelectorAll('[draggable="true"]').length
|
|
410
|
+
? surface.querySelectorAll(':scope > [draggable="true"]').length
|
|
382
411
|
: 0;
|
|
383
412
|
}
|
|
384
413
|
this.dispatchEvent(new CustomEvent('impact-effort:ready', {
|