From 7a28100a4b967d595c907f8b1e79b762eb70c774 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Sat, 8 Aug 2026 06:06:14 -0400 Subject: [PATCH] =?UTF-8?q?Add=20TigerModal=20=E2=80=94=20in-app=20confirm?= =?UTF-8?q?/prompt/alert=20house=20primitive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The house rule bans the browser's native confirm()/prompt()/alert() (unstyled, un-themeable, freeze the tab), but until now there was no shared in-app replacement — so modules hand-rolled their own (TigerStripe's PayDialog, TigerLicense's Dialog: the same helper twice). Promote it to a core theme primitive alongside TigerButton/TigerDOM. TigerModal.confirm() -> Promise, prompt() -> Promise (null on cancel, mirroring window.prompt), alert() -> Promise. Promise-based, themed, builds its one reusable Bootstrap modal on demand (like TigerDOM.notify builds its alert) so a view needs no modal markup. Enter submits; field focuses on open; a confirm settles before hide so the trailing hidden.bs.modal no-ops. Depends only on bootstrap.Modal — enqueued after the bundle in all three layouts (public/admin/auth), never on jQuery/TigerDOM. Documented in AGENTS.md's UI/UX section. The two private modules migrate to it next (once this releases and lands on the boxes), retiring their local copies. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 10 ++ themes/puma/assets/js/tiger.modal.js | 128 +++++++++++++++++++++++ themes/puma/layouts/scripts/admin.phtml | 1 + themes/puma/layouts/scripts/auth.phtml | 1 + themes/puma/layouts/scripts/layout.phtml | 1 + 5 files changed, 141 insertions(+) create mode 100644 themes/puma/assets/js/tiger.modal.js diff --git a/AGENTS.md b/AGENTS.md index d73207d..7299493 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,6 +143,16 @@ again.** - **`TigerDOM.expand/collapse/toggle`** — the underlying reveal primitives (Web Animations API, interruptible, `prefers-reduced-motion`-aware). Reach for these for any show/hide (submenus, accordions, panels) instead of a jQuery slide or a raw `display` flip. +- **`TigerModal.confirm/prompt/alert`** (`tiger.modal.js`) — the in-app replacement for the browser's + native `confirm()`/`prompt()`/`alert()`, which are **never** used (unstyled, un-themeable, freeze the + tab). Promise-based, themed, and it builds its one reusable Bootstrap modal on demand so a view needs + no modal markup: `confirm()`→`Promise`, `prompt()`→`Promise` (`null` on cancel, + mirroring `window.prompt` so `if (v === null) return;` swaps straight in). Enter submits; the field + focuses on open. Depends only on Bootstrap (loaded ahead of it in every layout). + ```js + TigerModal.confirm({ title: 'Remove card', body: 'Remove this card?', confirmLabel: 'Remove', variant: 'danger' }) + .then(function (ok) { if (!ok) { return; } /* … */ }); + ``` The rule of thumb: **a control should always say what it's doing.** A save button that just sits there, or a message that blinks into existence and never leaves, is the tell of a cheap UI. These diff --git a/themes/puma/assets/js/tiger.modal.js b/themes/puma/assets/js/tiger.modal.js new file mode 100644 index 0000000..8f655d8 --- /dev/null +++ b/themes/puma/assets/js/tiger.modal.js @@ -0,0 +1,128 @@ +/*! SPDX-License-Identifier: BSD-3-Clause · © 2026 WebTigers · Tiger™/WebTigers™ are trademarks */ +/** + * tiger.modal.js — in-app confirm / prompt / alert dialogs (TigerModal). + * + * The house replacement for the browser's native confirm()/prompt()/alert() — which are unstyled, + * un-themeable, and freeze the whole tab. TigerModal returns a Promise and renders inside the theme, + * so a control's "are you sure?" or "name this thing" moment looks like the rest of the product. + * + * TigerModal.confirm({ title, body, confirmLabel, cancelLabel, variant }) -> Promise + * TigerModal.prompt({ title, label, value, placeholder, help, confirmLabel, variant }) -> Promise + * TigerModal.alert({ title, body, confirmLabel, variant }) -> Promise + * + * confirm resolves true (confirmed) or false (Cancel / ✕ / Esc / backdrop). prompt resolves the entered + * string (raw — the caller trims) or null when cancelled, so a call site's `if (v === null) return;` + * mirrors window.prompt exactly. Enter in the field submits; the field focuses (and selects) on open. + * + * TigerModal.confirm({ title: 'Remove card', body: 'Remove this card?', confirmLabel: 'Remove', variant: 'danger' }) + * .then(function (ok) { if (!ok) { return; } ... }); + * TigerModal.prompt({ title: 'Name this card', label: 'Card name', value: current }) + * .then(function (name) { if (name === null) { return; } ... }); + * + * One reusable Bootstrap modal is built lazily on first use and injected into the DOM (the same + * build-the-element-on-demand move as TigerDOM.notify), so a view needs no modal markup. Depends only + * on Bootstrap's bundle (bootstrap.Modal) — loaded ahead of this in every layout — never on jQuery/TigerDOM. + * + * @api + */ +(function () { + 'use strict'; + if (typeof window.bootstrap === 'undefined' || !window.bootstrap.Modal) { return; } + + var el = null, modal = null, resolver = null, mode = 'confirm'; + + function q(sel) { return el.querySelector('[data-tm="' + sel + '"]'); } + + // Resolve the pending promise exactly once. A confirm/OK settles its value BEFORE hiding, so the + // subsequent hidden.bs.modal (which would settle the cancel value) finds no resolver and no-ops. + function settle(value) { + if (!resolver) { return; } + var r = resolver; resolver = null; r(value); + } + + function build() { + if (el) { return; } + el = document.createElement('div'); + el.className = 'modal fade'; + el.tabIndex = -1; + el.setAttribute('aria-hidden', 'true'); + el.innerHTML = + ''; + document.body.appendChild(el); + modal = window.bootstrap.Modal.getOrCreateInstance(el); + + q('confirm').addEventListener('click', function () { + settle(mode === 'prompt' ? q('input').value : true); + modal.hide(); + }); + q('input').addEventListener('keydown', function (e) { + if (e.key === 'Enter') { e.preventDefault(); q('confirm').click(); } + }); + // Any dismissal that isn't a confirm (Cancel, ✕, Esc, backdrop) resolves the cancel value. + el.addEventListener('hidden.bs.modal', function () { + settle(mode === 'prompt' ? null : (mode === 'alert' ? undefined : false)); + }); + el.addEventListener('shown.bs.modal', function () { + if (mode === 'prompt') { var i = q('input'); i.focus(); i.select(); } + else { q('confirm').focus(); } + }); + } + + function open(opts, kind) { + build(); + settle(kind === 'prompt' ? null : (kind === 'alert' ? undefined : false)); // cancel any dialog still open + mode = kind; + + q('title').textContent = opts.title || (kind === 'prompt' ? 'Enter a value' : (kind === 'alert' ? 'Notice' : 'Are you sure?')); + var body = q('body'); + body.textContent = opts.body || ''; + body.classList.toggle('d-none', !opts.body); + + var cancel = q('cancel'); + cancel.textContent = opts.cancelLabel || 'Cancel'; + cancel.classList.toggle('d-none', kind === 'alert'); // an alert is a single-button acknowledgement + + var conf = q('confirm'); + conf.textContent = opts.confirmLabel || (kind === 'prompt' ? 'Save' : (kind === 'alert' ? 'OK' : 'Confirm')); + conf.className = 'btn btn-' + (opts.variant || 'primary'); + + var field = q('field'); + if (kind === 'prompt') { + field.classList.remove('d-none'); + var label = q('label'); + label.textContent = opts.label || ''; + label.classList.toggle('d-none', !opts.label); + var input = q('input'); + input.value = opts.value != null ? String(opts.value) : ''; + input.placeholder = opts.placeholder || ''; + var help = q('help'); + help.textContent = opts.help || ''; + help.classList.toggle('d-none', !opts.help); + } else { + field.classList.add('d-none'); + } + + return new Promise(function (resolve) { resolver = resolve; modal.show(); }); + } + + window.TigerModal = { + confirm: function (opts) { return open(opts || {}, 'confirm'); }, + prompt: function (opts) { return open(opts || {}, 'prompt'); }, + alert: function (opts) { return open(opts || {}, 'alert'); } + }; +})(); diff --git a/themes/puma/layouts/scripts/admin.phtml b/themes/puma/layouts/scripts/admin.phtml index 4d0cbf5..c69e0ec 100644 --- a/themes/puma/layouts/scripts/admin.phtml +++ b/themes/puma/layouts/scripts/admin.phtml @@ -124,6 +124,7 @@ $_hasAgent = class_exists('Tiger_Agent') && Tiger_Agent::isAvailable(); + diff --git a/themes/puma/layouts/scripts/auth.phtml b/themes/puma/layouts/scripts/auth.phtml index 8061213..7ef90bf 100644 --- a/themes/puma/layouts/scripts/auth.phtml +++ b/themes/puma/layouts/scripts/auth.phtml @@ -47,6 +47,7 @@ $_lang = defined('LANG') ? LANG : 'en'; + diff --git a/themes/puma/layouts/scripts/layout.phtml b/themes/puma/layouts/scripts/layout.phtml index 0da4d47..349a2fe 100644 --- a/themes/puma/layouts/scripts/layout.phtml +++ b/themes/puma/layouts/scripts/layout.phtml @@ -67,6 +67,7 @@ $_lang = defined('LANG') ? LANG : 'en'; render('_partials/consent-banner.phtml') ?> +