Files
devplacepy/devplacepy/static/js/components/AppDialog.js
T
retoorandClaude Opus 5 91fac7fd67 Gate blocked actions behind an in-place terms acceptance dialog
A member whose account has not accepted the terms in force now gets one
dialog on the action they attempted instead of a dead-end refusal. The
client handler is the single TermsGate, wired into every Http POST helper
so the four optimistic controllers cannot swallow the gate into an error
flash, and the original request is replayed once the acceptance is
recorded. Reading the site and deleting an account stay unblocked.

apple.md is the source brief the compliance research documents reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:25:57 +02:00

169 lines
6.4 KiB
JavaScript

// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class AppDialog extends Component {
connectedCallback() {
if (this._built) {
return;
}
this._built = true;
this.resolver = null;
this.mode = "confirm";
this.lastFocus = null;
this.build();
}
build() {
const overlay = document.createElement("div");
overlay.className = "dialog-overlay";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.innerHTML =
'<div class="card dialog-card">' +
'<div class="modal-header"><h3 class="dialog-title"></h3>' +
'<button type="button" class="modal-close btn-ghost btn-icon dialog-close">&times;</button></div>' +
'<p class="dialog-message"></p>' +
'<ul class="dialog-links" hidden></ul>' +
'<div class="dialog-field" hidden><label class="dialog-field-label"></label>' +
'<input type="text" class="dialog-input" autocomplete="off"></div>' +
'<div class="modal-footer">' +
'<button type="button" class="btn btn-secondary dialog-cancel">Cancel</button>' +
'<button type="button" class="btn btn-primary dialog-confirm">OK</button>' +
"</div></div>";
this.appendChild(overlay);
this.overlay = overlay;
this.titleEl = overlay.querySelector(".dialog-title");
this.messageEl = overlay.querySelector(".dialog-message");
const uid = Math.random().toString(36).slice(2, 9);
this.titleEl.id = `dialog-title-${uid}`;
this.messageEl.id = `dialog-message-${uid}`;
overlay.setAttribute("aria-labelledby", this.titleEl.id);
overlay.setAttribute("aria-describedby", this.messageEl.id);
this.links = overlay.querySelector(".dialog-links");
this.field = overlay.querySelector(".dialog-field");
this.fieldLabel = overlay.querySelector(".dialog-field-label");
this.input = overlay.querySelector(".dialog-input");
this.cancelBtn = overlay.querySelector(".dialog-cancel");
this.confirmBtn = overlay.querySelector(".dialog-confirm");
overlay.addEventListener("click", (e) => {
if (e.target === overlay) this.dismiss();
});
overlay.querySelector(".dialog-close").addEventListener("click", () => this.dismiss());
this.cancelBtn.addEventListener("click", () => this.dismiss());
this.confirmBtn.addEventListener("click", () => this.accept());
this.input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
this.accept();
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && this.overlay.classList.contains("visible")) {
e.preventDefault();
this.dismiss();
}
});
overlay.addEventListener("keydown", (e) => {
if (e.key !== "Tab") return;
const items = [
...overlay.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
),
].filter((el) => el.offsetParent !== null);
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
}
open(mode, options) {
const opts = options || {};
this.mode = mode;
this.titleEl.textContent = opts.title || (mode === "prompt" ? "Rename" : mode === "alert" ? "Notice" : "Confirm");
this.messageEl.textContent = opts.message || "";
this.messageEl.hidden = !opts.message;
this.confirmBtn.textContent = opts.confirmLabel || (mode === "alert" ? "OK" : mode === "prompt" ? "Save" : "Confirm");
this.cancelBtn.textContent = opts.cancelLabel || "Cancel";
this.cancelBtn.style.display = mode === "alert" ? "none" : "";
this.confirmBtn.classList.toggle("dialog-danger", !!opts.danger);
this.renderLinks(opts.links);
if (mode === "prompt") {
this.field.hidden = false;
this.fieldLabel.textContent = opts.label || "";
this.fieldLabel.hidden = !opts.label;
this.input.value = opts.value || "";
this.input.placeholder = opts.placeholder || "";
} else {
this.field.hidden = true;
}
this.lastFocus = document.activeElement;
this.overlay.classList.add("visible");
const focusTarget = mode === "prompt" ? this.input : this.confirmBtn;
setTimeout(() => {
focusTarget.focus();
if (mode === "prompt") this.input.select();
}, 20);
return new Promise((resolve) => { this.resolver = resolve; });
}
renderLinks(links) {
const items = Array.isArray(links) ? links : [];
this.links.replaceChildren();
this.links.hidden = !items.length;
for (const item of items) {
const anchor = document.createElement("a");
anchor.href = item.href;
anchor.textContent = item.label;
anchor.target = "_blank";
anchor.rel = "noopener";
const row = document.createElement("li");
row.appendChild(anchor);
this.links.appendChild(row);
}
}
accept() {
const value = this.mode === "prompt" ? this.input.value : true;
this.close(value);
}
dismiss() {
this.close(this.mode === "prompt" ? null : this.mode === "alert" ? undefined : false);
}
close(value) {
this.overlay.classList.remove("visible");
if (this.lastFocus && this.lastFocus.focus) this.lastFocus.focus();
const resolve = this.resolver;
this.resolver = null;
if (resolve) resolve(value);
}
confirm(options) {
return this.open("confirm", options);
}
prompt(options) {
return this.open("prompt", options);
}
alert(options) {
return this.open("alert", options);
}
}
customElements.define("dp-dialog", AppDialog);