@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
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
*
|
|
16
16
|
* @fires story-map:reorder - Item reordered within a column
|
|
17
17
|
* @fires story-map:transfer - Item moved between columns
|
|
18
|
-
* @fires story-map:ready -
|
|
18
|
+
* @fires story-map:ready - Once after the first render (with src: after the JSON renders)
|
|
19
|
+
* @fires story-map:activities-changed - { activities, source: 'property' } after .activities is set
|
|
20
|
+
* @fires story-map:upgraded - Each time the map connects and upgrades
|
|
19
21
|
*
|
|
20
22
|
* @example
|
|
21
23
|
* <story-map>
|
|
@@ -28,6 +30,13 @@
|
|
|
28
30
|
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
29
31
|
import { VBElement } from '../../lib/vb-element.js';
|
|
30
32
|
import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
|
|
33
|
+
import {
|
|
34
|
+
SrcLoader, announce, createLiveRegion, createStoryCard, itemLabel, syncPlaceholder, titleCase,
|
|
35
|
+
} from '../../lib/board-surface.js';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {{ id: string, label: string, journeyPhase: string | null }} ActivityMeta
|
|
39
|
+
*/
|
|
31
40
|
|
|
32
41
|
class StoryMap extends VBElement {
|
|
33
42
|
static get observedAttributes() { return ['src', 'compact', 'title']; }
|
|
@@ -42,207 +51,211 @@ class StoryMap extends VBElement {
|
|
|
42
51
|
#surfaces = {};
|
|
43
52
|
/** @type {HTMLDivElement | null} */
|
|
44
53
|
#liveRegion = null;
|
|
54
|
+
/** @type {HTMLHeadingElement | null} */
|
|
55
|
+
#titleEl = null;
|
|
45
56
|
/** @type {string} */
|
|
46
57
|
#groupId = '';
|
|
47
|
-
/** @type {
|
|
58
|
+
/** @type {ActivityMeta[]} */
|
|
48
59
|
#activities = [];
|
|
60
|
+
/** Last story records assigned via `.activities` / src, keyed by data-id. */
|
|
61
|
+
/** @type {Map<string, Record<string, any>>} */
|
|
62
|
+
#records = new Map();
|
|
63
|
+
/** Shell built (survives disconnect so a moved map keeps its stories). */
|
|
64
|
+
#built = false;
|
|
65
|
+
/** Listeners attached for the current connection. */
|
|
66
|
+
#wired = false;
|
|
67
|
+
#readyFired = false;
|
|
68
|
+
#loader = new SrcLoader();
|
|
49
69
|
|
|
50
70
|
setup() {
|
|
51
|
-
this.#groupId = `sm-${++StoryMap.#instanceCount}`;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
if (!this.#groupId) this.#groupId = `sm-${++StoryMap.#instanceCount}`;
|
|
72
|
+
|
|
73
|
+
if (!this.#built) {
|
|
74
|
+
this.#built = true;
|
|
75
|
+
const sections = /** @type {HTMLElement[]} */ ([
|
|
76
|
+
...this.querySelectorAll(':scope > section[data-activity]')
|
|
77
|
+
]);
|
|
78
|
+
const parsed = sections.map(section => StoryMap.#parseSection(section));
|
|
79
|
+
for (const section of sections) section.remove();
|
|
80
|
+
|
|
81
|
+
this.#syncTitle();
|
|
82
|
+
this.#scroll = document.createElement('div');
|
|
83
|
+
this.#scroll.className = 'sm-scroll';
|
|
84
|
+
this.#scroll.setAttribute('role', 'region');
|
|
85
|
+
this.#scroll.setAttribute('tabindex', '0');
|
|
86
|
+
this.#liveRegion = createLiveRegion('sm-live-region');
|
|
87
|
+
this.#renderColumns(parsed.map(p => p.meta), parsed.map(p => p.children));
|
|
88
|
+
this.append(this.#scroll, this.#liveRegion);
|
|
89
|
+
}
|
|
66
90
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
91
|
+
if (!this.#wired) {
|
|
92
|
+
this.#wired = true;
|
|
93
|
+
this.listen(this, 'drag-surface:reorder', this.#onReorder);
|
|
94
|
+
this.listen(this, 'drag-surface:transfer', this.#onTransfer);
|
|
70
95
|
}
|
|
71
96
|
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
97
|
+
// Data mode: fetch once per src value; ready fires after it renders.
|
|
98
|
+
const src = this.getAttribute('src');
|
|
99
|
+
if (src) {
|
|
100
|
+
if (src !== this.#loader.url) this._loadSrc(src);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!this.#readyFired) this.#dispatchReady();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
teardown() {
|
|
108
|
+
// Listeners are removed by VBElement. The title, columns and stories
|
|
109
|
+
// stay in place so a map that is moved in the DOM keeps its content;
|
|
110
|
+
// the next connect only re-attaches listeners.
|
|
111
|
+
this.#wired = false;
|
|
112
|
+
}
|
|
78
113
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
114
|
+
/**
|
|
115
|
+
* @param {HTMLElement} section
|
|
116
|
+
* @returns {{ meta: ActivityMeta, children: HTMLElement[] }}
|
|
117
|
+
*/
|
|
118
|
+
static #parseSection(section) {
|
|
119
|
+
const id = section.getAttribute('data-activity') || '';
|
|
120
|
+
const label = section.getAttribute('data-activity-label') || titleCase(id);
|
|
121
|
+
const journeyPhase = section.getAttribute('data-journey-phase') || null;
|
|
122
|
+
const children = /** @type {HTMLElement[]} */ ([...section.children]);
|
|
123
|
+
return { meta: { id, label, journeyPhase }, children };
|
|
124
|
+
}
|
|
82
125
|
|
|
83
|
-
|
|
84
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Replace the columns inside the scroll container. `children[i]` are the
|
|
128
|
+
* story cards for activity i (moved, not cloned).
|
|
129
|
+
* @param {ActivityMeta[]} metas
|
|
130
|
+
* @param {HTMLElement[][]} children
|
|
131
|
+
*/
|
|
132
|
+
#renderColumns(metas, children) {
|
|
133
|
+
this.#activities = metas;
|
|
134
|
+
this.#surfaces = {};
|
|
135
|
+
const columns = document.createElement('div');
|
|
136
|
+
columns.className = 'sm-columns';
|
|
85
137
|
|
|
86
|
-
|
|
138
|
+
metas.forEach((activity, i) => {
|
|
87
139
|
const column = document.createElement('section');
|
|
88
140
|
column.className = 'sm-column';
|
|
89
141
|
column.setAttribute('data-activity-column', activity.id);
|
|
90
142
|
|
|
91
|
-
// Header with count badge
|
|
92
143
|
const header = document.createElement('header');
|
|
93
144
|
header.className = 'sm-activity-header';
|
|
94
145
|
const h3 = document.createElement('h3');
|
|
95
146
|
h3.textContent = activity.label;
|
|
96
147
|
const countBadge = document.createElement('span');
|
|
97
148
|
countBadge.className = 'sm-activity-count';
|
|
98
|
-
countBadge.textContent = String(activity.children.length);
|
|
99
149
|
h3.appendChild(countBadge);
|
|
100
150
|
header.appendChild(h3);
|
|
101
151
|
column.appendChild(header);
|
|
102
152
|
|
|
103
|
-
// Drag surface
|
|
104
153
|
const surface = document.createElement('drag-surface');
|
|
105
154
|
surface.setAttribute('group', this.#groupId);
|
|
106
155
|
surface.setAttribute('aria-label', `${activity.label} stories`);
|
|
107
156
|
surface.setAttribute('data-layout', 'stack');
|
|
108
157
|
surface.setAttribute('data-layout-gap', 'xs');
|
|
109
|
-
|
|
110
|
-
// Move captured children into the drag-surface
|
|
111
|
-
if (activity.children.length > 0) {
|
|
112
|
-
for (const child of activity.children) {
|
|
113
|
-
surface.appendChild(child);
|
|
114
|
-
}
|
|
115
|
-
totalStories += activity.children.length;
|
|
116
|
-
} else {
|
|
117
|
-
const placeholder = document.createElement('p');
|
|
118
|
-
placeholder.className = 'sm-empty';
|
|
119
|
-
placeholder.textContent = 'No stories yet';
|
|
120
|
-
surface.appendChild(placeholder);
|
|
121
|
-
}
|
|
158
|
+
for (const child of children[i] || []) surface.appendChild(child);
|
|
122
159
|
|
|
123
160
|
column.appendChild(surface);
|
|
124
|
-
|
|
161
|
+
columns.appendChild(column);
|
|
125
162
|
this.#surfaces[activity.id] = surface;
|
|
126
|
-
|
|
163
|
+
this.#updateCount(activity.id);
|
|
164
|
+
syncPlaceholder(surface, 'sm-empty', 'No stories yet');
|
|
165
|
+
});
|
|
127
166
|
|
|
128
|
-
|
|
167
|
+
this.#columnsEl?.remove();
|
|
168
|
+
this.#columnsEl = columns;
|
|
169
|
+
this.#scroll?.appendChild(columns);
|
|
170
|
+
this.#scroll?.setAttribute('aria-label', this.getAttribute('title') || 'Story map');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Create, update, or remove the optional heading from the title attribute. */
|
|
174
|
+
#syncTitle() {
|
|
129
175
|
const title = this.getAttribute('title');
|
|
130
176
|
if (title) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
this.#scroll.appendChild(this.#columnsEl);
|
|
138
|
-
this.appendChild(this.#scroll);
|
|
139
|
-
|
|
140
|
-
// 7. Create live region
|
|
141
|
-
this.#liveRegion = document.createElement('div');
|
|
142
|
-
this.#liveRegion.className = 'sm-live-region';
|
|
143
|
-
this.#liveRegion.setAttribute('role', 'status');
|
|
144
|
-
this.#liveRegion.setAttribute('aria-live', 'polite');
|
|
145
|
-
this.#liveRegion.setAttribute('aria-atomic', 'true');
|
|
146
|
-
this.appendChild(this.#liveRegion);
|
|
147
|
-
|
|
148
|
-
// 8. Listen for drag-surface:reorder
|
|
149
|
-
this.listen(this, 'drag-surface:reorder', (e) => {
|
|
150
|
-
const detail = /** @type {CustomEvent} */ (e).detail;
|
|
151
|
-
const surface = /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('drag-surface'));
|
|
152
|
-
const activityId = this.#findActivityForSurface(surface);
|
|
153
|
-
if (!activityId) return;
|
|
154
|
-
|
|
155
|
-
this.#updateCount(activityId);
|
|
156
|
-
|
|
157
|
-
this.dispatchEvent(new CustomEvent('story-map:reorder', {
|
|
158
|
-
bubbles: true,
|
|
159
|
-
detail: {
|
|
160
|
-
itemId: detail.itemId,
|
|
161
|
-
activity: activityId,
|
|
162
|
-
oldIndex: detail.oldIndex,
|
|
163
|
-
newIndex: detail.newIndex,
|
|
164
|
-
},
|
|
165
|
-
}));
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
// 9. Listen for drag-surface:transfer
|
|
169
|
-
this.listen(this, 'drag-surface:transfer', (e) => {
|
|
170
|
-
const detail = /** @type {CustomEvent} */ (e).detail;
|
|
171
|
-
const fromSurface = detail.fromSurface;
|
|
172
|
-
const toSurface = detail.toSurface;
|
|
173
|
-
const fromActivity = this.#findActivityForSurface(fromSurface);
|
|
174
|
-
const toActivity = this.#findActivityForSurface(toSurface);
|
|
175
|
-
if (!fromActivity || !toActivity) return;
|
|
176
|
-
|
|
177
|
-
// Update count badges
|
|
178
|
-
this.#updateCount(fromActivity);
|
|
179
|
-
this.#updateCount(toActivity);
|
|
180
|
-
|
|
181
|
-
// Remove placeholder from receiving column if it had one
|
|
182
|
-
const toPlaceholder = toSurface.querySelector('.sm-empty');
|
|
183
|
-
if (toPlaceholder) toPlaceholder.remove();
|
|
184
|
-
|
|
185
|
-
// Add placeholder to sending column if now empty
|
|
186
|
-
const fromDraggables = fromSurface.querySelectorAll(':scope > [draggable="true"]');
|
|
187
|
-
if (fromDraggables.length === 0 && !fromSurface.querySelector('.sm-empty')) {
|
|
188
|
-
const placeholder = document.createElement('p');
|
|
189
|
-
placeholder.className = 'sm-empty';
|
|
190
|
-
placeholder.textContent = 'No stories yet';
|
|
191
|
-
fromSurface.appendChild(placeholder);
|
|
177
|
+
if (!this.#titleEl) {
|
|
178
|
+
this.#titleEl = document.createElement('h2');
|
|
179
|
+
this.#titleEl.className = 'sm-title';
|
|
180
|
+
this.prepend(this.#titleEl);
|
|
192
181
|
}
|
|
182
|
+
this.#titleEl.textContent = title;
|
|
183
|
+
} else if (this.#titleEl) {
|
|
184
|
+
this.#titleEl.remove();
|
|
185
|
+
this.#titleEl = null;
|
|
186
|
+
}
|
|
187
|
+
this.#scroll?.setAttribute('aria-label', title || 'Story map');
|
|
188
|
+
}
|
|
193
189
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
190
|
+
#dispatchReady() {
|
|
191
|
+
this.#readyFired = true;
|
|
192
|
+
let storyCount = 0;
|
|
193
|
+
for (const surface of Object.values(this.#surfaces)) {
|
|
194
|
+
storyCount += surface.querySelectorAll(':scope > [draggable="true"]').length;
|
|
195
|
+
}
|
|
196
|
+
this.dispatchEvent(new CustomEvent('story-map:ready', {
|
|
197
|
+
bubbles: true,
|
|
198
|
+
detail: { activityCount: this.#activities.length, storyCount },
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
197
201
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
newIndex: detail.newIndex,
|
|
205
|
-
},
|
|
206
|
-
}));
|
|
207
|
-
});
|
|
202
|
+
/** @param {Event} e */
|
|
203
|
+
#onReorder = (e) => {
|
|
204
|
+
const detail = /** @type {CustomEvent} */ (e).detail;
|
|
205
|
+
const surface = /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('drag-surface'));
|
|
206
|
+
const activityId = this.#findActivityForSurface(surface);
|
|
207
|
+
if (!activityId) return;
|
|
208
208
|
|
|
209
|
-
|
|
210
|
-
const src = this.getAttribute('src');
|
|
211
|
-
if (src) {
|
|
212
|
-
this._loadSrc(src);
|
|
213
|
-
return; // ready event fires after src loads
|
|
214
|
-
}
|
|
209
|
+
this.#updateCount(activityId);
|
|
215
210
|
|
|
216
|
-
|
|
217
|
-
this.dispatchEvent(new CustomEvent('story-map:ready', {
|
|
211
|
+
this.dispatchEvent(new CustomEvent('story-map:reorder', {
|
|
218
212
|
bubbles: true,
|
|
219
213
|
detail: {
|
|
220
|
-
|
|
221
|
-
|
|
214
|
+
itemId: detail.itemId,
|
|
215
|
+
activity: activityId,
|
|
216
|
+
oldIndex: detail.oldIndex,
|
|
217
|
+
newIndex: detail.newIndex,
|
|
222
218
|
},
|
|
223
219
|
}));
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
this.#
|
|
236
|
-
this.#
|
|
237
|
-
|
|
238
|
-
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** @param {Event} e */
|
|
223
|
+
#onTransfer = (e) => {
|
|
224
|
+
const detail = /** @type {CustomEvent} */ (e).detail;
|
|
225
|
+
const fromSurface = detail.fromSurface;
|
|
226
|
+
const toSurface = detail.toSurface;
|
|
227
|
+
const fromActivity = this.#findActivityForSurface(fromSurface);
|
|
228
|
+
const toActivity = this.#findActivityForSurface(toSurface);
|
|
229
|
+
if (!fromActivity || !toActivity) return;
|
|
230
|
+
|
|
231
|
+
this.#updateCount(fromActivity);
|
|
232
|
+
this.#updateCount(toActivity);
|
|
233
|
+
syncPlaceholder(toSurface, 'sm-empty', 'No stories yet');
|
|
234
|
+
syncPlaceholder(fromSurface, 'sm-empty', 'No stories yet');
|
|
235
|
+
|
|
236
|
+
announce(this.#liveRegion,
|
|
237
|
+
`${itemLabel(detail.item)} moved from ${this.#activityLabel(fromActivity)} to ${this.#activityLabel(toActivity)}`);
|
|
238
|
+
|
|
239
|
+
this.dispatchEvent(new CustomEvent('story-map:transfer', {
|
|
240
|
+
bubbles: true,
|
|
241
|
+
detail: {
|
|
242
|
+
itemId: detail.itemId,
|
|
243
|
+
fromActivity,
|
|
244
|
+
toActivity,
|
|
245
|
+
newIndex: detail.newIndex,
|
|
246
|
+
},
|
|
247
|
+
}));
|
|
248
|
+
};
|
|
239
249
|
|
|
240
250
|
// ── Data API (HTML-first / JS-first dual contract) ──────────────
|
|
241
251
|
|
|
242
252
|
/**
|
|
243
|
-
* Read the current activities + stories as a nested data array
|
|
244
|
-
*
|
|
245
|
-
*
|
|
253
|
+
* Read the current activities + stories as a nested data array, in DOM
|
|
254
|
+
* order (drags are reflected):
|
|
255
|
+
* `[{ id, label, journeyPhase?, stories: [{ id, ...record }] }]`.
|
|
256
|
+
* A story assigned through `.activities` or `src` returns the record it
|
|
257
|
+
* was given (so the getter round-trips through the setter); an authored
|
|
258
|
+
* `<user-story>` returns its `.data`; any other card `{ id, title }`.
|
|
246
259
|
*/
|
|
247
260
|
get activities() {
|
|
248
261
|
return this.#activities.map(a => ({
|
|
@@ -250,69 +263,47 @@ class StoryMap extends VBElement {
|
|
|
250
263
|
label: a.label,
|
|
251
264
|
journeyPhase: a.journeyPhase || undefined,
|
|
252
265
|
stories: [...(this.#surfaces[a.id]?.querySelectorAll(':scope > [draggable="true"]') || [])]
|
|
253
|
-
.map(el => ({
|
|
254
|
-
id: el.getAttribute('data-id') || undefined,
|
|
255
|
-
storyId: el.getAttribute('story-id') || undefined,
|
|
256
|
-
title: el.querySelector('[slot="title"]')?.textContent?.trim() || undefined,
|
|
257
|
-
})),
|
|
266
|
+
.map(el => this.#storyRecord(/** @type {HTMLElement} */ (el))),
|
|
258
267
|
}));
|
|
259
268
|
}
|
|
260
269
|
|
|
261
270
|
/**
|
|
262
271
|
* Replace the entire story map. Accepts a nested tree:
|
|
263
|
-
* [{ id, label
|
|
264
|
-
* Stories
|
|
265
|
-
* override via `.renderStory`.
|
|
272
|
+
* `[{ id, label?, journeyPhase?, stories: [{ id, title?, persona?, action?, benefit?, ... }] }]`
|
|
273
|
+
* Stories render as `<user-story>` cards (persona/action/benefit/title
|
|
274
|
+
* become slotted children) by default; override via `.renderStory`.
|
|
266
275
|
*
|
|
267
|
-
*
|
|
268
|
-
* preservation
|
|
276
|
+
* Record-shaped: the columns are rebuilt on assignment (no per-story
|
|
277
|
+
* node preservation). Assigning before the element upgrades is fine —
|
|
278
|
+
* the tree is rendered on connect.
|
|
269
279
|
*
|
|
270
|
-
* Emits story-map:activities-changed { activities, source: 'property' }
|
|
280
|
+
* Emits `story-map:activities-changed { activities, source: 'property' }`.
|
|
271
281
|
*/
|
|
272
282
|
set activities(value) {
|
|
273
283
|
const next = Array.isArray(value) ? value : [];
|
|
284
|
+
this.#records.clear();
|
|
285
|
+
/** @type {HTMLElement[][]} */
|
|
286
|
+
const children = [];
|
|
287
|
+
/** @type {ActivityMeta[]} */
|
|
288
|
+
const metas = next.map(a => {
|
|
289
|
+
const id = String(a.id ?? '');
|
|
290
|
+
children.push((a.stories || []).map((/** @type {any} */ story) => this.#cardFromRecord(story, undefined)));
|
|
291
|
+
return { id, label: a.label || titleCase(id), journeyPhase: a.journeyPhase || null };
|
|
292
|
+
});
|
|
274
293
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (typeof self.renderStory === 'function') {
|
|
287
|
-
const out = self.renderStory(story);
|
|
288
|
-
el = out instanceof Element ? out : null;
|
|
289
|
-
}
|
|
290
|
-
if (!el) {
|
|
291
|
-
if (customElements.get('user-story')) {
|
|
292
|
-
el = document.createElement('user-story');
|
|
293
|
-
/** @type {any} */ (el).data = story;
|
|
294
|
-
} else {
|
|
295
|
-
el = document.createElement('article');
|
|
296
|
-
el.className = 'sm-card';
|
|
297
|
-
el.textContent = story.title || story.id || '';
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
if (!el.hasAttribute('draggable')) el.setAttribute('draggable', 'true');
|
|
301
|
-
if (story.id && !el.hasAttribute('data-id')) el.setAttribute('data-id', String(story.id));
|
|
302
|
-
section.appendChild(el);
|
|
303
|
-
}
|
|
304
|
-
this.appendChild(section);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
const swap = () => {
|
|
308
|
-
this.teardown();
|
|
309
|
-
this.removeAttribute('data-upgraded');
|
|
310
|
-
this.setup();
|
|
311
|
-
};
|
|
312
|
-
if (this.hasAttribute('data-upgraded') && this.#scroll) {
|
|
313
|
-
viewTransitionSwap(this, swap, 'sm-vt');
|
|
294
|
+
if (!this.#built) {
|
|
295
|
+
// Not upgraded yet: leave sections for setup() to parse.
|
|
296
|
+
for (const section of this.querySelectorAll(':scope > section[data-activity]')) section.remove();
|
|
297
|
+
metas.forEach((meta, i) => {
|
|
298
|
+
const section = document.createElement('section');
|
|
299
|
+
section.setAttribute('data-activity', meta.id);
|
|
300
|
+
section.setAttribute('data-activity-label', meta.label);
|
|
301
|
+
if (meta.journeyPhase) section.setAttribute('data-journey-phase', meta.journeyPhase);
|
|
302
|
+
section.append(...children[i]);
|
|
303
|
+
this.appendChild(section);
|
|
304
|
+
});
|
|
314
305
|
} else {
|
|
315
|
-
|
|
306
|
+
viewTransitionSwap(this, () => this.#renderColumns(metas, children), 'sm-vt');
|
|
316
307
|
}
|
|
317
308
|
|
|
318
309
|
this.dispatchEvent(new CustomEvent('story-map:activities-changed', {
|
|
@@ -321,56 +312,95 @@ class StoryMap extends VBElement {
|
|
|
321
312
|
}));
|
|
322
313
|
}
|
|
323
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Build a draggable card for a story record and remember the record.
|
|
317
|
+
* @param {Record<string, any>} story
|
|
318
|
+
* @param {string | undefined} defaultDetail
|
|
319
|
+
* @returns {HTMLElement}
|
|
320
|
+
*/
|
|
321
|
+
#cardFromRecord(story, defaultDetail) {
|
|
322
|
+
/** @type {Element | null} */
|
|
323
|
+
let el = null;
|
|
324
|
+
const self = /** @type {any} */ (this);
|
|
325
|
+
if (typeof self.renderStory === 'function') {
|
|
326
|
+
const out = self.renderStory(story);
|
|
327
|
+
el = out instanceof Element ? out : null;
|
|
328
|
+
}
|
|
329
|
+
if (!el) el = createStoryCard(story, defaultDetail);
|
|
330
|
+
const card = /** @type {HTMLElement} */ (el);
|
|
331
|
+
if (!card.hasAttribute('draggable')) card.setAttribute('draggable', 'true');
|
|
332
|
+
const id = story.id ?? story.storyId;
|
|
333
|
+
if (id != null && !card.hasAttribute('data-id')) card.setAttribute('data-id', String(id));
|
|
334
|
+
const key = card.getAttribute('data-id');
|
|
335
|
+
if (key) this.#records.set(key, story);
|
|
336
|
+
return card;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* @param {HTMLElement} el
|
|
341
|
+
* @returns {Record<string, any>}
|
|
342
|
+
*/
|
|
343
|
+
#storyRecord(el) {
|
|
344
|
+
const id = el.getAttribute('data-id') || undefined;
|
|
345
|
+
const known = id ? this.#records.get(id) : undefined;
|
|
346
|
+
if (known) return { ...known, id: known.id ?? id };
|
|
347
|
+
const data = /** @type {any} */ (el).data;
|
|
348
|
+
if (el.localName === 'user-story' && data && typeof data === 'object') {
|
|
349
|
+
/** @type {Record<string, any>} */
|
|
350
|
+
const out = { id };
|
|
351
|
+
for (const [k, v] of Object.entries(data)) if (v !== undefined) out[k] = v;
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
id,
|
|
356
|
+
storyId: el.getAttribute('story-id') || undefined,
|
|
357
|
+
title: (el.querySelector('[slot="title"]')?.textContent || el.textContent || '').replace(/\s+/g, ' ').trim() || undefined,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
324
361
|
// ── Attribute changes ────────────────────────────────
|
|
325
362
|
|
|
326
363
|
attributeChangedCallback(name, oldValue, newValue) {
|
|
327
|
-
if (oldValue === newValue || !this
|
|
328
|
-
if (name === 'src') this._loadSrc(newValue);
|
|
364
|
+
if (oldValue === newValue || !this.#built) return;
|
|
365
|
+
if (name === 'src' && newValue && newValue !== this.#loader.url) this._loadSrc(newValue);
|
|
366
|
+
if (name === 'src' && !newValue) this.#loader.cancel(); // src removed: drop a pending load, keep the cards
|
|
367
|
+
if (name === 'title') this.#syncTitle();
|
|
329
368
|
}
|
|
330
369
|
|
|
331
370
|
// ── JSON loading ────────────────────────────────────
|
|
332
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Fetch `{ activities: [{ id, label?, journeyPhase?, stories: [...] }] }`
|
|
374
|
+
* and replace the map with it. One request per src value; a newer src
|
|
375
|
+
* aborts an older in-flight one. Once the data has rendered, fires
|
|
376
|
+
* `story-map:activities-changed` (source 'src'), and `story-map:ready`
|
|
377
|
+
* if it has not fired yet (ready is once per element).
|
|
378
|
+
* @param {string} url
|
|
379
|
+
*/
|
|
333
380
|
async _loadSrc(url) {
|
|
334
381
|
if (!url) return;
|
|
335
382
|
try {
|
|
336
|
-
const
|
|
337
|
-
if (!
|
|
338
|
-
const data =
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (story.status) el.setAttribute('status', story.status);
|
|
358
|
-
if (story.points) el.setAttribute('points', String(story.points));
|
|
359
|
-
el.setAttribute('detail', story.detail || 'compact');
|
|
360
|
-
section.appendChild(el);
|
|
361
|
-
}
|
|
362
|
-
this.appendChild(section);
|
|
363
|
-
}
|
|
364
|
-
const swap = () => {
|
|
365
|
-
this.teardown();
|
|
366
|
-
this.removeAttribute('data-upgraded');
|
|
367
|
-
this.setup();
|
|
368
|
-
};
|
|
369
|
-
if (this.hasAttribute('data-upgraded') && this.#scroll) {
|
|
370
|
-
viewTransitionSwap(this, swap, 'sm-vt');
|
|
371
|
-
} else {
|
|
372
|
-
swap();
|
|
373
|
-
}
|
|
383
|
+
const result = await this.#loader.fetchJson(url);
|
|
384
|
+
if (!result) return;
|
|
385
|
+
const { data, isCurrent } = result;
|
|
386
|
+
this.#records.clear();
|
|
387
|
+
/** @type {HTMLElement[][]} */
|
|
388
|
+
const children = [];
|
|
389
|
+
/** @type {ActivityMeta[]} */
|
|
390
|
+
const metas = (data.activities || []).map((/** @type {any} */ activity) => {
|
|
391
|
+
const id = String(activity.id ?? '');
|
|
392
|
+
children.push((activity.stories || []).map((/** @type {any} */ story) => this.#cardFromRecord(story, 'compact')));
|
|
393
|
+
return { id, label: activity.label || titleCase(id), journeyPhase: activity.journeyPhase || null };
|
|
394
|
+
});
|
|
395
|
+
// viewTransitionSwap runs the swap synchronously when it can't animate.
|
|
396
|
+
const tx = viewTransitionSwap(this, () => this.#renderColumns(metas, children), 'sm-vt');
|
|
397
|
+
if (tx) await tx.updateCallbackDone;
|
|
398
|
+
if (!isCurrent()) return;
|
|
399
|
+
this.dispatchEvent(new CustomEvent('story-map:activities-changed', {
|
|
400
|
+
detail: { activities: this.activities, source: 'src' },
|
|
401
|
+
bubbles: true,
|
|
402
|
+
}));
|
|
403
|
+
if (!this.#readyFired) this.#dispatchReady();
|
|
374
404
|
} catch (err) {
|
|
375
405
|
console.warn(`[story-map] Failed to load src="${url}":`, err);
|
|
376
406
|
}
|
|
@@ -391,6 +421,11 @@ class StoryMap extends VBElement {
|
|
|
391
421
|
return null;
|
|
392
422
|
}
|
|
393
423
|
|
|
424
|
+
/** @param {string} activityId */
|
|
425
|
+
#activityLabel(activityId) {
|
|
426
|
+
return this.#activities.find(a => a.id === activityId)?.label || titleCase(activityId);
|
|
427
|
+
}
|
|
428
|
+
|
|
394
429
|
/**
|
|
395
430
|
* Update the count badge in the column header for a given activity.
|
|
396
431
|
* @param {string} activityId
|
|
@@ -400,45 +435,9 @@ class StoryMap extends VBElement {
|
|
|
400
435
|
if (!surface) return;
|
|
401
436
|
const count = surface.querySelectorAll(':scope > [draggable="true"]').length;
|
|
402
437
|
const column = surface.closest('[data-activity-column]');
|
|
403
|
-
|
|
404
|
-
const badge = column.querySelector('.sm-activity-count');
|
|
438
|
+
const badge = column?.querySelector('.sm-activity-count');
|
|
405
439
|
if (badge) badge.textContent = String(count);
|
|
406
440
|
}
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* Announce a message via the live region.
|
|
410
|
-
* @param {string} msg
|
|
411
|
-
*/
|
|
412
|
-
#announce(msg) {
|
|
413
|
-
if (!this.#liveRegion) return;
|
|
414
|
-
this.#liveRegion.textContent = '';
|
|
415
|
-
requestAnimationFrame(() => {
|
|
416
|
-
if (this.#liveRegion) this.#liveRegion.textContent = msg;
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* Get a human-readable label for a story item.
|
|
422
|
-
* @param {HTMLElement} item
|
|
423
|
-
* @returns {string}
|
|
424
|
-
*/
|
|
425
|
-
#itemLabel(item) {
|
|
426
|
-
return item.getAttribute('story-id')
|
|
427
|
-
|| item.getAttribute('data-id')
|
|
428
|
-
|| item.textContent?.trim().slice(0, 40)
|
|
429
|
-
|| 'item';
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* Convert a hyphenated or lowercase id to Title Case.
|
|
434
|
-
* @param {string} str
|
|
435
|
-
* @returns {string}
|
|
436
|
-
*/
|
|
437
|
-
#titleCase(str) {
|
|
438
|
-
return str
|
|
439
|
-
.replace(/[-_]/g, ' ')
|
|
440
|
-
.replace(/\b\w/g, c => c.toUpperCase());
|
|
441
|
-
}
|
|
442
441
|
}
|
|
443
442
|
|
|
444
443
|
registerComponent('story-map', StoryMap);
|