@profpowell/vb-project-planning 0.1.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/README.md +27 -5
- package/dist/vb-project-planning.css +61 -5
- package/dist/vb-project-planning.js +304 -27
- package/dist/vb-project-planning.min.css +1 -1
- package/dist/vb-project-planning.min.js +101 -101
- package/package.json +5 -4
- 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 +3 -3
- package/src/web-components/iron-triangle/logic.js +200 -5
- package/src/web-components/iron-triangle/styles.css +81 -3
- package/src/web-components/product-roadmap/logic.js +25 -13
- 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 +27 -3
- package/src/web-components/requirement-card/static.html +1 -1
- package/src/web-components/user-persona/logic.js +1 -1
- package/src/web-components/user-story/logic.js +2 -1
|
@@ -527,7 +527,7 @@ var UserPersona = class extends HTMLElement {
|
|
|
527
527
|
/** @type {any} */
|
|
528
528
|
root.querySelectorAll ? root : document
|
|
529
529
|
);
|
|
530
|
-
return Array.from(scope.querySelectorAll(`user-story[persona-id="${cssEscape(this.id)}"]`));
|
|
530
|
+
return Array.from(scope.querySelectorAll(`user-story[persona-id="${cssEscape(this.id)}"]:not([data-detail-clone])`));
|
|
531
531
|
}
|
|
532
532
|
#cacheSlotValues() {
|
|
533
533
|
for (const child of [...this.children]) {
|
|
@@ -1362,7 +1362,7 @@ var UserStory = class _UserStory extends HTMLElement {
|
|
|
1362
1362
|
}
|
|
1363
1363
|
connectedCallback() {
|
|
1364
1364
|
this.#cacheSlotValues();
|
|
1365
|
-
if (this.storyId && !this.id) this.id = this.storyId;
|
|
1365
|
+
if (this.storyId && !this.id && !this.hasAttribute("data-detail-clone")) this.id = this.storyId;
|
|
1366
1366
|
if (this.hasAttribute("src")) {
|
|
1367
1367
|
this._loadSrc(this.getAttribute("src"));
|
|
1368
1368
|
}
|
|
@@ -1545,6 +1545,7 @@ var UserStory = class _UserStory extends HTMLElement {
|
|
|
1545
1545
|
const hasSlottedContent = [...this.children].some((c) => c.getAttribute("slot") && c.tagName !== "DIALOG");
|
|
1546
1546
|
full.setAttribute("detail", hasSlottedContent ? "full" : "compact");
|
|
1547
1547
|
full.removeAttribute("id");
|
|
1548
|
+
full.setAttribute("data-detail-clone", "");
|
|
1548
1549
|
for (const child of [...this.children]) {
|
|
1549
1550
|
if (child.tagName === "DIALOG") continue;
|
|
1550
1551
|
full.appendChild(child.cloneNode(true));
|
|
@@ -4388,7 +4389,22 @@ registerComponent("burndown-chart", BurndownChart);
|
|
|
4388
4389
|
|
|
4389
4390
|
// src/web-components/product-roadmap/logic.js
|
|
4390
4391
|
var Roadmap = class _Roadmap extends VBElement {
|
|
4392
|
+
static get observedAttributes() {
|
|
4393
|
+
return ["start", "end", "view", "editable", "today-marker"];
|
|
4394
|
+
}
|
|
4391
4395
|
setup() {
|
|
4396
|
+
if (!this.#readConfig()) return false;
|
|
4397
|
+
const lanes = [...this.querySelectorAll(":scope > section[data-lane]")];
|
|
4398
|
+
if (!lanes.length) {
|
|
4399
|
+
console.warn("product-roadmap: no <section data-lane> children found");
|
|
4400
|
+
return false;
|
|
4401
|
+
}
|
|
4402
|
+
this.#sourceLanes = lanes;
|
|
4403
|
+
this.#render();
|
|
4404
|
+
return true;
|
|
4405
|
+
}
|
|
4406
|
+
/** Parse start/end/view/editable from attributes. Returns false when invalid. */
|
|
4407
|
+
#readConfig() {
|
|
4392
4408
|
const startAttr = this.getAttribute("start");
|
|
4393
4409
|
const endAttr = this.getAttribute("end");
|
|
4394
4410
|
if (!startAttr || !endAttr) {
|
|
@@ -4406,17 +4422,12 @@ var Roadmap = class _Roadmap extends VBElement {
|
|
|
4406
4422
|
this.#totalMs = end.getTime() - start.getTime();
|
|
4407
4423
|
this.#view = this.getAttribute("view") === "month" ? "month" : "quarter";
|
|
4408
4424
|
this.#editable = this.hasAttribute("editable");
|
|
4409
|
-
const lanes = [...this.querySelectorAll(":scope > section[data-lane]")];
|
|
4410
|
-
if (!lanes.length) {
|
|
4411
|
-
console.warn("product-roadmap: no <section data-lane> children found");
|
|
4412
|
-
return false;
|
|
4413
|
-
}
|
|
4414
|
-
this.#sourceLanes = lanes;
|
|
4415
|
-
this.#render();
|
|
4416
4425
|
return true;
|
|
4417
4426
|
}
|
|
4418
|
-
attributeChangedCallback() {
|
|
4419
|
-
if (
|
|
4427
|
+
attributeChangedCallback(_name, oldValue, newValue) {
|
|
4428
|
+
if (oldValue === newValue) return;
|
|
4429
|
+
if (!this.isConnected || !this.#sourceLanes.length) return;
|
|
4430
|
+
if (this.#readConfig()) this.#render();
|
|
4420
4431
|
}
|
|
4421
4432
|
#render() {
|
|
4422
4433
|
[...this.children].forEach((c) => {
|
|
@@ -9339,7 +9350,7 @@ function buildTriangleSvg({
|
|
|
9339
9350
|
center.setAttribute("role", "button");
|
|
9340
9351
|
center.setAttribute(
|
|
9341
9352
|
"aria-label",
|
|
9342
|
-
`Quality \u2014 ${formatQualitySummary(qualitySummary)}. Activate to open the
|
|
9353
|
+
`Quality \u2014 ${formatQualitySummary(qualitySummary)}. Activate to open the quality decision.`
|
|
9343
9354
|
);
|
|
9344
9355
|
const backdrop = document.createElementNS(SVG_NS, "circle");
|
|
9345
9356
|
backdrop.setAttribute("class", "capacity-backdrop");
|
|
@@ -9502,6 +9513,12 @@ var IronTriangle = class extends VBElement {
|
|
|
9502
9513
|
#qualitySummary = "";
|
|
9503
9514
|
/** @type {Record<string, HTMLDialogElement>} */
|
|
9504
9515
|
#dialogs = {};
|
|
9516
|
+
/** @type {HTMLOutputElement | null} */
|
|
9517
|
+
#readout = null;
|
|
9518
|
+
/** @type {HTMLButtonElement | null} */
|
|
9519
|
+
#modeButton = null;
|
|
9520
|
+
/** @type {HTMLElement | null} */
|
|
9521
|
+
#deadlineWarning = null;
|
|
9505
9522
|
constructor() {
|
|
9506
9523
|
super();
|
|
9507
9524
|
this.#internals = this.attachInternals();
|
|
@@ -9510,7 +9527,9 @@ var IronTriangle = class extends VBElement {
|
|
|
9510
9527
|
setup() {
|
|
9511
9528
|
this.#qualitySummary = this.getAttribute("data-quality-summary") || "";
|
|
9512
9529
|
this.#seedDefaults();
|
|
9530
|
+
this.#renderStatus();
|
|
9513
9531
|
this.#renderViz();
|
|
9532
|
+
this.#syncStatus();
|
|
9514
9533
|
this.listen(this, "click", (e) => this.#onClick(e));
|
|
9515
9534
|
this.listen(this, "keydown", (e) => this.#onKeydown(e));
|
|
9516
9535
|
}
|
|
@@ -9568,7 +9587,7 @@ var IronTriangle = class extends VBElement {
|
|
|
9568
9587
|
}
|
|
9569
9588
|
/** Imperatively open a vertex editor. */
|
|
9570
9589
|
openEditor(axis) {
|
|
9571
|
-
if (!SECTIONS.includes(axis)) return;
|
|
9590
|
+
if (!SECTIONS.includes(axis) || this.#inert) return;
|
|
9572
9591
|
const dialog = this.#ensureDialog(axis);
|
|
9573
9592
|
if (typeof dialog.showModal === "function") dialog.showModal();
|
|
9574
9593
|
else dialog.setAttribute("open", "");
|
|
@@ -9634,7 +9653,7 @@ var IronTriangle = class extends VBElement {
|
|
|
9634
9653
|
const center = event.target.closest?.('.center[data-target="quality"]');
|
|
9635
9654
|
if (center && this.contains(center)) {
|
|
9636
9655
|
event.preventDefault();
|
|
9637
|
-
this.#fireQualityOpen();
|
|
9656
|
+
if (!this.hasAttribute("disabled")) this.#fireQualityOpen();
|
|
9638
9657
|
}
|
|
9639
9658
|
}
|
|
9640
9659
|
#onKeydown(event) {
|
|
@@ -9648,7 +9667,7 @@ var IronTriangle = class extends VBElement {
|
|
|
9648
9667
|
const center = event.target.closest?.('.center[data-target="quality"]');
|
|
9649
9668
|
if (center && this.contains(center)) {
|
|
9650
9669
|
event.preventDefault();
|
|
9651
|
-
this.#fireQualityOpen();
|
|
9670
|
+
if (!this.hasAttribute("disabled")) this.#fireQualityOpen();
|
|
9652
9671
|
}
|
|
9653
9672
|
}
|
|
9654
9673
|
#fireQualityOpen() {
|
|
@@ -9704,6 +9723,7 @@ var IronTriangle = class extends VBElement {
|
|
|
9704
9723
|
dialog.close("save");
|
|
9705
9724
|
this.#recompute({ source: "dialog", field: axis });
|
|
9706
9725
|
});
|
|
9726
|
+
this.#syncDisabledLocked();
|
|
9707
9727
|
return dialog;
|
|
9708
9728
|
}
|
|
9709
9729
|
#captureDialog(axis, form) {
|
|
@@ -9765,6 +9785,7 @@ var IronTriangle = class extends VBElement {
|
|
|
9765
9785
|
this.#value.hash = triangleHash(this.#value);
|
|
9766
9786
|
this.#renderViz();
|
|
9767
9787
|
this.#syncStateFlags();
|
|
9788
|
+
this.#syncStatus();
|
|
9768
9789
|
this.#publishFormValue();
|
|
9769
9790
|
this.dispatchEvent(new CustomEvent("iron-triangle:change", {
|
|
9770
9791
|
bubbles: true,
|
|
@@ -9783,6 +9804,7 @@ var IronTriangle = class extends VBElement {
|
|
|
9783
9804
|
const existing = this.querySelector(":scope > svg");
|
|
9784
9805
|
if (existing) existing.replaceWith(svg);
|
|
9785
9806
|
else this.prepend(svg);
|
|
9807
|
+
this.#syncHitTargetsInert();
|
|
9786
9808
|
this.dataset.capacityPoints = String(this.#value.capacityPoints);
|
|
9787
9809
|
this.dataset.capacitySource = this.#value.capacitySource;
|
|
9788
9810
|
}
|
|
@@ -9804,11 +9826,172 @@ var IronTriangle = class extends VBElement {
|
|
|
9804
9826
|
} catch {
|
|
9805
9827
|
}
|
|
9806
9828
|
}
|
|
9829
|
+
get #inert() {
|
|
9830
|
+
return this.hasAttribute("disabled") || this.hasAttribute("locked");
|
|
9831
|
+
}
|
|
9832
|
+
/**
|
|
9833
|
+
* disabled / locked: no editing path stays open. styles.css already sets
|
|
9834
|
+
* pointer-events: none; this closes the keyboard and programmatic paths
|
|
9835
|
+
* (vertex targets leave the tab order, openEditor() is a no-op) and
|
|
9836
|
+
* disables the mode switch and every dialog control, including dialogs
|
|
9837
|
+
* built later.
|
|
9838
|
+
*
|
|
9839
|
+
* The centre Quality target is navigation, not editing, so under `locked`
|
|
9840
|
+
* it stays focusable and clickable (styles.css re-enables its pointer).
|
|
9841
|
+
* Under `disabled` it goes inert too: browsers suppress mouse events
|
|
9842
|
+
* inside a disabled form-associated element, so keeping only its
|
|
9843
|
+
* keyboard path alive would be inconsistent.
|
|
9844
|
+
*/
|
|
9807
9845
|
#syncDisabledLocked() {
|
|
9808
|
-
const off = this
|
|
9846
|
+
const off = this.#inert;
|
|
9847
|
+
if (this.#modeButton) this.#modeButton.disabled = off;
|
|
9809
9848
|
for (const dialog of Object.values(this.#dialogs)) {
|
|
9810
9849
|
for (const el of dialog.querySelectorAll("input, select, textarea, button")) el.disabled = off;
|
|
9811
9850
|
}
|
|
9851
|
+
this.#syncHitTargetsInert();
|
|
9852
|
+
}
|
|
9853
|
+
/** Hit targets follow disabled / locked across re-renders. */
|
|
9854
|
+
#syncHitTargetsInert() {
|
|
9855
|
+
const editingOff = this.#inert;
|
|
9856
|
+
const navOff = this.hasAttribute("disabled");
|
|
9857
|
+
for (const target of this.querySelectorAll(':scope > svg [role="button"][tabindex]')) {
|
|
9858
|
+
const off = target.matches('.center[data-target="quality"]') ? navOff : editingOff;
|
|
9859
|
+
target.setAttribute("tabindex", off ? "-1" : "0");
|
|
9860
|
+
if (off) target.setAttribute("aria-disabled", "true");
|
|
9861
|
+
else target.removeAttribute("aria-disabled");
|
|
9862
|
+
}
|
|
9863
|
+
}
|
|
9864
|
+
// ── Internal: status strip (a11y hooks) ──────────────────────────
|
|
9865
|
+
/**
|
|
9866
|
+
* Builds the status strip once: live readout, mode toggle, deadline
|
|
9867
|
+
* warning. Content is refreshed by #syncStatus() on every recompute.
|
|
9868
|
+
*/
|
|
9869
|
+
#renderStatus() {
|
|
9870
|
+
if (this.querySelector(":scope > .iron-triangle-status")) return;
|
|
9871
|
+
const strip = document.createElement("div");
|
|
9872
|
+
strip.className = "iron-triangle-status";
|
|
9873
|
+
const readout = document.createElement("output");
|
|
9874
|
+
readout.className = "iron-triangle-readout";
|
|
9875
|
+
readout.setAttribute("aria-live", "polite");
|
|
9876
|
+
readout.setAttribute("aria-atomic", "true");
|
|
9877
|
+
const button = document.createElement("button");
|
|
9878
|
+
button.type = "button";
|
|
9879
|
+
button.className = "iron-triangle-mode ghost";
|
|
9880
|
+
button.setAttribute("aria-pressed", "false");
|
|
9881
|
+
button.textContent = "Manual capacity";
|
|
9882
|
+
button.addEventListener("click", () => this.#openCapacityDialog());
|
|
9883
|
+
const warning = document.createElement("p");
|
|
9884
|
+
warning.className = "iron-triangle-warning";
|
|
9885
|
+
warning.setAttribute("data-warning", "over-deadline");
|
|
9886
|
+
warning.hidden = true;
|
|
9887
|
+
strip.append(readout, button, warning);
|
|
9888
|
+
const svg = this.querySelector(":scope > svg");
|
|
9889
|
+
if (svg) svg.after(strip);
|
|
9890
|
+
else this.prepend(strip);
|
|
9891
|
+
this.#readout = readout;
|
|
9892
|
+
this.#modeButton = button;
|
|
9893
|
+
this.#deadlineWarning = warning;
|
|
9894
|
+
this.#syncDisabledLocked();
|
|
9895
|
+
}
|
|
9896
|
+
#syncStatus() {
|
|
9897
|
+
if (!this.#readout) return;
|
|
9898
|
+
const { capacityPoints, capacitySource } = this.#value;
|
|
9899
|
+
const manual = capacitySource === "manual";
|
|
9900
|
+
const deadline = this.#value.time?.deadline;
|
|
9901
|
+
const overDeadline = this.matches(":state(over-deadline)");
|
|
9902
|
+
const parts = [];
|
|
9903
|
+
parts.push(capacityPoints > 0 ? `Capacity: ${capacityPoints} points (${manual ? "manual" : "formula"}).` : "Capacity: not yet budgeted.");
|
|
9904
|
+
if (overDeadline) parts.push(`Deadline has passed (${deadline}).`);
|
|
9905
|
+
const text = parts.join(" ");
|
|
9906
|
+
if (this.#readout.textContent !== text) this.#readout.textContent = text;
|
|
9907
|
+
if (this.#modeButton) {
|
|
9908
|
+
this.#modeButton.setAttribute("aria-pressed", manual ? "true" : "false");
|
|
9909
|
+
const label = manual && capacityPoints > 0 ? `Manual capacity: ${capacityPoints} pts` : "Manual capacity";
|
|
9910
|
+
if (this.#modeButton.textContent !== label) this.#modeButton.textContent = label;
|
|
9911
|
+
this.#modeButton.setAttribute("aria-label", manual ? `${label}. Activate to edit or return to the formula.` : `${label}. Activate to override the formula with a fixed number.`);
|
|
9912
|
+
}
|
|
9913
|
+
if (this.#deadlineWarning) {
|
|
9914
|
+
this.#deadlineWarning.textContent = overDeadline ? `Deadline has passed (${deadline})` : "";
|
|
9915
|
+
this.#deadlineWarning.hidden = !overDeadline;
|
|
9916
|
+
}
|
|
9917
|
+
}
|
|
9918
|
+
#openCapacityDialog() {
|
|
9919
|
+
const dialog = this.#ensureCapacityDialog();
|
|
9920
|
+
const input = (
|
|
9921
|
+
/** @type {HTMLInputElement} */
|
|
9922
|
+
dialog.querySelector('[name="capacity.points"]')
|
|
9923
|
+
);
|
|
9924
|
+
input.value = String(this.#value.capacityPoints > 0 ? this.#value.capacityPoints : 1);
|
|
9925
|
+
const useFormula = (
|
|
9926
|
+
/** @type {HTMLButtonElement} */
|
|
9927
|
+
dialog.querySelector('button[value="formula"]')
|
|
9928
|
+
);
|
|
9929
|
+
useFormula.hidden = this.#value.capacitySource !== "manual";
|
|
9930
|
+
if (typeof dialog.showModal === "function") dialog.showModal();
|
|
9931
|
+
else dialog.setAttribute("open", "");
|
|
9932
|
+
}
|
|
9933
|
+
#ensureCapacityDialog() {
|
|
9934
|
+
if (this.#dialogs.capacity) return this.#dialogs.capacity;
|
|
9935
|
+
const dialog = document.createElement("dialog");
|
|
9936
|
+
dialog.className = "iron-triangle-dialog iron-triangle-dialog--capacity";
|
|
9937
|
+
dialog.setAttribute("aria-label", "Capacity \u2014 manual override");
|
|
9938
|
+
const form = document.createElement("form");
|
|
9939
|
+
form.method = "dialog";
|
|
9940
|
+
form.setAttribute("data-layout", "stack");
|
|
9941
|
+
form.setAttribute("data-layout-gap", "m");
|
|
9942
|
+
const heading = document.createElement("h3");
|
|
9943
|
+
heading.textContent = "Manual capacity";
|
|
9944
|
+
form.append(heading);
|
|
9945
|
+
const hint = document.createElement("p");
|
|
9946
|
+
hint.className = "iron-triangle-dialog__hint";
|
|
9947
|
+
hint.textContent = "Fix the capacity to a number the team committed to. The Time / Cost / Scope inputs stay saved but stop driving the readout.";
|
|
9948
|
+
form.append(hint);
|
|
9949
|
+
form.append(buildFormField("capacity", {
|
|
9950
|
+
key: "points",
|
|
9951
|
+
label: "Capacity (points)",
|
|
9952
|
+
type: "number",
|
|
9953
|
+
min: 1,
|
|
9954
|
+
step: 1,
|
|
9955
|
+
width: "6rem"
|
|
9956
|
+
}, this.#value.capacityPoints));
|
|
9957
|
+
const actions = document.createElement("div");
|
|
9958
|
+
actions.setAttribute("data-layout", "cluster");
|
|
9959
|
+
actions.setAttribute("data-layout-gap", "s");
|
|
9960
|
+
actions.setAttribute("data-layout-justify", "end");
|
|
9961
|
+
const useFormula = document.createElement("button");
|
|
9962
|
+
useFormula.type = "button";
|
|
9963
|
+
useFormula.value = "formula";
|
|
9964
|
+
useFormula.textContent = "Use formula";
|
|
9965
|
+
useFormula.addEventListener("click", () => {
|
|
9966
|
+
dialog.close("formula");
|
|
9967
|
+
this.setFormula();
|
|
9968
|
+
});
|
|
9969
|
+
const cancel = document.createElement("button");
|
|
9970
|
+
cancel.type = "button";
|
|
9971
|
+
cancel.value = "cancel";
|
|
9972
|
+
cancel.textContent = "Cancel";
|
|
9973
|
+
cancel.addEventListener("click", () => dialog.close("cancel"));
|
|
9974
|
+
const save = document.createElement("button");
|
|
9975
|
+
save.type = "submit";
|
|
9976
|
+
save.value = "save";
|
|
9977
|
+
save.textContent = "Save";
|
|
9978
|
+
actions.append(useFormula, cancel, save);
|
|
9979
|
+
form.append(actions);
|
|
9980
|
+
dialog.append(form);
|
|
9981
|
+
this.append(dialog);
|
|
9982
|
+
this.#dialogs.capacity = dialog;
|
|
9983
|
+
form.addEventListener("submit", (e) => {
|
|
9984
|
+
e.preventDefault();
|
|
9985
|
+
const input = (
|
|
9986
|
+
/** @type {HTMLInputElement} */
|
|
9987
|
+
form.querySelector('[name="capacity.points"]')
|
|
9988
|
+
);
|
|
9989
|
+
const n = Number(input.value);
|
|
9990
|
+
dialog.close("save");
|
|
9991
|
+
if (Number.isFinite(n) && n > 0) this.setManual(n);
|
|
9992
|
+
});
|
|
9993
|
+
this.#syncDisabledLocked();
|
|
9994
|
+
return dialog;
|
|
9812
9995
|
}
|
|
9813
9996
|
// ── Internal: revise() field accessors ───────────────────────────
|
|
9814
9997
|
#getField(path) {
|
|
@@ -10423,6 +10606,7 @@ function anchorFor(p) {
|
|
|
10423
10606
|
}
|
|
10424
10607
|
|
|
10425
10608
|
// src/web-components/quality-target/logic.js
|
|
10609
|
+
var instanceCount = 0;
|
|
10426
10610
|
var QualityTarget = class extends VBElement {
|
|
10427
10611
|
static formAssociated = true;
|
|
10428
10612
|
static get observedAttributes() {
|
|
@@ -10431,7 +10615,6 @@ var QualityTarget = class extends VBElement {
|
|
|
10431
10615
|
"data-capacity-points",
|
|
10432
10616
|
"data-cost-weights",
|
|
10433
10617
|
"data-radius",
|
|
10434
|
-
"data-show-envelope",
|
|
10435
10618
|
"data-min-rationale",
|
|
10436
10619
|
"data-max-rationale",
|
|
10437
10620
|
"data-min-overrun-rationale",
|
|
@@ -10462,6 +10645,9 @@ var QualityTarget = class extends VBElement {
|
|
|
10462
10645
|
#dialogs = {};
|
|
10463
10646
|
/** @type {HTMLElement | null} */
|
|
10464
10647
|
#footer = null;
|
|
10648
|
+
/** Per-instance prefix so generated label/control ids never collide
|
|
10649
|
+
* when a page holds more than one target. */
|
|
10650
|
+
#uid = `quality-target-${++instanceCount}`;
|
|
10465
10651
|
constructor() {
|
|
10466
10652
|
super();
|
|
10467
10653
|
this.#internals = this.attachInternals();
|
|
@@ -10472,6 +10658,7 @@ var QualityTarget = class extends VBElement {
|
|
|
10472
10658
|
this.#bindToTriangle();
|
|
10473
10659
|
this.#renderViz();
|
|
10474
10660
|
this.#ensureFooter();
|
|
10661
|
+
this.#publishFormValue();
|
|
10475
10662
|
this.listen(this, "click", (e) => this.#onClick(e));
|
|
10476
10663
|
this.listen(this, "keydown", (e) => this.#onKeydown(e));
|
|
10477
10664
|
this.listen(this, "input", (e) => this.#onFooterInput(e));
|
|
@@ -10490,8 +10677,11 @@ var QualityTarget = class extends VBElement {
|
|
|
10490
10677
|
} else if (name === "data-cost-weights") {
|
|
10491
10678
|
this.#refreshCostWeights();
|
|
10492
10679
|
this.#renderViz();
|
|
10493
|
-
} else if (name === "data-radius"
|
|
10680
|
+
} else if (name === "data-radius") {
|
|
10494
10681
|
this.#renderViz();
|
|
10682
|
+
} else if (name === "data-min-overrun-rationale" || name === "data-max-overrun-rationale") {
|
|
10683
|
+
this.#updateFooter();
|
|
10684
|
+
this.#publishFormValue();
|
|
10495
10685
|
} else if (name === "disabled" || name === "locked") {
|
|
10496
10686
|
this.#syncDisabledLocked();
|
|
10497
10687
|
}
|
|
@@ -10563,7 +10753,7 @@ var QualityTarget = class extends VBElement {
|
|
|
10563
10753
|
}
|
|
10564
10754
|
/** Imperatively open one axis's editor. */
|
|
10565
10755
|
openEditor(ility) {
|
|
10566
|
-
if (!this.#ilities.includes(ility)) return;
|
|
10756
|
+
if (!this.#ilities.includes(ility) || this.#inert) return;
|
|
10567
10757
|
const dialog = this.#ensureDialog(ility);
|
|
10568
10758
|
if (typeof dialog.showModal === "function") dialog.showModal();
|
|
10569
10759
|
else dialog.setAttribute("open", "");
|
|
@@ -10641,10 +10831,10 @@ var QualityTarget = class extends VBElement {
|
|
|
10641
10831
|
form.append(fs);
|
|
10642
10832
|
const ff = document.createElement("form-field");
|
|
10643
10833
|
const rl = document.createElement("label");
|
|
10644
|
-
rl.setAttribute("for",
|
|
10834
|
+
rl.setAttribute("for", `${this.#uid}-${ility}-rationale`);
|
|
10645
10835
|
rl.textContent = "Rationale (required when Critical)";
|
|
10646
10836
|
const ta = document.createElement("textarea");
|
|
10647
|
-
ta.id =
|
|
10837
|
+
ta.id = `${this.#uid}-${ility}-rationale`;
|
|
10648
10838
|
ta.name = "rationale";
|
|
10649
10839
|
ta.rows = 3;
|
|
10650
10840
|
ta.minLength = this.#minRationale();
|
|
@@ -10698,6 +10888,7 @@ var QualityTarget = class extends VBElement {
|
|
|
10698
10888
|
this.#publish("dialog", ility);
|
|
10699
10889
|
});
|
|
10700
10890
|
this.#syncDialogFromState(ility);
|
|
10891
|
+
this.#syncDisabledLocked();
|
|
10701
10892
|
return dialog;
|
|
10702
10893
|
}
|
|
10703
10894
|
#syncDialogFromState(ility) {
|
|
@@ -10740,8 +10931,8 @@ var QualityTarget = class extends VBElement {
|
|
|
10740
10931
|
<strong>Over budget \u2014 explain why.</strong>
|
|
10741
10932
|
<small data-overrun-prompt></small>
|
|
10742
10933
|
<form-field>
|
|
10743
|
-
<label for="
|
|
10744
|
-
<textarea id="
|
|
10934
|
+
<label for="${this.#uid}-overrun">Overrun rationale (\u2265 ${this.#minOverrunRationale()} chars)</label>
|
|
10935
|
+
<textarea id="${this.#uid}-overrun" name="overrunRationale"
|
|
10745
10936
|
minlength="${this.#minOverrunRationale()}"
|
|
10746
10937
|
maxlength="${this.#maxOverrunRationale()}"
|
|
10747
10938
|
rows="3"></textarea>
|
|
@@ -10755,6 +10946,7 @@ var QualityTarget = class extends VBElement {
|
|
|
10755
10946
|
footer.append(errors);
|
|
10756
10947
|
this.append(footer);
|
|
10757
10948
|
this.#footer = footer;
|
|
10949
|
+
this.#syncDisabledLocked();
|
|
10758
10950
|
}
|
|
10759
10951
|
#updateFooter() {
|
|
10760
10952
|
if (!this.#footer) return;
|
|
@@ -10768,8 +10960,20 @@ var QualityTarget = class extends VBElement {
|
|
|
10768
10960
|
if (overrun) {
|
|
10769
10961
|
overrun.hidden = !isOver;
|
|
10770
10962
|
const prompt = overrun.querySelector("[data-overrun-prompt]");
|
|
10771
|
-
if (prompt
|
|
10772
|
-
|
|
10963
|
+
if (prompt) {
|
|
10964
|
+
const over = sum - cap;
|
|
10965
|
+
prompt.textContent = isOver ? `You're over budget by ${over} ${over === 1 ? "point" : "points"} (${sum}/${cap}).` : "";
|
|
10966
|
+
}
|
|
10967
|
+
const min = this.#minOverrunRationale();
|
|
10968
|
+
const label = overrun.querySelector("label");
|
|
10969
|
+
if (label) label.textContent = `Overrun rationale (\u2265 ${min} chars)`;
|
|
10970
|
+
const ta = (
|
|
10971
|
+
/** @type {HTMLTextAreaElement | null} */
|
|
10972
|
+
overrun.querySelector("textarea")
|
|
10973
|
+
);
|
|
10974
|
+
if (ta) {
|
|
10975
|
+
ta.minLength = min;
|
|
10976
|
+
ta.maxLength = this.#maxOverrunRationale();
|
|
10773
10977
|
}
|
|
10774
10978
|
}
|
|
10775
10979
|
}
|
|
@@ -10852,6 +11056,7 @@ var QualityTarget = class extends VBElement {
|
|
|
10852
11056
|
const existing = this.querySelector(":scope > svg");
|
|
10853
11057
|
if (existing) existing.replaceWith(svg);
|
|
10854
11058
|
else this.prepend(svg);
|
|
11059
|
+
this.#syncAxesInert();
|
|
10855
11060
|
}
|
|
10856
11061
|
#publish(source, field) {
|
|
10857
11062
|
this.#renderViz();
|
|
@@ -10878,14 +11083,67 @@ var QualityTarget = class extends VBElement {
|
|
|
10878
11083
|
this.#internals.setFormValue(JSON.stringify(this.value));
|
|
10879
11084
|
} catch {
|
|
10880
11085
|
}
|
|
11086
|
+
this.#syncValidity();
|
|
10881
11087
|
}
|
|
11088
|
+
/**
|
|
11089
|
+
* Mirror validateVector() into ElementInternals so a parent <form>'s
|
|
11090
|
+
* checkValidity() / submit is blocked too, not just this element's own
|
|
11091
|
+
* checkValidity() method. The overrun textarea anchors the message when
|
|
11092
|
+
* the overrun rationale is the problem.
|
|
11093
|
+
*/
|
|
11094
|
+
#syncValidity() {
|
|
11095
|
+
if (typeof this.#internals.setValidity !== "function") return;
|
|
11096
|
+
const { valid, errors } = validateVector({
|
|
11097
|
+
vector: this.#vector,
|
|
11098
|
+
rationales: this.#rationales,
|
|
11099
|
+
costWeights: this.#costWeights,
|
|
11100
|
+
capacityPoints: this.#resolveCapacity(),
|
|
11101
|
+
overrunRationale: this.#overrunRationale,
|
|
11102
|
+
minRationale: this.#minRationale(),
|
|
11103
|
+
minOverrunRationale: this.#minOverrunRationale()
|
|
11104
|
+
});
|
|
11105
|
+
if (valid) {
|
|
11106
|
+
this.#internals.setValidity({});
|
|
11107
|
+
return;
|
|
11108
|
+
}
|
|
11109
|
+
const overrunTa = (
|
|
11110
|
+
/** @type {HTMLElement | null} */
|
|
11111
|
+
this.overBudget ? this.querySelector("[data-quality-overrun] textarea") : null
|
|
11112
|
+
);
|
|
11113
|
+
try {
|
|
11114
|
+
this.#internals.setValidity({ customError: true }, errors.join(" "), overrunTa ?? void 0);
|
|
11115
|
+
} catch {
|
|
11116
|
+
this.#internals.setValidity({ customError: true }, errors.join(" "));
|
|
11117
|
+
}
|
|
11118
|
+
}
|
|
11119
|
+
get #inert() {
|
|
11120
|
+
return this.hasAttribute("disabled") || this.hasAttribute("locked");
|
|
11121
|
+
}
|
|
11122
|
+
/**
|
|
11123
|
+
* disabled / locked: no editing path stays open. styles.css already sets
|
|
11124
|
+
* pointer-events: none; this closes the keyboard and programmatic paths
|
|
11125
|
+
* (axes leave the tab order, openEditor() is a no-op) and disables every
|
|
11126
|
+
* dialog and footer control, including dialogs built later.
|
|
11127
|
+
*/
|
|
10882
11128
|
#syncDisabledLocked() {
|
|
10883
|
-
const off = this
|
|
11129
|
+
const off = this.#inert;
|
|
10884
11130
|
for (const dialog of Object.values(this.#dialogs)) {
|
|
10885
11131
|
for (const el of dialog.querySelectorAll("input, textarea, select, button")) {
|
|
10886
11132
|
el.disabled = off;
|
|
10887
11133
|
}
|
|
10888
11134
|
}
|
|
11135
|
+
for (const el of this.#footer?.querySelectorAll("textarea") ?? []) {
|
|
11136
|
+
el.disabled = off;
|
|
11137
|
+
}
|
|
11138
|
+
this.#syncAxesInert();
|
|
11139
|
+
}
|
|
11140
|
+
#syncAxesInert() {
|
|
11141
|
+
const off = this.#inert;
|
|
11142
|
+
for (const axis of this.querySelectorAll(":scope > svg .axis[data-ility]")) {
|
|
11143
|
+
axis.setAttribute("tabindex", off ? "-1" : "0");
|
|
11144
|
+
if (off) axis.setAttribute("aria-disabled", "true");
|
|
11145
|
+
else axis.removeAttribute("aria-disabled");
|
|
11146
|
+
}
|
|
10889
11147
|
}
|
|
10890
11148
|
// ── Internal: config readers ──────────────────────────────────────
|
|
10891
11149
|
#minRationale() {
|
|
@@ -11040,6 +11298,8 @@ var TEMPLATE = `
|
|
|
11040
11298
|
</article>
|
|
11041
11299
|
`;
|
|
11042
11300
|
var RequirementCard = class extends VBElement {
|
|
11301
|
+
/** True when this element added role="button" itself (so it may remove it). */
|
|
11302
|
+
#ownsRole = false;
|
|
11043
11303
|
static get observedAttributes() {
|
|
11044
11304
|
return ["data-priority", "data-conflict", "tabindex"];
|
|
11045
11305
|
}
|
|
@@ -11062,6 +11322,15 @@ var RequirementCard = class extends VBElement {
|
|
|
11062
11322
|
this.#syncDefaultPill();
|
|
11063
11323
|
this.#syncInteractive();
|
|
11064
11324
|
this.listen(this, "click", (e) => this.#onActivate(e));
|
|
11325
|
+
const wrapper = this.closest("a[href], button");
|
|
11326
|
+
if (wrapper) {
|
|
11327
|
+
this.listen(wrapper, "click", (e) => {
|
|
11328
|
+
if (!this.contains(
|
|
11329
|
+
/** @type {Node} */
|
|
11330
|
+
e.target
|
|
11331
|
+
)) this.#onActivate(e);
|
|
11332
|
+
});
|
|
11333
|
+
}
|
|
11065
11334
|
this.listen(this, "keydown", (e) => {
|
|
11066
11335
|
const ke = (
|
|
11067
11336
|
/** @type {KeyboardEvent} */
|
|
@@ -11104,7 +11373,7 @@ var RequirementCard = class extends VBElement {
|
|
|
11104
11373
|
return this.hasAttribute("tabindex") || this.closest("a[href], button");
|
|
11105
11374
|
}
|
|
11106
11375
|
#syncSlot(name, slot) {
|
|
11107
|
-
const hasContent = !!slot && slot.assignedNodes(
|
|
11376
|
+
const hasContent = !!slot && slot.assignedNodes().some(
|
|
11108
11377
|
(n) => n.nodeType === Node.ELEMENT_NODE || n.nodeType === Node.TEXT_NODE && n.textContent.trim().length > 0
|
|
11109
11378
|
);
|
|
11110
11379
|
this.setState(`has-${name}`, hasContent);
|
|
@@ -11121,6 +11390,14 @@ var RequirementCard = class extends VBElement {
|
|
|
11121
11390
|
}
|
|
11122
11391
|
#syncInteractive() {
|
|
11123
11392
|
this.setState("interactive", !!this.#interactive);
|
|
11393
|
+
const needsRole = this.hasAttribute("tabindex") && !this.closest("a[href], button");
|
|
11394
|
+
if (needsRole && !this.hasAttribute("role")) {
|
|
11395
|
+
this.setAttribute("role", "button");
|
|
11396
|
+
this.#ownsRole = true;
|
|
11397
|
+
} else if (!needsRole && this.#ownsRole) {
|
|
11398
|
+
this.removeAttribute("role");
|
|
11399
|
+
this.#ownsRole = false;
|
|
11400
|
+
}
|
|
11124
11401
|
}
|
|
11125
11402
|
#onActivate(event) {
|
|
11126
11403
|
if (!this.#interactive) return;
|