/** * action-confirm — modal dialog that requires a non-empty comment before * confirming an action (e.g. resolving a decision, marking an intervention done). * * Lives on document.body so it survives live-poll re-renders of the page content. * * Usage: * import {openActionConfirm} from "./components/action-confirm.js"; * * openActionConfirm({ * title: "Mark as Done", // modal header * entityTitle: task.title, // shown as context below the header * label: "Resolution comment", // textarea label * placeholder: "What was done?", // textarea placeholder * confirmLabel: "Mark Done", // confirm button text * onConfirm: async (comment) => { ... }, // called with trimmed comment * // onConfirm should throw (or return a rejected promise) on API error * }); */ const _STYLE_ID = "action-confirm-styles"; function _ensureStyles() { if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return; const s = document.createElement("style"); s.id = _STYLE_ID; s.textContent = ` /* ── Backdrop ────────────────────────────────────────────────────────────── */ .ac-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 9200; display: flex; align-items: center; justify-content: center; animation: _ac-fade 0.15s ease; } @keyframes _ac-fade { from { opacity: 0 } to { opacity: 1 } } /* ── Box ──────────────────────────────────────────────────────────────────── */ .ac-box { width: min(480px, 92vw); background: var(--theme-background, #fff); border-radius: 12px; box-shadow: 0 16px 56px rgba(0,0,0,0.28); display: flex; flex-direction: column; overflow: hidden; animation: _ac-rise 0.15s ease; } @keyframes _ac-rise { from { transform: translateY(12px); opacity: 0 } to { transform: translateY(0); opacity: 1 } } /* ── Header ───────────────────────────────────────────────────────────────── */ .ac-header { display: flex; align-items: flex-start; gap: 0.75rem; padding: 0.85rem 1rem 0.75rem; border-bottom: 1px solid var(--theme-foreground-faint, #e8e8e8); background: var(--theme-background-alt, #f7f7f7); } .ac-title { flex: 1; font-size: 0.95rem; font-weight: 700; line-height: 1.3; color: var(--theme-foreground, #111); } .ac-close { background: none; border: 1px solid transparent; cursor: pointer; font-size: 0.9rem; color: var(--theme-foreground-muted, #888); padding: 0.15rem 0.45rem; border-radius: 6px; flex-shrink: 0; font-family: inherit; line-height: 1.3; } .ac-close:hover { border-color: var(--theme-foreground-faint, #ccc); background: var(--theme-background, #fff); color: var(--theme-foreground, #111); } /* ── Body ─────────────────────────────────────────────────────────────────── */ .ac-body { padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; } .ac-entity-title { font-size: 0.85rem; color: var(--theme-foreground-muted, #555); background: var(--theme-background-alt, #f9f9f9); border: 1px solid var(--theme-foreground-faint, #eee); border-radius: 6px; padding: 0.45rem 0.65rem; line-height: 1.45; word-break: break-word; } .ac-label { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--theme-foreground-muted, #666); margin-bottom: 0.25rem; display: block; } .ac-textarea { width: 100%; box-sizing: border-box; min-height: 80px; resize: vertical; font-size: 0.87rem; line-height: 1.5; font-family: inherit; padding: 0.5rem 0.65rem; border: 1px solid var(--theme-foreground-faint, #d1d5db); border-radius: 6px; background: var(--theme-background, #fff); color: var(--theme-foreground, #111); transition: border-color 0.1s, box-shadow 0.1s; } .ac-textarea:focus { outline: none; border-color: steelblue; box-shadow: 0 0 0 2px #bfdbfe; } .ac-textarea.ac-invalid { border-color: #dc2626; box-shadow: 0 0 0 2px #fecaca; } .ac-error { font-size: 0.8rem; color: #dc2626; background: #fef2f2; border: 1px solid #fecaca; border-radius: 6px; padding: 0.35rem 0.6rem; } /* ── Footer ───────────────────────────────────────────────────────────────── */ .ac-footer { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 0.65rem 1rem 0.9rem; border-top: 1px solid var(--theme-foreground-faint, #e8e8e8); } .ac-btn-cancel { padding: 0.3rem 0.85rem; border-radius: 6px; border: 1px solid var(--theme-foreground-faint, #d1d5db); background: var(--theme-background, #fff); color: var(--theme-foreground-muted, #555); font-size: 0.85rem; font-family: inherit; cursor: pointer; } .ac-btn-cancel:hover { border-color: #9ca3af; color: var(--theme-foreground, #111); } .ac-btn-confirm { padding: 0.3rem 0.85rem; border-radius: 6px; border: 1px solid #22c55e; background: #f0fdf4; color: #166534; font-size: 0.85rem; font-weight: 600; font-family: inherit; cursor: pointer; transition: background 0.1s; } .ac-btn-confirm:hover:not(:disabled) { background: #dcfce7; } .ac-btn-confirm:disabled { opacity: 0.5; cursor: not-allowed; } `; document.head.append(s); } /** * @param {object} opts * @param {string} opts.title Modal header text * @param {string} [opts.entityTitle] Optional context shown below the header * @param {string} opts.label Textarea label * @param {string} [opts.placeholder] Textarea placeholder * @param {string} [opts.confirmLabel] Confirm button label (default: "Confirm") * @param {Function} opts.onConfirm async (comment: string) => void — throw to show error */ export function openActionConfirm({ title, entityTitle, label, placeholder = "Add a comment…", confirmLabel = "Confirm", onConfirm, }) { _ensureStyles(); document.getElementById("_ac-root")?.remove(); const root = document.createElement("div"); root.id = "_ac-root"; root.className = "ac-backdrop"; root.setAttribute("role", "dialog"); root.setAttribute("aria-modal", "true"); root.setAttribute("aria-label", title); // ── Header ──────────────────────────────────────────────────────────────── const header = document.createElement("div"); header.className = "ac-header"; const titleEl = document.createElement("div"); titleEl.className = "ac-title"; titleEl.textContent = title; const closeBtn = document.createElement("button"); closeBtn.className = "ac-close"; closeBtn.textContent = "✕"; closeBtn.setAttribute("aria-label", "Cancel"); header.append(titleEl, closeBtn); // ── Body ────────────────────────────────────────────────────────────────── const body = document.createElement("div"); body.className = "ac-body"; if (entityTitle) { const ctx = document.createElement("div"); ctx.className = "ac-entity-title"; ctx.textContent = entityTitle; body.append(ctx); } const labelEl = document.createElement("label"); labelEl.className = "ac-label"; labelEl.textContent = label; const textarea = document.createElement("textarea"); textarea.className = "ac-textarea"; textarea.placeholder = placeholder; textarea.rows = 3; labelEl.append(textarea); // make label clickable const fieldWrap = document.createElement("div"); fieldWrap.append(labelEl); body.append(fieldWrap); const errorEl = document.createElement("div"); errorEl.className = "ac-error"; errorEl.style.display = "none"; body.append(errorEl); // ── Footer ──────────────────────────────────────────────────────────────── const footer = document.createElement("div"); footer.className = "ac-footer"; const cancelBtn = document.createElement("button"); cancelBtn.className = "ac-btn-cancel"; cancelBtn.textContent = "Cancel"; const confirmBtn = document.createElement("button"); confirmBtn.className = "ac-btn-confirm"; confirmBtn.textContent = confirmLabel; footer.append(cancelBtn, confirmBtn); // ── Assemble ────────────────────────────────────────────────────────────── const box = document.createElement("div"); box.className = "ac-box"; box.append(header, body, footer); root.append(box); document.body.append(root); // Focus the textarea after animation setTimeout(() => textarea.focus(), 50); // ── Behaviour ───────────────────────────────────────────────────────────── const close = () => root.remove(); cancelBtn.addEventListener("click", close); closeBtn.addEventListener("click", close); root.addEventListener("click", e => { if (e.target === root) close(); }); const onKey = e => { if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); } }; document.addEventListener("keydown", onKey); textarea.addEventListener("input", () => { textarea.classList.remove("ac-invalid"); errorEl.style.display = "none"; }); confirmBtn.addEventListener("click", async () => { const comment = textarea.value.trim(); if (!comment) { textarea.classList.add("ac-invalid"); textarea.focus(); return; } confirmBtn.disabled = true; cancelBtn.disabled = true; confirmBtn.textContent = "…"; errorEl.style.display = "none"; try { await onConfirm(comment); close(); } catch (err) { errorEl.textContent = err?.message ?? "Request failed — check that the API is running."; errorEl.style.display = ""; confirmBtn.disabled = false; cancelBtn.disabled = false; confirmBtn.textContent = confirmLabel; } }); }