@profpowell/vb-project-planning 0.2.1 → 0.2.2
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 +5 -2
- package/dist/vb-project-planning.css +56 -2
- package/dist/vb-project-planning.js +3188 -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/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 +9 -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/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 +14 -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
|
@@ -24,34 +24,44 @@
|
|
|
24
24
|
* "goals": ["goal1"],
|
|
25
25
|
* "painPoints": ["pain1"]
|
|
26
26
|
* }
|
|
27
|
+
*
|
|
28
|
+
* Title and summary are content: `<h2 slot="title">`, `<p slot="summary">`
|
|
29
|
+
* (or the `title` / `summary` JSON fields, which create those children).
|
|
30
|
+
* Once a quadrant has been edited, it renders from the edited items; its
|
|
31
|
+
* slotted light-DOM content is no longer projected.
|
|
32
|
+
*
|
|
33
|
+
* @fires empathy-map:ready - Once, after the first render (after the src data renders when src is set), detail: { title, persona }
|
|
34
|
+
* @fires empathy-map:update - After an edit is committed (Done / Escape / closeQuadrant()), detail: { quadrant, items }
|
|
35
|
+
* @fires empathy-map:upgraded - Once, after first connect
|
|
36
|
+
* @fires empathy-map:data-changed - After .data assignment, detail: { data, source: 'property' }
|
|
27
37
|
*/
|
|
28
38
|
|
|
29
39
|
import { styles } from './styles.js';
|
|
30
40
|
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
31
41
|
import { viewTransitionSwap } from '../../lib/vb-view-transition.js';
|
|
32
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
esc, QUADRANT_META, emotionMeta, isEmotion, lucideSvg, UX_ICONS, UXShadowElement, fillSlot, slottedChild,
|
|
44
|
+
} from '../_ux-base.js';
|
|
33
45
|
|
|
34
46
|
const QUADRANT_KEYS = ['says', 'thinks', 'does', 'feels'];
|
|
35
47
|
|
|
36
|
-
class EmpathyMap extends
|
|
48
|
+
class EmpathyMap extends UXShadowElement {
|
|
49
|
+
static readyEvent = 'empathy-map:ready';
|
|
50
|
+
|
|
37
51
|
static get observedAttributes() {
|
|
38
52
|
return ['persona', 'persona-id', 'src', 'editable', 'compact'];
|
|
39
53
|
}
|
|
40
54
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
this.__painPoints = null;
|
|
52
|
-
/** @type {Set<string>} */
|
|
53
|
-
this._editingQuadrants = new Set();
|
|
54
|
-
}
|
|
55
|
+
/** @type {Object<string, string[]>|null} */
|
|
56
|
+
__quadrants = null;
|
|
57
|
+
/** @type {string[]|null} */
|
|
58
|
+
__goals = null;
|
|
59
|
+
/** @type {string[]|null} */
|
|
60
|
+
__painPoints = null;
|
|
61
|
+
/** @type {Set<string>} quadrants whose editor is open */
|
|
62
|
+
_editingQuadrants = new Set();
|
|
63
|
+
/** @type {Set<string>} quadrants edited at least once (render from data, not the slot) */
|
|
64
|
+
_editedQuadrants = new Set();
|
|
55
65
|
|
|
56
66
|
/* ── Properties ─────────────────────────────────── */
|
|
57
67
|
|
|
@@ -61,7 +71,7 @@ class EmpathyMap extends HTMLElement {
|
|
|
61
71
|
/** @param {Object<string, string[]>|null} data */
|
|
62
72
|
set quadrants(data) {
|
|
63
73
|
this.__quadrants = data;
|
|
64
|
-
if (this.
|
|
74
|
+
if (this._isSetUp) this._render();
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
/** @returns {string[]|null} */
|
|
@@ -70,7 +80,7 @@ class EmpathyMap extends HTMLElement {
|
|
|
70
80
|
/** @param {string[]|null} data */
|
|
71
81
|
set goals(data) {
|
|
72
82
|
this.__goals = data;
|
|
73
|
-
if (this.
|
|
83
|
+
if (this._isSetUp) this._render();
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
/** @returns {string[]|null} */
|
|
@@ -79,45 +89,20 @@ class EmpathyMap extends HTMLElement {
|
|
|
79
89
|
/** @param {string[]|null} data */
|
|
80
90
|
set painPoints(data) {
|
|
81
91
|
this.__painPoints = data;
|
|
82
|
-
if (this.
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/* ── Slot caching ───────────────────────────────── */
|
|
86
|
-
|
|
87
|
-
#cacheSlotValues() {
|
|
88
|
-
for (const child of this.children) {
|
|
89
|
-
const slot = child.getAttribute('slot');
|
|
90
|
-
if (slot) this.#slotCache.set(slot, child.textContent.trim());
|
|
91
|
-
}
|
|
92
|
+
if (this._isSetUp) this._render();
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Attribute value, else the text of a same-named light-DOM child (e.g.
|
|
97
|
+
* `<span slot="persona">`, read as data; it is not projected).
|
|
98
|
+
* @param {string} attr
|
|
99
|
+
*/
|
|
94
100
|
_resolve(attr) {
|
|
95
|
-
return this.getAttribute(attr) || this
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/* ── Lifecycle ─────────────────────────────────── */
|
|
99
|
-
|
|
100
|
-
connectedCallback() {
|
|
101
|
-
this.#cacheSlotValues();
|
|
102
|
-
this.setAttribute('data-upgraded', '');
|
|
103
|
-
if (this.hasAttribute('src')) {
|
|
104
|
-
this._loadSrc(this.getAttribute('src'));
|
|
105
|
-
} else {
|
|
106
|
-
this._render();
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
disconnectedCallback() {
|
|
111
|
-
this.removeAttribute('data-upgraded');
|
|
101
|
+
return this.getAttribute(attr) || this._slotCache.get(attr) || '';
|
|
112
102
|
}
|
|
113
103
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (name === 'src') {
|
|
117
|
-
this._loadSrc(this.getAttribute('src'));
|
|
118
|
-
} else {
|
|
119
|
-
this._render();
|
|
120
|
-
}
|
|
104
|
+
_readyDetail() {
|
|
105
|
+
return { title: this._slotText('title') || 'Empathy Map', persona: this._resolve('persona') };
|
|
121
106
|
}
|
|
122
107
|
|
|
123
108
|
// ── Data API (HTML-first / JS-first dual contract) ──────────────
|
|
@@ -127,8 +112,8 @@ class EmpathyMap extends HTMLElement {
|
|
|
127
112
|
* slotted content. Mirrors what a consumer would assign to .data.
|
|
128
113
|
*/
|
|
129
114
|
get data() {
|
|
130
|
-
const titleEl = this
|
|
131
|
-
const summaryEl = this
|
|
115
|
+
const titleEl = slottedChild(this, 'title');
|
|
116
|
+
const summaryEl = slottedChild(this, 'summary');
|
|
132
117
|
return {
|
|
133
118
|
persona: this.getAttribute('persona') || undefined,
|
|
134
119
|
personaId: this.getAttribute('persona-id') || undefined,
|
|
@@ -146,29 +131,22 @@ class EmpathyMap extends HTMLElement {
|
|
|
146
131
|
* Emits empathy-map:data-changed { data, source: 'property' }.
|
|
147
132
|
*/
|
|
148
133
|
set data(value) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (
|
|
165
|
-
if (value.goals != null) this.__goals = value.goals;
|
|
166
|
-
if (value.painPoints != null) this.__painPoints = value.painPoints;
|
|
167
|
-
if (this.isConnected) this._render();
|
|
168
|
-
this.dispatchEvent(new CustomEvent('empathy-map:data-changed', {
|
|
169
|
-
detail: { data: this.data, source: 'property' },
|
|
170
|
-
bubbles: true, composed: true,
|
|
171
|
-
}));
|
|
134
|
+
this._setData(value);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Apply an empathy-map record (from .data or src). Title / summary create
|
|
139
|
+
* slotted children only when none are authored.
|
|
140
|
+
* @param {Record<string, any>} data
|
|
141
|
+
*/
|
|
142
|
+
_applyData(data) {
|
|
143
|
+
if (data.persona) this.setAttribute('persona', String(data.persona));
|
|
144
|
+
if (data.personaId) this.setAttribute('persona-id', String(data.personaId));
|
|
145
|
+
fillSlot(this, 'title', data.title, { tag: 'h2' });
|
|
146
|
+
fillSlot(this, 'summary', data.summary);
|
|
147
|
+
if (data.quadrants != null) this.__quadrants = data.quadrants;
|
|
148
|
+
if (data.goals != null) this.__goals = data.goals;
|
|
149
|
+
if (data.painPoints != null) this.__painPoints = data.painPoints;
|
|
172
150
|
}
|
|
173
151
|
|
|
174
152
|
/* ── Public API ────────────────────────────────── */
|
|
@@ -184,7 +162,8 @@ class EmpathyMap extends HTMLElement {
|
|
|
184
162
|
}
|
|
185
163
|
|
|
186
164
|
/**
|
|
187
|
-
* Close a quadrant edit and save.
|
|
165
|
+
* Close a quadrant edit and save. No-op (and no update event) unless the
|
|
166
|
+
* quadrant's editor is open.
|
|
188
167
|
* @param {string} name - Quadrant key: says|thinks|does|feels
|
|
189
168
|
*/
|
|
190
169
|
closeQuadrant(name) {
|
|
@@ -192,41 +171,17 @@ class EmpathyMap extends HTMLElement {
|
|
|
192
171
|
this._closeEdit(name);
|
|
193
172
|
}
|
|
194
173
|
|
|
195
|
-
/* ── JSON loading
|
|
174
|
+
/* ── JSON loading states ───────────────────────── */
|
|
196
175
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
this.shadowRoot.innerHTML = `<style>${styles}</style>` +
|
|
176
|
+
_renderLoading() {
|
|
177
|
+
/** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = `<style>${styles}</style>` +
|
|
200
178
|
`<div class="state-msg">Loading\u2026</div>`;
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
// Content → slotted elements
|
|
208
|
-
if (data.title && !this.querySelector('[slot="title"]')) {
|
|
209
|
-
const el = document.createElement('h2');
|
|
210
|
-
el.slot = 'title';
|
|
211
|
-
el.textContent = data.title;
|
|
212
|
-
this.appendChild(el);
|
|
213
|
-
}
|
|
214
|
-
if (data.summary && !this.querySelector('[slot="summary"]')) {
|
|
215
|
-
const el = document.createElement('p');
|
|
216
|
-
el.slot = 'summary';
|
|
217
|
-
el.textContent = data.summary;
|
|
218
|
-
this.appendChild(el);
|
|
219
|
-
}
|
|
220
|
-
this.__quadrants = data.quadrants || null;
|
|
221
|
-
this.__goals = data.goals || null;
|
|
222
|
-
this.__painPoints = data.painPoints || null;
|
|
223
|
-
this._render();
|
|
224
|
-
} catch (err) {
|
|
225
|
-
if (!this.shadowRoot) return;
|
|
226
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
227
|
-
this.shadowRoot.innerHTML = `<style>${styles}</style>` +
|
|
228
|
-
`<div class="state-msg state-msg--error">Could not load empathy map: ${esc(msg)}</div>`;
|
|
229
|
-
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** @param {Error} err */
|
|
182
|
+
_renderError(err) {
|
|
183
|
+
/** @type {ShadowRoot} */ (this.shadowRoot).innerHTML = `<style>${styles}</style>` +
|
|
184
|
+
`<div class="state-msg state-msg--error">Could not load empathy map: ${esc(err.message)}</div>`;
|
|
230
185
|
}
|
|
231
186
|
|
|
232
187
|
/* ── Render ────────────────────────────────────── */
|
|
@@ -236,11 +191,10 @@ class EmpathyMap extends HTMLElement {
|
|
|
236
191
|
const personaId = this._resolve('persona-id') || '';
|
|
237
192
|
const compact = this.hasAttribute('compact');
|
|
238
193
|
const editable = this.hasAttribute('editable');
|
|
239
|
-
const
|
|
240
|
-
const hasSummary = !!this.querySelector('[slot="summary"]') || this.#slotCache.has('summary');
|
|
194
|
+
const hasSummary = !!slottedChild(this, 'summary') || this._slotCache.has('summary');
|
|
241
195
|
|
|
242
|
-
const hasGoals = this.__goals?.length || this
|
|
243
|
-
const hasPains = this.__painPoints?.length || this
|
|
196
|
+
const hasGoals = this.__goals?.length || slottedChild(this, 'goals');
|
|
197
|
+
const hasPains = this.__painPoints?.length || slottedChild(this, 'pain-points');
|
|
244
198
|
|
|
245
199
|
const html = `<style>${styles}</style>
|
|
246
200
|
<article class="empathy-map${compact ? ' empathy-map--compact' : ''}">
|
|
@@ -281,21 +235,19 @@ class EmpathyMap extends HTMLElement {
|
|
|
281
235
|
swaps. Skip on the first render — nothing to fade from. */
|
|
282
236
|
const swap = () => {
|
|
283
237
|
if (!this.shadowRoot) return;
|
|
238
|
+
// An open editor's unsaved text (and focus) survives the re-render.
|
|
239
|
+
const drafts = this.#captureDrafts();
|
|
284
240
|
this.shadowRoot.innerHTML = html;
|
|
285
|
-
if (editable)
|
|
241
|
+
if (editable) {
|
|
242
|
+
this._bindEditListeners();
|
|
243
|
+
this.#restoreDrafts(drafts);
|
|
244
|
+
}
|
|
286
245
|
};
|
|
287
246
|
if (this.shadowRoot?.querySelector('article')) {
|
|
288
247
|
viewTransitionSwap(this, swap, 'em-vt');
|
|
289
248
|
} else {
|
|
290
249
|
swap();
|
|
291
250
|
}
|
|
292
|
-
|
|
293
|
-
/* Dispatch ready event */
|
|
294
|
-
this.dispatchEvent(new CustomEvent('empathy-map:ready', {
|
|
295
|
-
bubbles: true,
|
|
296
|
-
composed: true,
|
|
297
|
-
detail: { title: this.querySelector('[slot="title"]')?.textContent?.trim() || 'Empathy Map', persona },
|
|
298
|
-
}));
|
|
299
251
|
}
|
|
300
252
|
|
|
301
253
|
/* ── Render quadrant ───────────────────────────── */
|
|
@@ -305,20 +257,18 @@ class EmpathyMap extends HTMLElement {
|
|
|
305
257
|
const items = this.__quadrants?.[key];
|
|
306
258
|
const isEditing = this._editingQuadrants.has(key);
|
|
307
259
|
|
|
308
|
-
const frontContent = items && items.length
|
|
309
|
-
? (key
|
|
310
|
-
? items.map(item => this._renderEmotion(item)).join('')
|
|
311
|
-
: items.map(item => `<p>${esc(item)}</p>`).join(''))
|
|
260
|
+
const frontContent = (items && items.length) || this._editedQuadrants.has(key)
|
|
261
|
+
? this._quadrantItemsHtml(key)
|
|
312
262
|
: `<slot name="${key}"><p class="placeholder">Add ${meta.label.toLowerCase()} items\u2026</p></slot>`;
|
|
313
263
|
|
|
314
|
-
const textareaValue =
|
|
264
|
+
const textareaValue = this.#editorSeed(key);
|
|
315
265
|
|
|
316
266
|
return `
|
|
317
267
|
<section class="quadrant quadrant--${key}"${isEditing ? ' data-editing' : ''}>
|
|
318
268
|
<div class="quadrant__inner">
|
|
319
269
|
<div class="quadrant__header">
|
|
320
270
|
<div class="quadrant__icon" aria-hidden="true">${lucideSvg(meta.icon)}</div>
|
|
321
|
-
<
|
|
271
|
+
<h3 class="quadrant__label">${meta.label}</h3>
|
|
322
272
|
${editable ? `<button class="quadrant__edit-btn" data-quadrant="${key}"
|
|
323
273
|
aria-label="Edit ${meta.label}" title="Edit ${meta.label}">${lucideSvg(UX_ICONS.pencil)}</button>` : ''}
|
|
324
274
|
</div>
|
|
@@ -342,12 +292,25 @@ class EmpathyMap extends HTMLElement {
|
|
|
342
292
|
</section>`;
|
|
343
293
|
}
|
|
344
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Front-face markup for a quadrant rendered from data (no slot).
|
|
297
|
+
* @param {string} key
|
|
298
|
+
*/
|
|
299
|
+
_quadrantItemsHtml(key) {
|
|
300
|
+
const items = this.__quadrants?.[key] || [];
|
|
301
|
+
if (!items.length) {
|
|
302
|
+
return `<p class="placeholder">Add ${QUADRANT_META[key].label.toLowerCase()} items\u2026</p>`;
|
|
303
|
+
}
|
|
304
|
+
return key === 'feels'
|
|
305
|
+
? items.map(item => this._renderEmotion(item)).join('')
|
|
306
|
+
: items.map(item => `<p>${esc(item)}</p>`).join('');
|
|
307
|
+
}
|
|
308
|
+
|
|
345
309
|
/* ── Render emotion tag ────────────────────────── */
|
|
346
310
|
|
|
347
311
|
_renderEmotion(item) {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (meta) {
|
|
312
|
+
if (isEmotion(item)) {
|
|
313
|
+
const meta = emotionMeta(item);
|
|
351
314
|
return `<span class="emotion-tag" style="--ec:${meta.color}"><span role="img" aria-label="${esc(item)}">${meta.emoji}</span> ${esc(item)}</span>`;
|
|
352
315
|
}
|
|
353
316
|
return `<p>${esc(item)}</p>`;
|
|
@@ -407,10 +370,9 @@ class EmpathyMap extends HTMLElement {
|
|
|
407
370
|
}
|
|
408
371
|
|
|
409
372
|
_openEdit(key) {
|
|
410
|
-
this._editingQuadrants.add(key);
|
|
411
|
-
|
|
412
373
|
const section = this.shadowRoot?.querySelector(`.quadrant--${key}`);
|
|
413
374
|
if (!section) return;
|
|
375
|
+
this._editingQuadrants.add(key);
|
|
414
376
|
|
|
415
377
|
section.setAttribute('data-editing', '');
|
|
416
378
|
|
|
@@ -421,34 +383,66 @@ class EmpathyMap extends HTMLElement {
|
|
|
421
383
|
|
|
422
384
|
const textarea = /** @type {HTMLTextAreaElement | null} */ (section.querySelector('.quadrant__editor'));
|
|
423
385
|
if (textarea) {
|
|
424
|
-
|
|
425
|
-
const items = this.__quadrants?.[key];
|
|
426
|
-
if (items?.length) {
|
|
427
|
-
textarea.value = items.join('\n');
|
|
428
|
-
} else {
|
|
429
|
-
const slot = /** @type {HTMLSlotElement | null} */ (section.querySelector(`slot[name="${key}"]`));
|
|
430
|
-
if (slot) {
|
|
431
|
-
const assigned = slot.assignedElements();
|
|
432
|
-
if (assigned.length) {
|
|
433
|
-
/** @type {string[]} */
|
|
434
|
-
const lines = [];
|
|
435
|
-
assigned.forEach(el => {
|
|
436
|
-
const lis = el.querySelectorAll('li');
|
|
437
|
-
if (lis.length) {
|
|
438
|
-
lis.forEach(li => lines.push(li.textContent?.trim() ?? ''));
|
|
439
|
-
} else {
|
|
440
|
-
lines.push(el.textContent?.trim() ?? '');
|
|
441
|
-
}
|
|
442
|
-
});
|
|
443
|
-
textarea.value = lines.filter(Boolean).join('\n');
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
}
|
|
386
|
+
textarea.value = this.#editorSeed(key);
|
|
447
387
|
textarea.focus();
|
|
448
388
|
}
|
|
449
389
|
}
|
|
450
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Text an editor opens with: the saved items, else (for a quadrant never
|
|
393
|
+
* edited) its slotted light-DOM items, one per line.
|
|
394
|
+
* @param {string} key
|
|
395
|
+
* @returns {string}
|
|
396
|
+
*/
|
|
397
|
+
#editorSeed(key) {
|
|
398
|
+
const items = this.__quadrants?.[key];
|
|
399
|
+
if (items?.length) return items.join('\n');
|
|
400
|
+
if (this._editedQuadrants.has(key)) return '';
|
|
401
|
+
/** @type {string[]} */
|
|
402
|
+
const lines = [];
|
|
403
|
+
for (const el of this.children) {
|
|
404
|
+
if (el.getAttribute('slot') !== key) continue;
|
|
405
|
+
const lis = el.querySelectorAll('li');
|
|
406
|
+
if (lis.length) lis.forEach(li => lines.push(li.textContent?.trim() ?? ''));
|
|
407
|
+
else lines.push(el.textContent?.trim() ?? '');
|
|
408
|
+
}
|
|
409
|
+
return lines.filter(Boolean).join('\n');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Snapshot open editors (value, selection, focus) before the shadow
|
|
414
|
+
* tree is replaced.
|
|
415
|
+
* @returns {Map<string, { value: string, start: number, end: number, focused: boolean }>}
|
|
416
|
+
*/
|
|
417
|
+
#captureDrafts() {
|
|
418
|
+
const drafts = new Map();
|
|
419
|
+
const root = this.shadowRoot;
|
|
420
|
+
if (!root) return drafts;
|
|
421
|
+
for (const key of this._editingQuadrants) {
|
|
422
|
+
const ta = /** @type {HTMLTextAreaElement | null} */ (root.querySelector(`.quadrant__editor[data-quadrant="${key}"]`));
|
|
423
|
+
if (!ta) continue;
|
|
424
|
+
drafts.set(key, {
|
|
425
|
+
value: ta.value, start: ta.selectionStart, end: ta.selectionEnd, focused: root.activeElement === ta,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
return drafts;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** @param {Map<string, { value: string, start: number, end: number, focused: boolean }>} drafts */
|
|
432
|
+
#restoreDrafts(drafts) {
|
|
433
|
+
const root = this.shadowRoot;
|
|
434
|
+
if (!root) return;
|
|
435
|
+
for (const [key, d] of drafts) {
|
|
436
|
+
const ta = /** @type {HTMLTextAreaElement | null} */ (root.querySelector(`.quadrant__editor[data-quadrant="${key}"]`));
|
|
437
|
+
if (!ta) continue;
|
|
438
|
+
ta.value = d.value;
|
|
439
|
+
ta.setSelectionRange(d.start, d.end);
|
|
440
|
+
if (d.focused) ta.focus();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
451
444
|
_closeEdit(key) {
|
|
445
|
+
if (!this._editingQuadrants.has(key)) return;
|
|
452
446
|
const section = this.shadowRoot?.querySelector(`.quadrant--${key}`);
|
|
453
447
|
if (!section) return;
|
|
454
448
|
|
|
@@ -462,19 +456,12 @@ class EmpathyMap extends HTMLElement {
|
|
|
462
456
|
/* Ensure __quadrants object exists */
|
|
463
457
|
if (!this.__quadrants) this.__quadrants = {};
|
|
464
458
|
this.__quadrants[key] = items;
|
|
459
|
+
/* From now on this quadrant renders from data, even when emptied,
|
|
460
|
+
so later full renders don't resurrect the original slotted items. */
|
|
461
|
+
this._editedQuadrants.add(key);
|
|
465
462
|
|
|
466
|
-
/* Partially re-render front face content */
|
|
467
463
|
const contentEl = section.querySelector('.quadrant__content');
|
|
468
|
-
if (contentEl)
|
|
469
|
-
if (items.length) {
|
|
470
|
-
contentEl.innerHTML = key === 'feels'
|
|
471
|
-
? items.map(item => this._renderEmotion(item)).join('')
|
|
472
|
-
: items.map(item => `<p>${esc(item)}</p>`).join('');
|
|
473
|
-
} else {
|
|
474
|
-
const meta = QUADRANT_META[key];
|
|
475
|
-
contentEl.innerHTML = `<p class="placeholder">Add ${meta.label.toLowerCase()} items\u2026</p>`;
|
|
476
|
-
}
|
|
477
|
-
}
|
|
464
|
+
if (contentEl) contentEl.innerHTML = this._quadrantItemsHtml(key);
|
|
478
465
|
}
|
|
479
466
|
|
|
480
467
|
this._editingQuadrants.delete(key);
|
|
@@ -482,6 +469,12 @@ class EmpathyMap extends HTMLElement {
|
|
|
482
469
|
|
|
483
470
|
const front = section.querySelector('.quadrant__face--front');
|
|
484
471
|
const back = section.querySelector('.quadrant__face--back');
|
|
472
|
+
/* Move focus out of the face that is about to become inert, back to
|
|
473
|
+
the quadrant's edit button, so it doesn't fall to <body>. */
|
|
474
|
+
const active = this.shadowRoot?.activeElement;
|
|
475
|
+
if (back && active && back.contains(active)) {
|
|
476
|
+
/** @type {HTMLElement | null} */ (section.querySelector('.quadrant__edit-btn'))?.focus();
|
|
477
|
+
}
|
|
485
478
|
if (front) front.removeAttribute('inert');
|
|
486
479
|
if (back) back.setAttribute('inert', '');
|
|
487
480
|
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
Slotted content renders as normal children before upgrade.
|
|
4
4
|
-->
|
|
5
5
|
<empathy-map
|
|
6
|
-
title="Developer Onboarding"
|
|
7
6
|
persona="Sarah Chen"
|
|
8
|
-
persona-id="persona-sarah-chen"
|
|
9
|
-
|
|
7
|
+
persona-id="persona-sarah-chen">
|
|
8
|
+
<h2 slot="title">Developer Onboarding</h2>
|
|
9
|
+
<p slot="summary">Based on user interview round 2</p>
|
|
10
10
|
<ul slot="says">
|
|
11
11
|
<li>"I just want it to work"</li>
|
|
12
12
|
<li>"The documentation is confusing"</li>
|
|
@@ -58,26 +58,32 @@ Part of the **UX Planning Pack**.
|
|
|
58
58
|
| `data-assignee` | string | Assignee name |
|
|
59
59
|
| `data-milestone` | boolean | Render as diamond marker |
|
|
60
60
|
| `data-color` | string | Custom bar color |
|
|
61
|
+
| `data-story-ids` | string | Comma-separated linked user-story IDs |
|
|
62
|
+
| `data-item-ids` | string | Comma-separated linked work-item IDs |
|
|
63
|
+
|
|
64
|
+
Dates are `YYYY-MM-DD` calendar days in local time. Progress in `.tasks` / JSON: > 1 is a percentage, 0–1 a fraction; `<progress>` is `value / max`.
|
|
61
65
|
|
|
62
66
|
## Events
|
|
63
67
|
|
|
64
68
|
| Event | Detail | Description |
|
|
65
69
|
|-------|--------|-------------|
|
|
66
|
-
| `gantt-chart:
|
|
67
|
-
| `gantt-chart:
|
|
70
|
+
| `gantt-chart:upgraded` | — | Once per connect (also for an empty chart) |
|
|
71
|
+
| `gantt-chart:ready` | `{ taskCount, dateRange: { start, end } }` | After a render with tasks on connect, and after a `src` load (YYYY-MM-DD) |
|
|
72
|
+
| `gantt-chart:task-click` | `{ task }` | Bar/milestone clicked or activated with Enter/Space |
|
|
73
|
+
| `gantt-chart:tasks-changed` | `{ tasks, source: 'property' \| 'src' }` | After `.tasks` assignment or a `src` load |
|
|
68
74
|
|
|
69
75
|
## Failure Modes
|
|
70
76
|
|
|
71
77
|
- **No JavaScript:** Table renders normally with progress bars and time elements.
|
|
72
78
|
- **No CSS:** Table renders as default HTML table.
|
|
73
|
-
- **Keyboard Only:** Tab navigates bars; Enter
|
|
79
|
+
- **Keyboard Only:** Tab navigates bars; Enter or Space fires `task-click`.
|
|
74
80
|
- **Screen Reader:** Original table is preserved (visually hidden) for full table semantics.
|
|
75
81
|
|
|
76
82
|
## Accessibility
|
|
77
83
|
|
|
78
|
-
- Original table stays in DOM (sr-only) for screen reader access
|
|
79
|
-
- Bars
|
|
80
|
-
-
|
|
84
|
+
- Original table stays in DOM (sr-only) for screen reader access; for `.tasks`/`src` data a table is generated from the tasks
|
|
85
|
+
- Bars and milestones are focusable `role="button"` with an `aria-label` giving name, dates, status, progress and assignee
|
|
86
|
+
- Status is shown as text in the task list, not by colour alone
|
|
81
87
|
- `aria-live="polite"` region announces task selection
|
|
82
88
|
- `prefers-reduced-motion: reduce` disables transitions
|
|
83
89
|
- Print shows the original table, hides the visual chart
|
|
@@ -5,16 +5,32 @@
|
|
|
5
5
|
"description": "HTML-first Gantt chart using native table, progress, and time elements with progressive enhancement to an interactive timeline",
|
|
6
6
|
"htmlvalidate": { "flow": true, "permittedContent": ["@flow"] },
|
|
7
7
|
"attributes": [
|
|
8
|
-
{ "name": "title", "kind": "host-api", "purpose": "config", "type": "string", "description": "Chart heading" },
|
|
9
|
-
{ "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL
|
|
10
|
-
{ "name": "view", "kind": "host-api", "purpose": "config", "type": "
|
|
8
|
+
{ "name": "title", "kind": "host-api", "purpose": "config", "type": "string", "description": "Chart heading (a src JSON \"title\" sets it)" },
|
|
9
|
+
{ "name": "src", "kind": "host-api", "purpose": "config", "type": "string", "description": "URL of JSON task data ({ title?, tasks: [...] }). Fetched once on connect or when the attribute changes; the fetched tasks replace the child table. A newer value aborts an older in-flight request. Skipped on connect when .tasks was already assigned, and an in-flight request is aborted when .tasks is assigned (.tasks takes precedence)" },
|
|
10
|
+
{ "name": "view", "kind": "host-api", "purpose": "config", "type": "enum", "values": ["auto", "day", "week", "month", "quarter"], "default": "\"auto\"", "description": "Timeline granularity. auto picks day (≤ 3 weeks), week (≤ 90 days), month (≤ 1 year) or quarter" },
|
|
11
11
|
{ "name": "show-today", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Show vertical today marker line" },
|
|
12
12
|
{ "name": "show-progress", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Show progress fill inside bars" },
|
|
13
|
-
{ "name": "show-dependencies", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Draw dependency arrows between tasks" },
|
|
14
|
-
{ "name": "compact", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Reduced spacing
|
|
13
|
+
{ "name": "show-dependencies", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Draw dependency arrows between tasks (row positions are measured, so compact and grouped charts line up)" },
|
|
14
|
+
{ "name": "compact", "kind": "host-api", "purpose": "config", "type": "boolean", "description": "Reduced row height and spacing" }
|
|
15
|
+
],
|
|
16
|
+
"structure": [
|
|
17
|
+
{ "element": "<table>", "required": false, "description": "Task source: one <tbody> <tr> per task; first cell is the name, two <time datetime=\"YYYY-MM-DD\"> give start and end, an optional <progress> gives progress. Kept in the DOM, visually hidden, for screen readers and print" }
|
|
18
|
+
],
|
|
19
|
+
"childAttributes": [
|
|
20
|
+
{ "name": "data-task-id", "on": "tr", "type": "string", "description": "Unique task identifier (used by data-depends)" },
|
|
21
|
+
{ "name": "data-group", "on": "tr", "type": "string", "description": "Swim lane group; with two or more groups each gets a header row" },
|
|
22
|
+
{ "name": "data-depends", "on": "tr", "type": "string", "description": "Comma-separated task IDs this task depends on (drawn with show-dependencies)" },
|
|
23
|
+
{ "name": "data-status", "on": "tr", "type": "enum", "values": ["not-started", "in-progress", "done", "blocked"], "description": "Task status: shown as text in the task list and in the bar's accessible name; also tints the bar" },
|
|
24
|
+
{ "name": "data-assignee", "on": "tr", "type": "string", "description": "Assignee name (included in the bar's accessible name)" },
|
|
25
|
+
{ "name": "data-milestone", "on": "tr", "type": "boolean", "description": "Render a diamond marker at the start date instead of a duration bar" },
|
|
26
|
+
{ "name": "data-color", "on": "tr", "type": "string", "description": "Custom bar colour (any CSS colour)" },
|
|
27
|
+
{ "name": "data-story-ids", "on": "tr", "type": "string", "description": "Comma-separated linked user-story IDs (exposed as task.storyIds)" },
|
|
28
|
+
{ "name": "data-item-ids", "on": "tr", "type": "string", "description": "Comma-separated linked work-item IDs (exposed as task.itemIds)" }
|
|
15
29
|
],
|
|
16
30
|
"events": [
|
|
17
|
-
{ "name": "gantt-chart:
|
|
18
|
-
{ "name": "gantt-chart:
|
|
31
|
+
{ "name": "gantt-chart:upgraded", "detail": "", "description": "Once per connect, in a microtask after setup; a safe signal for the first .tasks assignment (fires for an empty chart too)" },
|
|
32
|
+
{ "name": "gantt-chart:ready", "detail": "{ taskCount, dateRange: { start, end } }", "description": "Once per element: after the first connect render that has tasks (table or .tasks), or else after the first src load. Not re-fired on reconnect or by later src loads (those fire tasks-changed with source 'src'). dateRange values are YYYY-MM-DD" },
|
|
33
|
+
{ "name": "gantt-chart:task-click", "detail": "{ task }", "description": "When a bar or milestone is clicked or activated with Enter/Space" },
|
|
34
|
+
{ "name": "gantt-chart:tasks-changed", "detail": "{ tasks, source: 'property' | 'src' }", "description": "After .tasks is assigned (source 'property') or src data loads (source 'src'), with the normalized tasks" }
|
|
19
35
|
]
|
|
20
36
|
}
|