@profpowell/vb-project-planning 0.2.0 → 0.2.1
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/dist/vb-project-planning.css +14 -1
- package/dist/vb-project-planning.js +144 -20
- package/dist/vb-project-planning.min.css +1 -1
- package/dist/vb-project-planning.min.js +91 -91
- package/package.json +1 -1
- package/src/web-components/iron-triangle/_capacity.js +1 -1
- package/src/web-components/iron-triangle/_triangle-geometry.js +1 -1
- package/src/web-components/iron-triangle/api.json +2 -2
- package/src/web-components/iron-triangle/logic.js +47 -9
- package/src/web-components/iron-triangle/styles.css +22 -1
- package/src/web-components/quality-target/api.json +2 -3
- package/src/web-components/quality-target/logic.js +85 -13
- package/src/web-components/requirement-card/api.json +1 -1
- package/src/web-components/requirement-card/logic.js +26 -2
- package/src/web-components/requirement-card/static.html +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profpowell/vb-project-planning",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Project- and UX-planning Web Components for Vanilla Breeze: personas, stories, journeys, empathy maps, impact/effort and quadrant grids, risk and traceability matrices, burndown, roadmap, kanban, story map, gantt, review surface, work items, ADRs, iron triangle, capacity plans, quality targets, and requirement cards.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Thomas A. Powell",
|
|
@@ -40,7 +40,7 @@ export function defaultFormulaText(time, cost, focusFactor) {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* FNV-1a (32-bit) hash over a stable JSON serialization of the triangle.
|
|
43
|
-
* Used for drift detection by downstream consumers (notably <
|
|
43
|
+
* Used for drift detection by downstream consumers (notably <quality-target>'s
|
|
44
44
|
* ironTriangleHash field).
|
|
45
45
|
*
|
|
46
46
|
* Matches the fnv1a in src/web-components/color-picker/_color-utils.js so
|
|
@@ -172,7 +172,7 @@ export function buildTriangleSvg({
|
|
|
172
172
|
center.setAttribute('role', 'button');
|
|
173
173
|
center.setAttribute(
|
|
174
174
|
'aria-label',
|
|
175
|
-
`Quality — ${formatQualitySummary(qualitySummary)}. Activate to open the
|
|
175
|
+
`Quality — ${formatQualitySummary(qualitySummary)}. Activate to open the quality decision.`,
|
|
176
176
|
);
|
|
177
177
|
|
|
178
178
|
// Backdrop circle — sits behind the text, masks the polygon stroke.
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
{ "name": "data-min-capacity", "kind": "data", "purpose": "config", "type": "number", "default": "1", "description": "Floor for capacityPoints" },
|
|
14
14
|
{ "name": "data-quality-href", "kind": "data", "purpose": "config", "type": "string", "description": "Navigation fallback when iron-triangle:open-quality isn't preventDefault-ed" },
|
|
15
15
|
{ "name": "data-quality-summary", "kind": "data", "purpose": "config", "type": "string", "description": "Short summary text shown in the center's native tooltip + aria-label (e.g. '3 critical: perf, sec, a11y')" },
|
|
16
|
-
{ "name": "disabled", "kind": "native", "purpose": "config", "type": "boolean", "description": "
|
|
17
|
-
{ "name": "locked", "kind": "host-api", "purpose": "semantic-state", "type": "boolean", "description": "Read-only mode for shipped
|
|
16
|
+
{ "name": "disabled", "kind": "native", "purpose": "config", "type": "boolean", "description": "Fully off: vertex and centre Quality targets leave the tab order (aria-disabled), openEditor() is a no-op, the mode switch and all dialog controls are disabled. setManual() / setFormula() / revise() still work for the host." },
|
|
17
|
+
{ "name": "locked", "kind": "host-api", "purpose": "semantic-state", "type": "boolean", "description": "Read-only mode for shipped shapes: the same editing lock as disabled, but the centre Quality target still opens the quality decision (iron-triangle:open-quality / data-quality-href)" }
|
|
18
18
|
],
|
|
19
19
|
"slots": [],
|
|
20
20
|
"events": [
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* is never colour-only.
|
|
26
26
|
*
|
|
27
27
|
* Optional: pass a `data-quality-summary` attribute or set the
|
|
28
|
-
* `qualitySummary` property to surface the bound
|
|
28
|
+
* `qualitySummary` property to surface the bound quality-target's
|
|
29
29
|
* decision on hover (e.g. "3 critical: perf, sec, a11y").
|
|
30
30
|
*/
|
|
31
31
|
|
|
@@ -171,7 +171,7 @@ class IronTriangle extends VBElement {
|
|
|
171
171
|
|
|
172
172
|
/** Imperatively open a vertex editor. */
|
|
173
173
|
openEditor(axis) {
|
|
174
|
-
if (!SECTIONS.includes(axis)) return;
|
|
174
|
+
if (!SECTIONS.includes(axis) || this.#inert) return;
|
|
175
175
|
const dialog = this.#ensureDialog(axis);
|
|
176
176
|
if (typeof dialog.showModal === 'function') dialog.showModal();
|
|
177
177
|
else dialog.setAttribute('open', '');
|
|
@@ -235,7 +235,7 @@ class IronTriangle extends VBElement {
|
|
|
235
235
|
const center = event.target.closest?.('.center[data-target="quality"]');
|
|
236
236
|
if (center && this.contains(center)) {
|
|
237
237
|
event.preventDefault();
|
|
238
|
-
this.#fireQualityOpen();
|
|
238
|
+
if (!this.hasAttribute('disabled')) this.#fireQualityOpen();
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
241
|
|
|
@@ -250,7 +250,7 @@ class IronTriangle extends VBElement {
|
|
|
250
250
|
const center = event.target.closest?.('.center[data-target="quality"]');
|
|
251
251
|
if (center && this.contains(center)) {
|
|
252
252
|
event.preventDefault();
|
|
253
|
-
this.#fireQualityOpen();
|
|
253
|
+
if (!this.hasAttribute('disabled')) this.#fireQualityOpen();
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
256
|
|
|
@@ -314,6 +314,7 @@ class IronTriangle extends VBElement {
|
|
|
314
314
|
this.#recompute({ source: 'dialog', field: axis });
|
|
315
315
|
});
|
|
316
316
|
|
|
317
|
+
this.#syncDisabledLocked();
|
|
317
318
|
return dialog;
|
|
318
319
|
}
|
|
319
320
|
|
|
@@ -399,6 +400,7 @@ class IronTriangle extends VBElement {
|
|
|
399
400
|
const existing = this.querySelector(':scope > svg');
|
|
400
401
|
if (existing) existing.replaceWith(svg);
|
|
401
402
|
else this.prepend(svg);
|
|
403
|
+
this.#syncHitTargetsInert();
|
|
402
404
|
this.dataset.capacityPoints = String(this.#value.capacityPoints);
|
|
403
405
|
this.dataset.capacitySource = this.#value.capacitySource;
|
|
404
406
|
}
|
|
@@ -422,12 +424,40 @@ class IronTriangle extends VBElement {
|
|
|
422
424
|
} catch { /* older Safari */ }
|
|
423
425
|
}
|
|
424
426
|
|
|
427
|
+
get #inert() { return this.hasAttribute('disabled') || this.hasAttribute('locked'); }
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* disabled / locked: no editing path stays open. styles.css already sets
|
|
431
|
+
* pointer-events: none; this closes the keyboard and programmatic paths
|
|
432
|
+
* (vertex targets leave the tab order, openEditor() is a no-op) and
|
|
433
|
+
* disables the mode switch and every dialog control, including dialogs
|
|
434
|
+
* built later.
|
|
435
|
+
*
|
|
436
|
+
* The centre Quality target is navigation, not editing, so under `locked`
|
|
437
|
+
* it stays focusable and clickable (styles.css re-enables its pointer).
|
|
438
|
+
* Under `disabled` it goes inert too: browsers suppress mouse events
|
|
439
|
+
* inside a disabled form-associated element, so keeping only its
|
|
440
|
+
* keyboard path alive would be inconsistent.
|
|
441
|
+
*/
|
|
425
442
|
#syncDisabledLocked() {
|
|
426
|
-
const off = this
|
|
443
|
+
const off = this.#inert;
|
|
427
444
|
if (this.#modeButton) this.#modeButton.disabled = off;
|
|
428
445
|
for (const dialog of Object.values(this.#dialogs)) {
|
|
429
446
|
for (const el of dialog.querySelectorAll('input, select, textarea, button')) /** @type {any} */ (el).disabled = off;
|
|
430
447
|
}
|
|
448
|
+
this.#syncHitTargetsInert();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Hit targets follow disabled / locked across re-renders. */
|
|
452
|
+
#syncHitTargetsInert() {
|
|
453
|
+
const editingOff = this.#inert;
|
|
454
|
+
const navOff = this.hasAttribute('disabled');
|
|
455
|
+
for (const target of this.querySelectorAll(':scope > svg [role="button"][tabindex]')) {
|
|
456
|
+
const off = target.matches('.center[data-target="quality"]') ? navOff : editingOff;
|
|
457
|
+
target.setAttribute('tabindex', off ? '-1' : '0');
|
|
458
|
+
if (off) target.setAttribute('aria-disabled', 'true');
|
|
459
|
+
else target.removeAttribute('aria-disabled');
|
|
460
|
+
}
|
|
431
461
|
}
|
|
432
462
|
|
|
433
463
|
// ── Internal: status strip (a11y hooks) ──────────────────────────
|
|
@@ -448,7 +478,9 @@ class IronTriangle extends VBElement {
|
|
|
448
478
|
|
|
449
479
|
const button = document.createElement('button');
|
|
450
480
|
button.type = 'button';
|
|
451
|
-
|
|
481
|
+
// VB's `ghost` variant keeps the switch secondary to the triangle;
|
|
482
|
+
// styles.css trims it further to sit inside the small status strip.
|
|
483
|
+
button.className = 'iron-triangle-mode ghost';
|
|
452
484
|
button.setAttribute('aria-pressed', 'false');
|
|
453
485
|
button.textContent = 'Manual capacity';
|
|
454
486
|
button.addEventListener('click', () => this.#openCapacityDialog());
|
|
@@ -486,9 +518,14 @@ class IronTriangle extends VBElement {
|
|
|
486
518
|
|
|
487
519
|
if (this.#modeButton) {
|
|
488
520
|
this.#modeButton.setAttribute('aria-pressed', manual ? 'true' : 'false');
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
521
|
+
// Pressed state is carried by the visible text too, not only by the
|
|
522
|
+
// underline. The aria-label starts with that visible text so voice
|
|
523
|
+
// users can say what they see (WCAG 2.5.3 Label in Name).
|
|
524
|
+
const label = manual && capacityPoints > 0 ? `Manual capacity: ${capacityPoints} pts` : 'Manual capacity';
|
|
525
|
+
if (this.#modeButton.textContent !== label) this.#modeButton.textContent = label;
|
|
526
|
+
this.#modeButton.setAttribute('aria-label', manual
|
|
527
|
+
? `${label}. Activate to edit or return to the formula.`
|
|
528
|
+
: `${label}. Activate to override the formula with a fixed number.`);
|
|
492
529
|
}
|
|
493
530
|
if (this.#deadlineWarning) {
|
|
494
531
|
this.#deadlineWarning.textContent = overDeadline ? `Deadline has passed (${deadline})` : '';
|
|
@@ -566,6 +603,7 @@ class IronTriangle extends VBElement {
|
|
|
566
603
|
if (Number.isFinite(n) && n > 0) this.setManual(n);
|
|
567
604
|
});
|
|
568
605
|
|
|
606
|
+
this.#syncDisabledLocked();
|
|
569
607
|
return dialog;
|
|
570
608
|
}
|
|
571
609
|
|
|
@@ -150,13 +150,27 @@ iron-triangle > .iron-triangle-status .iron-triangle-readout {
|
|
|
150
150
|
border: 0;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/* Quiet, secondary switch: ghost button, compact, muted until hovered or
|
|
154
|
+
pressed. VB's touch-target minimum block size is kept. Pressed adds an underline (plus the "Manual capacity: N pts" label) so
|
|
155
|
+
the state never rests on colour alone. */
|
|
153
156
|
iron-triangle > .iron-triangle-status .iron-triangle-mode {
|
|
154
157
|
font-size: inherit;
|
|
158
|
+
font-weight: var(--font-weight-normal, 400);
|
|
159
|
+
padding-block: 0;
|
|
160
|
+
padding-inline: var(--size-xs, 0.5rem);
|
|
161
|
+
border-color: transparent;
|
|
162
|
+
color: var(--color-text-muted);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
iron-triangle > .iron-triangle-status .iron-triangle-mode:hover {
|
|
166
|
+
color: var(--color-text);
|
|
155
167
|
}
|
|
156
168
|
|
|
157
169
|
iron-triangle > .iron-triangle-status .iron-triangle-mode[aria-pressed="true"] {
|
|
158
|
-
border-color: var(--color-interactive);
|
|
159
170
|
color: var(--color-interactive);
|
|
171
|
+
text-decoration: underline;
|
|
172
|
+
text-decoration-thickness: var(--border-width-medium, 2px);
|
|
173
|
+
text-underline-offset: 0.25em;
|
|
160
174
|
}
|
|
161
175
|
|
|
162
176
|
/* Visible, text-bearing warning — never colour-only. */
|
|
@@ -193,6 +207,13 @@ iron-triangle[locked] {
|
|
|
193
207
|
pointer-events: none;
|
|
194
208
|
}
|
|
195
209
|
|
|
210
|
+
/* The centre Quality target navigates to the quality decision rather than
|
|
211
|
+
editing the shape, so it keeps working on a locked (read-only) triangle.
|
|
212
|
+
A disabled triangle stays fully off, like any disabled control. */
|
|
213
|
+
iron-triangle[locked]:not([disabled]) svg .center[data-target="quality"] {
|
|
214
|
+
pointer-events: auto;
|
|
215
|
+
}
|
|
216
|
+
|
|
196
217
|
/* ── Dialog chrome ────────────────────────────────────────────── */
|
|
197
218
|
|
|
198
219
|
iron-triangle > dialog.iron-triangle-dialog {
|
|
@@ -13,13 +13,12 @@
|
|
|
13
13
|
{ "name": "data-capacity-points", "kind": "data", "purpose": "config", "type": "number", "description": "Literal capacity fallback when no triangle is bound" },
|
|
14
14
|
{ "name": "data-cost-weights", "kind": "data", "purpose": "config", "type": "string", "description": "JSON object overriding default per-ility cost weights" },
|
|
15
15
|
{ "name": "data-radius", "kind": "data", "purpose": "config", "type": "number", "default": "100", "description": "Base radius in SVG units" },
|
|
16
|
-
{ "name": "data-show-envelope", "kind": "data", "purpose": "visual-variant", "type": "boolean", "description": "Show the capacity envelope polygon (default true; set to \"false\" to hide)" },
|
|
17
16
|
{ "name": "data-min-rationale", "kind": "data", "purpose": "config", "type": "number", "default": "10", "description": "Min characters for a Critical row's rationale" },
|
|
18
17
|
{ "name": "data-max-rationale", "kind": "data", "purpose": "config", "type": "number", "default": "200", "description": "Max characters for the rationale textarea" },
|
|
19
18
|
{ "name": "data-min-overrun-rationale", "kind": "data", "purpose": "config", "type": "number", "default": "10", "description": "Min characters for overrunRationale" },
|
|
20
19
|
{ "name": "data-max-overrun-rationale", "kind": "data", "purpose": "config", "type": "number", "default": "400", "description": "Max characters for overrunRationale" },
|
|
21
|
-
{ "name": "disabled", "kind": "native", "purpose": "config", "type": "boolean", "description": "
|
|
22
|
-
{ "name": "locked", "kind": "host-api", "purpose": "semantic-state", "type": "boolean", "description": "Read-only mode for shipped vectors" }
|
|
20
|
+
{ "name": "disabled", "kind": "native", "purpose": "config", "type": "boolean", "description": "No editing: axes leave the tab order, openEditor() is a no-op, dialog and footer controls are disabled; also bars the element from form validation" },
|
|
21
|
+
{ "name": "locked", "kind": "host-api", "purpose": "semantic-state", "type": "boolean", "description": "Read-only mode for shipped vectors; same editing lock as disabled, but the value still submits and validates" }
|
|
23
22
|
],
|
|
24
23
|
"slots": [],
|
|
25
24
|
"events": [
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* One component, two roles. The radar polygon IS the picker: each
|
|
5
5
|
* axis is a clickable hit-target that opens a per-ility <dialog>
|
|
6
6
|
* editor (level radio group + rationale textarea). The same SVG
|
|
7
|
-
* also
|
|
8
|
-
*
|
|
9
|
-
* supplied via data-capacity-points
|
|
7
|
+
* also shows the Critical spend against the project's capacity in a
|
|
8
|
+
* centre readout ("5 / of 4"), with capacity read from a paired
|
|
9
|
+
* <iron-triangle> via data-bind-to or supplied via data-capacity-points.
|
|
10
|
+
* There is no envelope polygon: three level rings are the only backdrop.
|
|
10
11
|
*
|
|
11
12
|
* Replaces both <nfr-compass> and <nfr-radar> from earlier rounds.
|
|
12
13
|
*
|
|
@@ -37,12 +38,14 @@ import {
|
|
|
37
38
|
buildTargetSvg,
|
|
38
39
|
} from './_quality-utils.js';
|
|
39
40
|
|
|
41
|
+
let instanceCount = 0;
|
|
42
|
+
|
|
40
43
|
class QualityTarget extends VBElement {
|
|
41
44
|
static formAssociated = true;
|
|
42
45
|
static get observedAttributes() {
|
|
43
46
|
return [
|
|
44
47
|
'data-bind-to', 'data-capacity-points', 'data-cost-weights',
|
|
45
|
-
'data-radius',
|
|
48
|
+
'data-radius',
|
|
46
49
|
'data-min-rationale', 'data-max-rationale',
|
|
47
50
|
'data-min-overrun-rationale', 'data-max-overrun-rationale',
|
|
48
51
|
'disabled', 'locked',
|
|
@@ -71,6 +74,9 @@ class QualityTarget extends VBElement {
|
|
|
71
74
|
#dialogs = {};
|
|
72
75
|
/** @type {HTMLElement | null} */
|
|
73
76
|
#footer = null;
|
|
77
|
+
/** Per-instance prefix so generated label/control ids never collide
|
|
78
|
+
* when a page holds more than one target. */
|
|
79
|
+
#uid = `quality-target-${++instanceCount}`;
|
|
74
80
|
|
|
75
81
|
constructor() {
|
|
76
82
|
super();
|
|
@@ -83,6 +89,7 @@ class QualityTarget extends VBElement {
|
|
|
83
89
|
this.#bindToTriangle();
|
|
84
90
|
this.#renderViz();
|
|
85
91
|
this.#ensureFooter();
|
|
92
|
+
this.#publishFormValue();
|
|
86
93
|
|
|
87
94
|
this.listen(this, 'click', (e) => this.#onClick(e));
|
|
88
95
|
this.listen(this, 'keydown', (e) => this.#onKeydown(e));
|
|
@@ -104,8 +111,11 @@ class QualityTarget extends VBElement {
|
|
|
104
111
|
} else if (name === 'data-cost-weights') {
|
|
105
112
|
this.#refreshCostWeights();
|
|
106
113
|
this.#renderViz();
|
|
107
|
-
} else if (name === 'data-radius'
|
|
114
|
+
} else if (name === 'data-radius') {
|
|
108
115
|
this.#renderViz();
|
|
116
|
+
} else if (name === 'data-min-overrun-rationale' || name === 'data-max-overrun-rationale') {
|
|
117
|
+
this.#updateFooter();
|
|
118
|
+
this.#publishFormValue();
|
|
109
119
|
} else if (name === 'disabled' || name === 'locked') {
|
|
110
120
|
this.#syncDisabledLocked();
|
|
111
121
|
}
|
|
@@ -153,7 +163,7 @@ class QualityTarget extends VBElement {
|
|
|
153
163
|
|
|
154
164
|
/** Imperatively open one axis's editor. */
|
|
155
165
|
openEditor(ility) {
|
|
156
|
-
if (!this.#ilities.includes(ility)) return;
|
|
166
|
+
if (!this.#ilities.includes(ility) || this.#inert) return;
|
|
157
167
|
const dialog = this.#ensureDialog(ility);
|
|
158
168
|
if (typeof dialog.showModal === 'function') dialog.showModal();
|
|
159
169
|
else dialog.setAttribute('open', '');
|
|
@@ -244,10 +254,10 @@ class QualityTarget extends VBElement {
|
|
|
244
254
|
// Rationale form-field (required when Critical).
|
|
245
255
|
const ff = document.createElement('form-field');
|
|
246
256
|
const rl = document.createElement('label');
|
|
247
|
-
rl.setAttribute('for',
|
|
257
|
+
rl.setAttribute('for', `${this.#uid}-${ility}-rationale`);
|
|
248
258
|
rl.textContent = 'Rationale (required when Critical)';
|
|
249
259
|
const ta = document.createElement('textarea');
|
|
250
|
-
ta.id =
|
|
260
|
+
ta.id = `${this.#uid}-${ility}-rationale`;
|
|
251
261
|
ta.name = 'rationale';
|
|
252
262
|
ta.rows = 3;
|
|
253
263
|
ta.minLength = this.#minRationale();
|
|
@@ -304,6 +314,7 @@ class QualityTarget extends VBElement {
|
|
|
304
314
|
});
|
|
305
315
|
|
|
306
316
|
this.#syncDialogFromState(ility);
|
|
317
|
+
this.#syncDisabledLocked();
|
|
307
318
|
return dialog;
|
|
308
319
|
}
|
|
309
320
|
|
|
@@ -338,8 +349,8 @@ class QualityTarget extends VBElement {
|
|
|
338
349
|
<strong>Over budget — explain why.</strong>
|
|
339
350
|
<small data-overrun-prompt></small>
|
|
340
351
|
<form-field>
|
|
341
|
-
<label for="
|
|
342
|
-
<textarea id="
|
|
352
|
+
<label for="${this.#uid}-overrun">Overrun rationale (≥ ${this.#minOverrunRationale()} chars)</label>
|
|
353
|
+
<textarea id="${this.#uid}-overrun" name="overrunRationale"
|
|
343
354
|
minlength="${this.#minOverrunRationale()}"
|
|
344
355
|
maxlength="${this.#maxOverrunRationale()}"
|
|
345
356
|
rows="3"></textarea>
|
|
@@ -355,6 +366,7 @@ class QualityTarget extends VBElement {
|
|
|
355
366
|
|
|
356
367
|
this.append(footer);
|
|
357
368
|
this.#footer = footer;
|
|
369
|
+
this.#syncDisabledLocked();
|
|
358
370
|
}
|
|
359
371
|
|
|
360
372
|
#updateFooter() {
|
|
@@ -366,9 +378,19 @@ class QualityTarget extends VBElement {
|
|
|
366
378
|
if (overrun) {
|
|
367
379
|
overrun.hidden = !isOver;
|
|
368
380
|
const prompt = overrun.querySelector('[data-overrun-prompt]');
|
|
369
|
-
if (prompt
|
|
370
|
-
|
|
381
|
+
if (prompt) {
|
|
382
|
+
const over = sum - cap;
|
|
383
|
+
prompt.textContent = isOver
|
|
384
|
+
? `You're over budget by ${over} ${over === 1 ? 'point' : 'points'} (${sum}/${cap}).`
|
|
385
|
+
: '';
|
|
371
386
|
}
|
|
387
|
+
// Keep the label and limits in step with the data-*-overrun-rationale
|
|
388
|
+
// attributes, which may change after the footer was built.
|
|
389
|
+
const min = this.#minOverrunRationale();
|
|
390
|
+
const label = overrun.querySelector('label');
|
|
391
|
+
if (label) label.textContent = `Overrun rationale (≥ ${min} chars)`;
|
|
392
|
+
const ta = /** @type {HTMLTextAreaElement | null} */ (overrun.querySelector('textarea'));
|
|
393
|
+
if (ta) { ta.minLength = min; ta.maxLength = this.#maxOverrunRationale(); }
|
|
372
394
|
}
|
|
373
395
|
}
|
|
374
396
|
|
|
@@ -461,6 +483,7 @@ class QualityTarget extends VBElement {
|
|
|
461
483
|
const existing = this.querySelector(':scope > svg');
|
|
462
484
|
if (existing) existing.replaceWith(svg);
|
|
463
485
|
else this.prepend(svg);
|
|
486
|
+
this.#syncAxesInert();
|
|
464
487
|
}
|
|
465
488
|
|
|
466
489
|
#publish(source, field) {
|
|
@@ -490,15 +513,64 @@ class QualityTarget extends VBElement {
|
|
|
490
513
|
try {
|
|
491
514
|
this.#internals.setFormValue(JSON.stringify(this.value));
|
|
492
515
|
} catch { /* older Safari */ }
|
|
516
|
+
this.#syncValidity();
|
|
493
517
|
}
|
|
494
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Mirror validateVector() into ElementInternals so a parent <form>'s
|
|
521
|
+
* checkValidity() / submit is blocked too, not just this element's own
|
|
522
|
+
* checkValidity() method. The overrun textarea anchors the message when
|
|
523
|
+
* the overrun rationale is the problem.
|
|
524
|
+
*/
|
|
525
|
+
#syncValidity() {
|
|
526
|
+
if (typeof this.#internals.setValidity !== 'function') return;
|
|
527
|
+
const { valid, errors } = validateVector({
|
|
528
|
+
vector: this.#vector,
|
|
529
|
+
rationales: this.#rationales,
|
|
530
|
+
costWeights: this.#costWeights,
|
|
531
|
+
capacityPoints: this.#resolveCapacity(),
|
|
532
|
+
overrunRationale: this.#overrunRationale,
|
|
533
|
+
minRationale: this.#minRationale(),
|
|
534
|
+
minOverrunRationale: this.#minOverrunRationale(),
|
|
535
|
+
});
|
|
536
|
+
if (valid) { this.#internals.setValidity({}); return; }
|
|
537
|
+
const overrunTa = /** @type {HTMLElement | null} */ (
|
|
538
|
+
this.overBudget ? this.querySelector('[data-quality-overrun] textarea') : null);
|
|
539
|
+
try {
|
|
540
|
+
this.#internals.setValidity({ customError: true }, errors.join(' '), overrunTa ?? undefined);
|
|
541
|
+
} catch { /* anchor not yet connected */
|
|
542
|
+
this.#internals.setValidity({ customError: true }, errors.join(' '));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
get #inert() { return this.hasAttribute('disabled') || this.hasAttribute('locked'); }
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* disabled / locked: no editing path stays open. styles.css already sets
|
|
550
|
+
* pointer-events: none; this closes the keyboard and programmatic paths
|
|
551
|
+
* (axes leave the tab order, openEditor() is a no-op) and disables every
|
|
552
|
+
* dialog and footer control, including dialogs built later.
|
|
553
|
+
*/
|
|
495
554
|
#syncDisabledLocked() {
|
|
496
|
-
const off = this
|
|
555
|
+
const off = this.#inert;
|
|
497
556
|
for (const dialog of Object.values(this.#dialogs)) {
|
|
498
557
|
for (const el of dialog.querySelectorAll('input, textarea, select, button')) {
|
|
499
558
|
/** @type {HTMLInputElement} */ (el).disabled = off;
|
|
500
559
|
}
|
|
501
560
|
}
|
|
561
|
+
for (const el of this.#footer?.querySelectorAll('textarea') ?? []) {
|
|
562
|
+
/** @type {HTMLTextAreaElement} */ (el).disabled = off;
|
|
563
|
+
}
|
|
564
|
+
this.#syncAxesInert();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
#syncAxesInert() {
|
|
568
|
+
const off = this.#inert;
|
|
569
|
+
for (const axis of this.querySelectorAll(':scope > svg .axis[data-ility]')) {
|
|
570
|
+
axis.setAttribute('tabindex', off ? '-1' : '0');
|
|
571
|
+
if (off) axis.setAttribute('aria-disabled', 'true');
|
|
572
|
+
else axis.removeAttribute('aria-disabled');
|
|
573
|
+
}
|
|
502
574
|
}
|
|
503
575
|
|
|
504
576
|
// ── Internal: config readers ──────────────────────────────────────
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "../../../schemas/api.schema.json",
|
|
3
3
|
"element": "requirement-card",
|
|
4
4
|
"type": "web-component",
|
|
5
|
-
"description": "Display-only card for ONE NFR ility row. Renders priority + rationale + optional conflict flag for status dashboards and ADR appendices. The prioritization decision itself lives in <
|
|
5
|
+
"description": "Display-only card for ONE NFR ility row. Renders priority + rationale + optional conflict flag for status dashboards and ADR appendices. The prioritization decision itself lives in <quality-target>.",
|
|
6
6
|
"htmlvalidate": {
|
|
7
7
|
"flow": true,
|
|
8
8
|
"permittedContent": ["@flow"]
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* <requirement-card> — Display atom for ONE NFR ility row.
|
|
3
3
|
*
|
|
4
|
-
* Display-only. The prioritization decision lives in <
|
|
4
|
+
* Display-only. The prioritization decision lives in <quality-target>;
|
|
5
5
|
* this component just renders one row's worth of state — name +
|
|
6
6
|
* category badge + priority pill + rationale + (optional) conflict flag —
|
|
7
7
|
* for use on status dashboards, ADR appendices, or anywhere a project
|
|
8
|
-
* wants to surface a single ility outside the
|
|
8
|
+
* wants to surface a single ility outside the target.
|
|
9
9
|
*
|
|
10
10
|
* Slots:
|
|
11
11
|
* name — ility label (e.g. "Performance")
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
* requirement-card:click — bubbles a structured detail when the card
|
|
25
25
|
* is activated (mouse, keyboard, or touch);
|
|
26
26
|
* parent components can wire navigation.
|
|
27
|
+
* Inside an <a>/<button> wrapper, the
|
|
28
|
+
* wrapper's own keyboard activation counts.
|
|
27
29
|
*
|
|
28
30
|
* Pattern reference: matches the shadow-DOM + slot conventions from
|
|
29
31
|
* <score-card> (also a single-row dashboard atom).
|
|
@@ -58,6 +60,9 @@ const TEMPLATE = `
|
|
|
58
60
|
`;
|
|
59
61
|
|
|
60
62
|
class RequirementCard extends VBElement {
|
|
63
|
+
/** True when this element added role="button" itself (so it may remove it). */
|
|
64
|
+
#ownsRole = false;
|
|
65
|
+
|
|
61
66
|
static get observedAttributes() {
|
|
62
67
|
return ['data-priority', 'data-conflict', 'tabindex'];
|
|
63
68
|
}
|
|
@@ -82,6 +87,15 @@ class RequirementCard extends VBElement {
|
|
|
82
87
|
this.#syncInteractive();
|
|
83
88
|
|
|
84
89
|
this.listen(this, 'click', (e) => this.#onActivate(e));
|
|
90
|
+
// Keyboard activation of a wrapping <a>/<button> fires `click` on the
|
|
91
|
+
// wrapper itself, which never reaches the card's own listener. Clicks
|
|
92
|
+
// that started inside the card are already handled above.
|
|
93
|
+
const wrapper = this.closest('a[href], button');
|
|
94
|
+
if (wrapper) {
|
|
95
|
+
this.listen(wrapper, 'click', (e) => {
|
|
96
|
+
if (!this.contains(/** @type {Node} */ (e.target))) this.#onActivate(e);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
85
99
|
this.listen(this, 'keydown', (e) => {
|
|
86
100
|
const ke = /** @type {KeyboardEvent} */ (e);
|
|
87
101
|
if ((ke.key === 'Enter' || ke.key === ' ') && this.#interactive) {
|
|
@@ -146,6 +160,16 @@ class RequirementCard extends VBElement {
|
|
|
146
160
|
|
|
147
161
|
#syncInteractive() {
|
|
148
162
|
this.setState('interactive', !!this.#interactive);
|
|
163
|
+
// A focusable card that is not inside a native control needs a role
|
|
164
|
+
// so assistive tech announces it as activatable. Author roles win.
|
|
165
|
+
const needsRole = this.hasAttribute('tabindex') && !this.closest('a[href], button');
|
|
166
|
+
if (needsRole && !this.hasAttribute('role')) {
|
|
167
|
+
this.setAttribute('role', 'button');
|
|
168
|
+
this.#ownsRole = true;
|
|
169
|
+
} else if (!needsRole && this.#ownsRole) {
|
|
170
|
+
this.removeAttribute('role');
|
|
171
|
+
this.#ownsRole = false;
|
|
172
|
+
}
|
|
149
173
|
}
|
|
150
174
|
|
|
151
175
|
#onActivate(event) {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<body>
|
|
9
9
|
<!--
|
|
10
10
|
Display-only card for ONE NFR ility row. The prioritization
|
|
11
|
-
decision lives in <
|
|
11
|
+
decision lives in <quality-target>; this is the visual atom.
|
|
12
12
|
Pre-upgrade, the card renders as a plain article — no JS needed
|
|
13
13
|
for it to be readable.
|
|
14
14
|
-->
|