@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
|
@@ -24,11 +24,17 @@
|
|
|
24
24
|
* @attr {string} story-id - Ticket or story identifier
|
|
25
25
|
* @attr {string} detail - full | compact | minimal
|
|
26
26
|
* @attr {boolean} compact - Alias for detail="compact"
|
|
27
|
-
* @attr {string} src - URL to JSON data
|
|
27
|
+
* @attr {string} src - URL to JSON data (fetched once per value)
|
|
28
28
|
*
|
|
29
|
-
* @fires story-ready
|
|
30
|
-
* @fires status-changed
|
|
31
|
-
* @fires priority-changed
|
|
29
|
+
* @fires story-ready - Once, after the first render (after the src data renders when src is set)
|
|
30
|
+
* @fires status-changed - Fired when updateStatus() is called
|
|
31
|
+
* @fires priority-changed - Fired when updatePriority() is called
|
|
32
|
+
* @fires user-story:upgraded - Once, after first connect
|
|
33
|
+
* @fires user-story:data-changed - After .data assignment, detail: { data, source: 'property' }
|
|
34
|
+
*
|
|
35
|
+
* Keyboard: a minimal card is a button (Enter / Space open the detail
|
|
36
|
+
* dialog). On a board (directly inside a <drag-surface>) the host is the
|
|
37
|
+
* single tab stop: Enter opens the dialog, Space grabs for reordering.
|
|
32
38
|
*
|
|
33
39
|
* @example
|
|
34
40
|
* <user-story
|
|
@@ -49,9 +55,16 @@
|
|
|
49
55
|
|
|
50
56
|
import { styles } from './styles.js';
|
|
51
57
|
import { registerComponent } from '../../lib/bundle-registry.js';
|
|
52
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
esc, lucideSvg, UX_ICONS, UXShadowElement, PRIORITIES, STATUSES, lookup,
|
|
60
|
+
fillSlot, slottedChild, detailLevel, markEmptySections,
|
|
61
|
+
} from '../_ux-base.js';
|
|
62
|
+
|
|
63
|
+
let dialogSeq = 0;
|
|
64
|
+
|
|
65
|
+
class UserStory extends UXShadowElement {
|
|
66
|
+
static readyEvent = 'story-ready';
|
|
53
67
|
|
|
54
|
-
class UserStory extends HTMLElement {
|
|
55
68
|
static get observedAttributes() {
|
|
56
69
|
return [
|
|
57
70
|
'persona-id', 'priority', 'points',
|
|
@@ -59,42 +72,18 @@ class UserStory extends HTMLElement {
|
|
|
59
72
|
];
|
|
60
73
|
}
|
|
61
74
|
|
|
62
|
-
static PRIORITIES =
|
|
63
|
-
|
|
64
|
-
high: { label: 'High', color: '#ea580c', bg: 'rgba(234, 88, 12, 0.1)' },
|
|
65
|
-
medium: { label: 'Medium', color: '#ca8a04', bg: 'rgba(202, 138, 4, 0.1)' },
|
|
66
|
-
low: { label: 'Low', color: '#16a34a', bg: 'rgba(22, 163, 74, 0.1)' }
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
static STATUSES = {
|
|
70
|
-
backlog: { label: 'Backlog', color: '#6b7280', bg: 'rgba(107, 114, 128, 0.1)' },
|
|
71
|
-
'to-do': { label: 'To Do', color: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' },
|
|
72
|
-
'in-progress': { label: 'In Progress', color: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.1)' },
|
|
73
|
-
review: { label: 'Review', color: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' },
|
|
74
|
-
done: { label: 'Done', color: '#22c55e', bg: 'rgba(34, 197, 94, 0.1)' }
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
#slotCache = new Map();
|
|
75
|
+
static PRIORITIES = PRIORITIES;
|
|
76
|
+
static STATUSES = STATUSES;
|
|
78
77
|
|
|
79
78
|
/** @type {MutationObserver | null} */
|
|
80
79
|
#personaObserver = null;
|
|
81
80
|
|
|
81
|
+
/** @type {HTMLDialogElement | null} */
|
|
82
|
+
#dialog = null;
|
|
83
|
+
|
|
82
84
|
constructor() {
|
|
83
85
|
super();
|
|
84
|
-
this.
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
#cacheSlotValues() {
|
|
88
|
-
for (const child of [...this.children]) {
|
|
89
|
-
const slotName = child.getAttribute('slot');
|
|
90
|
-
if (slotName) {
|
|
91
|
-
this.#slotCache.set(slotName, child.textContent.trim());
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
_resolve(attr) {
|
|
97
|
-
return this.getAttribute(attr) || this.#slotCache.get(attr) || '';
|
|
86
|
+
this.addEventListener('keydown', this.#onBoardKeydown);
|
|
98
87
|
}
|
|
99
88
|
|
|
100
89
|
/* ── Data API ──────────────────────────────────── */
|
|
@@ -125,19 +114,12 @@ class UserStory extends HTMLElement {
|
|
|
125
114
|
* Idempotent for repeat calls.
|
|
126
115
|
*/
|
|
127
116
|
set data(value) {
|
|
128
|
-
|
|
129
|
-
this._applyData(value);
|
|
130
|
-
this.#cacheSlotValues();
|
|
131
|
-
if (this.shadowRoot) this.#render();
|
|
132
|
-
this.dispatchEvent(new CustomEvent('user-story:data-changed', {
|
|
133
|
-
detail: { data: this.data, source: 'property' },
|
|
134
|
-
bubbles: true, composed: true,
|
|
135
|
-
}));
|
|
117
|
+
this._setData(value);
|
|
136
118
|
}
|
|
137
119
|
|
|
138
120
|
/**
|
|
139
121
|
* Apply a data record to attributes + slotted children. Used by both the
|
|
140
|
-
* .data setter and
|
|
122
|
+
* .data setter and the src loader. Slotted children are created only when absent (authored content wins). No render — caller decides.
|
|
141
123
|
* @param {Record<string, unknown>} data
|
|
142
124
|
*/
|
|
143
125
|
_applyData(data) {
|
|
@@ -148,92 +130,51 @@ class UserStory extends HTMLElement {
|
|
|
148
130
|
if (data[jsonKey] != null) this.setAttribute(attr, String(data[jsonKey]));
|
|
149
131
|
}
|
|
150
132
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const el = document.createElement('span');
|
|
159
|
-
el.slot = 'action';
|
|
160
|
-
el.textContent = String(data.action);
|
|
161
|
-
this.appendChild(el);
|
|
162
|
-
}
|
|
163
|
-
if (data.benefit && !this.querySelector('[slot="benefit"]')) {
|
|
164
|
-
const el = document.createElement('span');
|
|
165
|
-
el.slot = 'benefit';
|
|
166
|
-
el.textContent = String(data.benefit);
|
|
167
|
-
this.appendChild(el);
|
|
168
|
-
}
|
|
169
|
-
if (data.title && !this.querySelector('[slot="title"]')) {
|
|
170
|
-
const el = document.createElement('h3');
|
|
171
|
-
el.slot = 'title';
|
|
172
|
-
el.textContent = String(data.title);
|
|
173
|
-
this.appendChild(el);
|
|
174
|
-
}
|
|
175
|
-
for (const key of ['acceptance-criteria', 'tasks', 'notes']) {
|
|
176
|
-
const jsonKey = key === 'acceptance-criteria' ? 'acceptanceCriteria' : key;
|
|
177
|
-
if (data[jsonKey] && !this.querySelector(`[slot="${key}"]`)) {
|
|
178
|
-
if (Array.isArray(data[jsonKey])) {
|
|
179
|
-
const ul = document.createElement('ul');
|
|
180
|
-
ul.slot = key;
|
|
181
|
-
for (const item of data[jsonKey]) {
|
|
182
|
-
const li = document.createElement('li');
|
|
183
|
-
li.textContent = String(item);
|
|
184
|
-
ul.appendChild(li);
|
|
185
|
-
}
|
|
186
|
-
this.appendChild(ul);
|
|
187
|
-
} else {
|
|
188
|
-
const p = document.createElement('p');
|
|
189
|
-
p.slot = key;
|
|
190
|
-
p.textContent = String(data[jsonKey]);
|
|
191
|
-
this.appendChild(p);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
133
|
+
fillSlot(this, 'persona', data.persona, { tag: 'span' });
|
|
134
|
+
fillSlot(this, 'action', data.action, { tag: 'span' });
|
|
135
|
+
fillSlot(this, 'benefit', data.benefit, { tag: 'span' });
|
|
136
|
+
fillSlot(this, 'title', data.title, { tag: 'h3' });
|
|
137
|
+
fillSlot(this, 'acceptance-criteria', data.acceptanceCriteria);
|
|
138
|
+
fillSlot(this, 'tasks', data.tasks);
|
|
139
|
+
fillSlot(this, 'notes', data.notes);
|
|
195
140
|
}
|
|
196
141
|
|
|
197
|
-
|
|
198
|
-
if (!url) return;
|
|
199
|
-
try {
|
|
200
|
-
const res = await fetch(url);
|
|
201
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
202
|
-
const data = await res.json();
|
|
203
|
-
this._applyData(data);
|
|
204
|
-
this.#cacheSlotValues();
|
|
205
|
-
this.#render();
|
|
206
|
-
} catch (err) {
|
|
207
|
-
console.warn(`[user-story] Failed to load src="${url}":`, err);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
142
|
+
/* ── Lifecycle (see UXShadowElement) ──────────── */
|
|
210
143
|
|
|
211
|
-
|
|
212
|
-
this.#cacheSlotValues();
|
|
144
|
+
_setup() {
|
|
213
145
|
if (this.storyId && !this.id && !this.hasAttribute('data-detail-clone')) this.id = this.storyId;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
this.#render();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
_connect() {
|
|
218
149
|
this.#observeLinkedPersona();
|
|
219
|
-
|
|
150
|
+
// Moved onto / off a board: switch the minimal card's tab stop.
|
|
151
|
+
this.#syncBoardFocus();
|
|
220
152
|
}
|
|
221
153
|
|
|
222
|
-
|
|
223
|
-
this.removeAttribute('data-upgraded');
|
|
154
|
+
_disconnect() {
|
|
224
155
|
this.#personaObserver?.disconnect();
|
|
225
156
|
this.#personaObserver = null;
|
|
157
|
+
// A move (disconnect + connect in one task) keeps the open dialog;
|
|
158
|
+
// a real removal closes and removes it.
|
|
159
|
+
queueMicrotask(() => { if (!this.isConnected) this.#removeDialog(); });
|
|
226
160
|
}
|
|
227
161
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
162
|
+
/** @param {string} name */
|
|
163
|
+
_attributeEffects(name) {
|
|
164
|
+
// Also runs after a .data / src record sets persona-id.
|
|
165
|
+
if (name === 'persona-id') this.#observeLinkedPersona();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_readyDetail() {
|
|
169
|
+
return {
|
|
170
|
+
id: this.storyId,
|
|
171
|
+
persona: this.persona,
|
|
172
|
+
action: this.action,
|
|
173
|
+
benefit: this.benefit,
|
|
174
|
+
priority: this.priority,
|
|
175
|
+
status: this.status,
|
|
176
|
+
points: this.points,
|
|
177
|
+
};
|
|
237
178
|
}
|
|
238
179
|
|
|
239
180
|
/**
|
|
@@ -249,7 +190,7 @@ class UserStory extends HTMLElement {
|
|
|
249
190
|
const scope = root && typeof root.getElementById === 'function' ? root : document;
|
|
250
191
|
const persona = scope.getElementById(this.personaId);
|
|
251
192
|
if (!persona || persona.tagName !== 'USER-PERSONA') return;
|
|
252
|
-
this.#personaObserver = new MutationObserver(() => this
|
|
193
|
+
this.#personaObserver = new MutationObserver(() => this._render());
|
|
253
194
|
this.#personaObserver.observe(persona, { attributes: true, attributeFilter: ['role'] });
|
|
254
195
|
}
|
|
255
196
|
|
|
@@ -271,8 +212,7 @@ class UserStory extends HTMLElement {
|
|
|
271
212
|
|
|
272
213
|
/** Persona text the author supplied directly via slot or .data (no link resolution). */
|
|
273
214
|
#explicitPersona() {
|
|
274
|
-
|
|
275
|
-
return slotted?.textContent?.trim() || this.#slotCache.get('persona') || '';
|
|
215
|
+
return this._slotText('persona');
|
|
276
216
|
}
|
|
277
217
|
|
|
278
218
|
/**
|
|
@@ -302,14 +242,12 @@ class UserStory extends HTMLElement {
|
|
|
302
242
|
|
|
303
243
|
/** Read action from slotted element or cache */
|
|
304
244
|
get action() {
|
|
305
|
-
|
|
306
|
-
return slotted?.textContent?.trim() || this.#slotCache.get('action') || '';
|
|
245
|
+
return this._slotText('action');
|
|
307
246
|
}
|
|
308
247
|
|
|
309
248
|
/** Read benefit from slotted element or cache */
|
|
310
249
|
get benefit() {
|
|
311
|
-
|
|
312
|
-
return slotted?.textContent?.trim() || this.#slotCache.get('benefit') || '';
|
|
250
|
+
return this._slotText('benefit');
|
|
313
251
|
}
|
|
314
252
|
|
|
315
253
|
get priority() {
|
|
@@ -334,8 +272,7 @@ class UserStory extends HTMLElement {
|
|
|
334
272
|
|
|
335
273
|
/** Read title from slotted heading or cache */
|
|
336
274
|
get storyTitle() {
|
|
337
|
-
|
|
338
|
-
return slotted?.textContent?.trim() || this.#slotCache.get('title') || '';
|
|
275
|
+
return this._slotText('title');
|
|
339
276
|
}
|
|
340
277
|
|
|
341
278
|
/** Label for minimal mode: title if set, otherwise truncated action */
|
|
@@ -350,15 +287,13 @@ class UserStory extends HTMLElement {
|
|
|
350
287
|
}
|
|
351
288
|
|
|
352
289
|
get _detailLevel() {
|
|
353
|
-
|
|
354
|
-
if (this.hasAttribute('compact')) return 'compact';
|
|
355
|
-
return 'full';
|
|
290
|
+
return detailLevel(this);
|
|
356
291
|
}
|
|
357
292
|
|
|
358
293
|
// ── Public API ───────────────────────────────────────────────────
|
|
359
294
|
|
|
360
295
|
updateStatus(newStatus) {
|
|
361
|
-
if (
|
|
296
|
+
if (Object.hasOwn(STATUSES, newStatus)) {
|
|
362
297
|
this.setAttribute('status', newStatus);
|
|
363
298
|
this.dispatchEvent(new CustomEvent('status-changed', {
|
|
364
299
|
detail: { status: newStatus, storyId: this.storyId },
|
|
@@ -369,7 +304,7 @@ class UserStory extends HTMLElement {
|
|
|
369
304
|
}
|
|
370
305
|
|
|
371
306
|
updatePriority(newPriority) {
|
|
372
|
-
if (
|
|
307
|
+
if (Object.hasOwn(PRIORITIES, newPriority)) {
|
|
373
308
|
this.setAttribute('priority', newPriority);
|
|
374
309
|
this.dispatchEvent(new CustomEvent('priority-changed', {
|
|
375
310
|
detail: { priority: newPriority, storyId: this.storyId },
|
|
@@ -379,21 +314,24 @@ class UserStory extends HTMLElement {
|
|
|
379
314
|
}
|
|
380
315
|
}
|
|
381
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Open a modal <dialog> showing this story at full detail. Each story
|
|
319
|
+
* owns at most one dialog; it is removed from the document when it
|
|
320
|
+
* closes or when the story is removed.
|
|
321
|
+
*/
|
|
382
322
|
showDetail() {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
dialog.id = dialogId;
|
|
388
|
-
dialog.setAttribute('data-size', 'l');
|
|
389
|
-
document.body.appendChild(dialog);
|
|
390
|
-
}
|
|
323
|
+
this.#removeDialog();
|
|
324
|
+
const dialog = document.createElement('dialog');
|
|
325
|
+
dialog.id = this.#dialogId();
|
|
326
|
+
dialog.setAttribute('data-size', 'l');
|
|
391
327
|
|
|
392
328
|
const form = document.createElement('form');
|
|
393
329
|
form.method = 'dialog';
|
|
394
330
|
const header = document.createElement('header');
|
|
395
|
-
const title = document.createElement('
|
|
331
|
+
const title = document.createElement('h2');
|
|
332
|
+
title.id = `${dialog.id}-title`;
|
|
396
333
|
title.textContent = this.storyTitle || this.storyId || 'Story Detail';
|
|
334
|
+
dialog.setAttribute('aria-labelledby', title.id);
|
|
397
335
|
const closeBtn = document.createElement('button');
|
|
398
336
|
closeBtn.type = 'submit';
|
|
399
337
|
closeBtn.setAttribute('aria-label', 'Close');
|
|
@@ -407,29 +345,83 @@ class UserStory extends HTMLElement {
|
|
|
407
345
|
if (attr === 'detail' || attr === 'compact' || attr === 'data-upgraded' || attr === 'draggable' || attr === 'data-id' || attr === 'data-quadrant') continue;
|
|
408
346
|
full.setAttribute(attr, this.getAttribute(attr) ?? '');
|
|
409
347
|
}
|
|
410
|
-
const hasSlottedContent = [...this.children].some(c => c.getAttribute('slot')
|
|
348
|
+
const hasSlottedContent = [...this.children].some(c => c.getAttribute('slot'));
|
|
411
349
|
full.setAttribute('detail', hasSlottedContent ? 'full' : 'compact');
|
|
412
350
|
full.removeAttribute('id');
|
|
413
351
|
full.setAttribute('data-detail-clone', ''); // keep the clone out of id/persona lookups
|
|
414
|
-
for (const child of [...this.children])
|
|
415
|
-
if (child.tagName === 'DIALOG') continue;
|
|
416
|
-
full.appendChild(child.cloneNode(true));
|
|
417
|
-
}
|
|
352
|
+
for (const child of [...this.children]) full.appendChild(child.cloneNode(true));
|
|
418
353
|
section.appendChild(full);
|
|
419
354
|
|
|
420
355
|
form.appendChild(header);
|
|
421
356
|
form.appendChild(section);
|
|
422
|
-
dialog.innerHTML = '';
|
|
423
357
|
dialog.appendChild(form);
|
|
424
|
-
dialog.addEventListener('close', () => {
|
|
358
|
+
dialog.addEventListener('close', () => {
|
|
359
|
+
if (this.#dialog === dialog) this.#dialog = null;
|
|
360
|
+
dialog.remove();
|
|
361
|
+
}, { once: true });
|
|
362
|
+
document.body.appendChild(dialog);
|
|
363
|
+
this.#dialog = dialog;
|
|
425
364
|
dialog.showModal();
|
|
426
365
|
}
|
|
427
366
|
|
|
367
|
+
/** `story-dialog-<story-id>`, made unique if another dialog already uses it. */
|
|
368
|
+
#dialogId() {
|
|
369
|
+
const base = `story-dialog-${this.storyId || this.id || 'story'}`;
|
|
370
|
+
let id = base;
|
|
371
|
+
while (document.getElementById(id)) id = `${base}-${++dialogSeq}`;
|
|
372
|
+
return id;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
#removeDialog() {
|
|
376
|
+
const dialog = this.#dialog;
|
|
377
|
+
if (!dialog) return;
|
|
378
|
+
this.#dialog = null;
|
|
379
|
+
if (dialog.open) dialog.close();
|
|
380
|
+
dialog.remove();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── Board keyboard model ─────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* True when this card is a direct child of a <drag-surface> (kanban-board,
|
|
387
|
+
* story-map, impact-effort), which makes the host its focusable,
|
|
388
|
+
* keyboard-reorderable list item.
|
|
389
|
+
*/
|
|
390
|
+
get #onBoard() {
|
|
391
|
+
return this.parentElement?.localName === 'drag-surface';
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* On a board the host (tabindex 0, set by drag-surface) is the card's
|
|
396
|
+
* only tab stop, so the minimal card's inner button leaves the tab order.
|
|
397
|
+
* Off a board the button is the tab stop and works as a normal button.
|
|
398
|
+
*/
|
|
399
|
+
#syncBoardFocus() {
|
|
400
|
+
const button = /** @type {HTMLButtonElement | null | undefined} */ (this.shadowRoot?.querySelector('.story-card--minimal'));
|
|
401
|
+
if (button) button.tabIndex = this.#onBoard ? -1 : 0;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Board keyboard model for a minimal card: Enter opens the detail
|
|
406
|
+
* dialog; Space (drag-surface's grab key) grabs and drops; while grabbed,
|
|
407
|
+
* Enter drops and arrows move. Runs on the host, before the drag-surface's
|
|
408
|
+
* own handler, and stops Enter there so it does not start a grab.
|
|
409
|
+
* @param {KeyboardEvent} e
|
|
410
|
+
*/
|
|
411
|
+
#onBoardKeydown = (e) => {
|
|
412
|
+
if (e.key !== 'Enter' || e.defaultPrevented || !this.#onBoard) return;
|
|
413
|
+
if (this._detailLevel !== 'minimal' || this.getAttribute('aria-grabbed') === 'true') return;
|
|
414
|
+
if (!this.shadowRoot?.querySelector('.story-card--minimal')) return;
|
|
415
|
+
e.preventDefault(); // the inner button must not also click
|
|
416
|
+
e.stopPropagation(); // keep drag-surface from starting a grab
|
|
417
|
+
this.showDetail();
|
|
418
|
+
};
|
|
419
|
+
|
|
428
420
|
// ── Private ──────────────────────────────────────────────────────
|
|
429
421
|
|
|
430
|
-
|
|
431
|
-
const priorityInfo =
|
|
432
|
-
const statusInfo =
|
|
422
|
+
_render() {
|
|
423
|
+
const priorityInfo = lookup(PRIORITIES, this.priority, 'medium');
|
|
424
|
+
const statusInfo = lookup(STATUSES, this.status, 'backlog');
|
|
433
425
|
const level = this._detailLevel;
|
|
434
426
|
const ariaLabel = this.storyId
|
|
435
427
|
? `User story: ${esc(this.storyId)}`
|
|
@@ -437,25 +429,24 @@ class UserStory extends HTMLElement {
|
|
|
437
429
|
|
|
438
430
|
if (!this.shadowRoot) return;
|
|
439
431
|
if (level === 'minimal') {
|
|
432
|
+
const label = this._minimalLabel || 'Untitled story';
|
|
433
|
+
const buttonLabel = this.storyId ? `${this.storyId}: ${label}` : label;
|
|
434
|
+
// A real button: focusable, Enter/Space activate it natively, and
|
|
435
|
+
// aria-haspopup tells screen readers it opens a dialog.
|
|
440
436
|
this.shadowRoot.innerHTML = `
|
|
441
437
|
<style>${styles}</style>
|
|
442
|
-
<
|
|
443
|
-
|
|
444
|
-
<
|
|
438
|
+
<button type="button" class="story-card story-card--minimal" part="card"
|
|
439
|
+
aria-haspopup="dialog" aria-label="${esc(buttonLabel)}">
|
|
440
|
+
<span class="story-body">
|
|
445
441
|
${this.storyId ? `<span class="story-id">${esc(this.storyId)}</span>` : ''}
|
|
446
|
-
<
|
|
442
|
+
<span class="story-title-wrap">
|
|
447
443
|
<slot name="title"><span class="story-title-fallback">${esc(this._minimalLabel || '[describe the action]')}</span></slot>
|
|
448
|
-
</
|
|
449
|
-
</
|
|
450
|
-
</
|
|
444
|
+
</span>
|
|
445
|
+
</span>
|
|
446
|
+
</button>
|
|
451
447
|
`;
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
card.addEventListener('click', () => this.showDetail());
|
|
455
|
-
card.addEventListener('keydown', (/** @type {Event} */ e) => {
|
|
456
|
-
const ke = /** @type {KeyboardEvent} */ (e);
|
|
457
|
-
if (ke.key === 'Enter' || ke.key === ' ') { ke.preventDefault(); this.showDetail(); }
|
|
458
|
-
});
|
|
448
|
+
this.shadowRoot.querySelector('.story-card--minimal')?.addEventListener('click', () => this.showDetail());
|
|
449
|
+
this.#syncBoardFocus();
|
|
459
450
|
} else {
|
|
460
451
|
this.shadowRoot.innerHTML = `
|
|
461
452
|
<style>${styles}</style>
|
|
@@ -473,7 +464,7 @@ class UserStory extends HTMLElement {
|
|
|
473
464
|
<span class="status-badge" part="status"
|
|
474
465
|
style="color: ${statusInfo.color}; background: ${statusInfo.bg};"
|
|
475
466
|
>${esc(statusInfo.label)}</span>
|
|
476
|
-
${this.points ? `<span class="points-badge" part="points">${esc(this.points)}</span>` : ''}
|
|
467
|
+
${this.points ? `<span class="points-badge" part="points">${esc(this.points)}<span class="visually-hidden"> story points</span></span>` : ''}
|
|
477
468
|
</div>
|
|
478
469
|
</header>
|
|
479
470
|
|
|
@@ -484,7 +475,7 @@ class UserStory extends HTMLElement {
|
|
|
484
475
|
? `<a class="persona-text persona-text--link" href="#${esc(this.personaId)}">${lucideSvg(UX_ICONS.user)} <slot name="persona"><span>${esc(this.#linkedPersonaRole() || 'user')}</span></slot></a>`
|
|
485
476
|
: `<span class="persona-text"><slot name="persona"><span>user</span></slot></span>`},
|
|
486
477
|
<span class="keyword">I want</span>
|
|
487
|
-
<span class="action-text"><slot name="action"><span>[describe the action]</span></slot></span>${this.benefit || this
|
|
478
|
+
<span class="action-text"><slot name="action"><span>[describe the action]</span></slot></span>${this.benefit || slottedChild(this, 'benefit') ? `
|
|
488
479
|
<span class="keyword">so that</span>
|
|
489
480
|
<span class="benefit-text"><slot name="benefit"></slot></span>` : ''}
|
|
490
481
|
</p>
|
|
@@ -496,7 +487,7 @@ class UserStory extends HTMLElement {
|
|
|
496
487
|
<div class="section-icon acceptance">
|
|
497
488
|
<svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
|
498
489
|
</div>
|
|
499
|
-
<
|
|
490
|
+
<h3 class="section-title">Acceptance Criteria</h3>
|
|
500
491
|
</div>
|
|
501
492
|
<div class="section-content">
|
|
502
493
|
<slot name="acceptance-criteria">
|
|
@@ -510,7 +501,7 @@ class UserStory extends HTMLElement {
|
|
|
510
501
|
<div class="section-icon tasks">
|
|
511
502
|
<svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>
|
|
512
503
|
</div>
|
|
513
|
-
<
|
|
504
|
+
<h3 class="section-title">Tasks</h3>
|
|
514
505
|
</div>
|
|
515
506
|
<div class="section-content">
|
|
516
507
|
<slot name="tasks">
|
|
@@ -524,7 +515,7 @@ class UserStory extends HTMLElement {
|
|
|
524
515
|
<div class="section-icon notes">
|
|
525
516
|
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
|
526
517
|
</div>
|
|
527
|
-
<
|
|
518
|
+
<h3 class="section-title">Notes</h3>
|
|
528
519
|
</div>
|
|
529
520
|
<div class="section-content">
|
|
530
521
|
<slot name="notes">
|
|
@@ -536,30 +527,8 @@ class UserStory extends HTMLElement {
|
|
|
536
527
|
</article>
|
|
537
528
|
`;
|
|
538
529
|
|
|
539
|
-
if (level === 'compact')
|
|
540
|
-
const sections = this.shadowRoot.querySelectorAll('.section');
|
|
541
|
-
sections.forEach(section => {
|
|
542
|
-
const slot = section.querySelector('slot');
|
|
543
|
-
if (slot && slot.assignedNodes().length === 0) {
|
|
544
|
-
section.setAttribute('data-empty', '');
|
|
545
|
-
}
|
|
546
|
-
});
|
|
547
|
-
}
|
|
530
|
+
if (level === 'compact') markEmptySections(this.shadowRoot, '.section');
|
|
548
531
|
}
|
|
549
|
-
|
|
550
|
-
this.dispatchEvent(new CustomEvent('story-ready', {
|
|
551
|
-
detail: {
|
|
552
|
-
id: this.storyId,
|
|
553
|
-
persona: this.persona,
|
|
554
|
-
action: this.action,
|
|
555
|
-
benefit: this.benefit,
|
|
556
|
-
priority: this.priority,
|
|
557
|
-
status: this.status,
|
|
558
|
-
points: this.points
|
|
559
|
-
},
|
|
560
|
-
bubbles: true,
|
|
561
|
-
composed: true
|
|
562
|
-
}));
|
|
563
532
|
}
|
|
564
533
|
}
|
|
565
534
|
|
|
@@ -210,6 +210,7 @@ const styles = `
|
|
|
210
210
|
.section-icon.tasks { background: var(--color-accent, #8b5cf6); }
|
|
211
211
|
|
|
212
212
|
.section-title {
|
|
213
|
+
margin: 0;
|
|
213
214
|
font-size: var(--_font-xs);
|
|
214
215
|
font-weight: 600;
|
|
215
216
|
color: var(--_muted);
|
|
@@ -274,13 +275,39 @@ const styles = `
|
|
|
274
275
|
margin: 0 !important;
|
|
275
276
|
}
|
|
276
277
|
|
|
278
|
+
.visually-hidden {
|
|
279
|
+
position: absolute;
|
|
280
|
+
inline-size: 1px;
|
|
281
|
+
block-size: 1px;
|
|
282
|
+
padding: 0;
|
|
283
|
+
margin: -1px;
|
|
284
|
+
overflow: hidden;
|
|
285
|
+
clip-path: inset(50%);
|
|
286
|
+
white-space: nowrap;
|
|
287
|
+
border: 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
277
290
|
.story-title-fallback {
|
|
278
291
|
font-size: var(--_font-sm);
|
|
279
292
|
font-weight: 600;
|
|
280
293
|
color: var(--_text);
|
|
281
294
|
}
|
|
282
295
|
|
|
283
|
-
/* Minimal detail level */
|
|
296
|
+
/* Minimal detail level — the card is a <button> that opens the detail dialog */
|
|
297
|
+
button.story-card--minimal {
|
|
298
|
+
display: block;
|
|
299
|
+
inline-size: 100%;
|
|
300
|
+
margin: 0;
|
|
301
|
+
font: inherit;
|
|
302
|
+
color: inherit;
|
|
303
|
+
text-align: start;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.story-card--minimal .story-body,
|
|
307
|
+
.story-card--minimal .story-title-wrap {
|
|
308
|
+
display: block;
|
|
309
|
+
}
|
|
310
|
+
|
|
284
311
|
:host([detail="minimal"]) .story-card {
|
|
285
312
|
padding: var(--_space-xs);
|
|
286
313
|
}
|
|
@@ -20,13 +20,13 @@ Part of the **UX Planning Pack**. Works as a child of `<kanban-board>`, `<story-
|
|
|
20
20
|
```html
|
|
21
21
|
<work-item
|
|
22
22
|
item-id="PROJ-42"
|
|
23
|
-
title="Implement search API"
|
|
24
23
|
type="task"
|
|
25
24
|
status="in-progress"
|
|
26
25
|
priority="high"
|
|
27
26
|
assignee="Sarah Chen"
|
|
28
27
|
estimate="5"
|
|
29
28
|
story-ids="PROJ-101,PROJ-102">
|
|
29
|
+
<h3 slot="title">Implement search API</h3>
|
|
30
30
|
<p slot="description">Build the search endpoint.</p>
|
|
31
31
|
<ul slot="checklist">
|
|
32
32
|
<li>Schema design</li>
|
|
@@ -41,7 +41,6 @@ Part of the **UX Planning Pack**. Works as a child of `<kanban-board>`, `<story-
|
|
|
41
41
|
| Attribute | Type | Default | Description |
|
|
42
42
|
|-----------|------|---------|-------------|
|
|
43
43
|
| `item-id` | string | — | Unique identifier (also sets HTML `id`) |
|
|
44
|
-
| `title` | string | — | Work item title |
|
|
45
44
|
| `type` | string | `"task"` | `task`, `bug`, `chore`, `spike`, `feature` |
|
|
46
45
|
| `priority` | string | `"medium"` | `critical`, `high`, `medium`, `low` |
|
|
47
46
|
| `status` | string | `"backlog"` | `backlog`, `to-do`, `in-progress`, `review`, `done`, `blocked` |
|
|
@@ -56,6 +55,7 @@ Part of the **UX Planning Pack**. Works as a child of `<kanban-board>`, `<story-
|
|
|
56
55
|
|
|
57
56
|
| Slot | Description |
|
|
58
57
|
|------|-------------|
|
|
58
|
+
| `title` | Work item heading (e.g. `<h3>`); there is no `title` attribute |
|
|
59
59
|
| `description` | Work item description |
|
|
60
60
|
| `checklist` | Subtasks or checklist |
|
|
61
61
|
| `notes` | Additional notes |
|
|
@@ -64,7 +64,7 @@ Part of the **UX Planning Pack**. Works as a child of `<kanban-board>`, `<story-
|
|
|
64
64
|
|
|
65
65
|
| Event | Detail |
|
|
66
66
|
|-------|--------|
|
|
67
|
-
| `work-item:ready` | `{ itemId, title, type, priority, status }` |
|
|
67
|
+
| `work-item:ready` | `{ itemId, title, type, priority, status }` (once, after the first render) |
|
|
68
68
|
| `work-item:status` | `{ status, itemId }` |
|
|
69
69
|
| `work-item:priority` | `{ priority, itemId }` |
|
|
70
70
|
|