Files
roast_command_center/public/js/field-help.js
T
snowspeeder 824a964f9d
Test and deploy / test-and-deploy (push) Successful in 2m28s
Rebuild printable roast plans and remove machine-based adjustments
2026-09-05 14:56:11 -04:00

93 lines
3.7 KiB
JavaScript

// Field-level "?" reference dialogs. Sibling to why-panels.js: why-panels are always-visible
// section prose ("why this works"); this is an on-demand, per-field reference library ("what are
// my options"). See FIELD_HELP_SPEC.md (deleted after implementation) for the design rationale.
import { HELP_TOPICS } from "./field-help-content.js?v=__ASSET_VERSION__";
export function initFieldHelp({ getPlan, getTempUnit, getMachineProfile }) {
const dialog = document.getElementById("field-help-dialog");
if (!dialog) return;
const titleEl = document.getElementById("help-dialog-title");
const bodyEl = document.getElementById("help-dialog-body");
let lastTrigger = null;
function openTopic(key, trigger) {
const topic = HELP_TOPICS[key];
if (!topic) return;
lastTrigger = trigger;
titleEl.textContent = topic.title;
bodyEl.replaceChildren(topic.render(getPlan(), getTempUnit?.(), getMachineProfile?.()));
bodyEl.scrollTop = 0;
dialog.showModal();
}
// A button appended into a `.field-label` span is a labelable element, and a <label>
// without an explicit `for` labels its FIRST labelable descendant in tree order — so an
// unlabeled wrapping <label> would silently make the help button, not the input, the
// label's control. Give the input an explicit id/for pairing before that can happen; a
// no-op wherever the container isn't inside a <label> or is already explicitly paired.
function ensureLabelAssociation(container) {
const label = container.closest("label");
if (!label || label.hasAttribute("for")) return;
const control = label.querySelector("input, select, textarea");
if (!control) return;
if (!control.id)
control.id = `fh-${(control.name || `${Math.random()}`).replace(/[^\w-]/g, "-")}`;
label.setAttribute("for", control.id);
}
function makeButton(key, title) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "help-btn";
btn.dataset.helpTopic = key;
btn.setAttribute("aria-label", `Field guide: ${title.split(" — ")[0]}`);
btn.setAttribute("aria-haspopup", "dialog");
btn.textContent = "?";
// Several triggers live inside <label> elements — stop the click from also
// focusing/toggling the label's control (radio chips especially).
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
openTopic(key, btn);
});
return btn;
}
for (const [key, topic] of Object.entries(HELP_TOPICS)) {
const containers = [];
for (const sel of topic.targets ?? [])
containers.push(...document.querySelectorAll(`#plan-form ${sel}`));
if (topic.place) containers.push(...topic.place());
if (containers.length === 0)
console.warn(`field-help: topic "${key}" resolved zero containers — its "?" button is missing.`);
for (const container of containers) {
ensureLabelAssociation(container);
container.append(makeButton(key, topic.title));
}
}
// Deferred: a click that closes the dialog (the ✕ button, or a backdrop click) also runs
// the browser's own click-focuses-the-target step, which can otherwise land after — and
// override — a synchronous focus() call made while handling that same click.
function returnFocus() {
const trigger = lastTrigger;
setTimeout(() => trigger?.focus(), 0);
}
dialog.querySelector("[data-close-help]")?.addEventListener("click", () => {
dialog.close();
returnFocus();
});
dialog.addEventListener("click", (e) => {
if (e.target === dialog) {
dialog.close();
returnFocus();
}
});
// Covers every other close path (Esc, or dialog.close() called directly) — native <dialog>
// already restores focus to the pre-showModal() element on close in current browsers; this
// listener is the explicit fallback for engines where that isn't reliable.
dialog.addEventListener("close", returnFocus);
}