|
/** |
|
* @typedef {Object} FieldOption |
|
* @property {string} [value] - The underlying data value for the option. Defaults to the label if omitted. |
|
* @property {string} label - The visible text display for the option. |
|
*/ |
|
|
|
/** |
|
* @typedef {Object} BoxField |
|
* @property {string} label - The unique identifier and visible text label for the form field. |
|
* @property {string} [description] - Optional subtext or instructions displayed below the label. |
|
* @property {"text" | "textarea" | "select"} type - The input field control type. |
|
* @property {FieldOption[]} [options] - Array of dropdown choices. Only applicable when type is "select". |
|
* @property {string} [value] - The default initial value for the field. |
|
*/ |
|
|
|
/** |
|
* @typedef {Object} BoxButton |
|
* @property {string} label - The text displayed inside the button. |
|
* @property {string} [color] - A valid CSS color string (e.g., "#dc3545", "blue") to override the background. |
|
* @property {"enter" | "esc"} [key] - Maps the keyboard trigger to this button. The last button defined with a mapping wins. |
|
* @property {string} [icon] - An emoji string to display next to the button label. |
|
*/ |
|
|
|
/** |
|
* @typedef {Object} ShowBoxOptions |
|
* @property {string} [title] - Optional title heading shown at the top of the box. |
|
* @property {string} message - The main text description or question shown in the prompt body. |
|
* @property {"error" | "question" | "warn" | "info" | string} [icon] - A predefined alert keyword or a custom emoji string. |
|
* @property {BoxButton[]} buttons - An array of configuration objects defining the prompt's action buttons. |
|
* @property {BoxField[]} [fields] - An optional array of input definitions to inject form controls into the prompt body. |
|
* @property {string} [className] - An optional top-level class name added to the dialog element for custom CSS targeting. |
|
* @property {boolean} [isDark] - Explicit theme toggle. If omitted, matches system preference dynamically. |
|
*/ |
|
|
|
/** |
|
* @typedef {Object} ShowBoxResponse |
|
* @property {BoxButton} button - The configuration object of the button that was clicked or triggered via keyboard shortcuts. |
|
* @property {Object<string, string>} data - A collection of key-value pairs matching form field values, keyed by their field `label`. |
|
*/ |
|
|
|
/** |
|
* Displays a custom, accessible async modal prompt using the HTML5 `<dialog>` API. |
|
* Supports theme sensing, dynamic layout injects, form captures, and custom button bindings. |
|
* |
|
* @global |
|
* @function showBox |
|
* @param {ShowBoxOptions} options - The explicit configuration layout parameter object. |
|
* @returns {Promise<ShowBoxResponse>} A promise that resolves when a selection action completes. |
|
*/ |
|
var showBox = (() => { |
|
function ensureStyles() { |
|
if (document.getElementById("custom-msgbox-styles")) return; |
|
|
|
const style = document.createElement("style"); |
|
style.id = "custom-msgbox-styles"; |
|
style.textContent = ` |
|
:root { |
|
--mb-bg: #ffffff; |
|
--mb-text: #212529; |
|
--mb-border: #dee2e6; |
|
--mb-input-bg: #ffffff; |
|
--mb-input-text: #212529; |
|
--mb-input-border: #ced4da; |
|
--mb-desc-text: #6c757d; |
|
--btn-bg: #0d6efd; |
|
--btn-text: #ffffff; |
|
--backdrop-bg: rgba(0, 0, 0, 0.5); |
|
} |
|
|
|
/* Default automatic dark mode detection fallback */ |
|
@media (prefers-color-scheme: dark) { |
|
.custom-msgbox:not([data-theme="light"]) { |
|
--mb-bg: #212529; |
|
--mb-text: #f8f9fa; |
|
--mb-border: #495057; |
|
--mb-input-bg: #2b3035; |
|
--mb-input-text: #f8f9fa; |
|
--mb-input-border: #495057; |
|
--mb-desc-text: #adb5bd; |
|
--btn-bg: #0d6efd; |
|
--btn-text: #ffffff; |
|
--backdrop-bg: rgba(0, 0, 0, 0.7); |
|
} |
|
} |
|
|
|
/* Explicit theme overrides passed via options */ |
|
.custom-msgbox[data-theme="dark"] { |
|
--mb-bg: #212529; |
|
--mb-text: #f8f9fa; |
|
--mb-border: #495057; |
|
--mb-input-bg: #2b3035; |
|
--mb-input-text: #f8f9fa; |
|
--mb-input-border: #495057; |
|
--mb-desc-text: #adb5bd; |
|
--btn-bg: #0d6efd; |
|
--btn-text: #ffffff; |
|
--backdrop-bg: rgba(0, 0, 0, 0.7); |
|
} |
|
|
|
.custom-msgbox[data-theme="light"] { |
|
--mb-bg: #ffffff; |
|
--mb-text: #212529; |
|
--mb-border: #dee2e6; |
|
--mb-input-bg: #ffffff; |
|
--mb-input-text: #212529; |
|
--mb-input-border: #ced4da; |
|
--mb-desc-text: #6c757d; |
|
--btn-bg: #0d6efd; |
|
--btn-text: #ffffff; |
|
--backdrop-bg: rgba(0, 0, 0, 0.5); |
|
} |
|
|
|
.custom-msgbox { |
|
border: 1px solid var(--mb-border); |
|
border-radius: 8px; |
|
padding: 24px; |
|
background: var(--mb-bg); |
|
color: var(--mb-text); |
|
max-width: 440px; |
|
width: 100%; |
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25); |
|
font-family: system-ui, -apple-system, sans-serif; |
|
} |
|
|
|
.custom-msgbox::backdrop { |
|
background: var(--backdrop-bg); |
|
backdrop-filter: blur(2px); |
|
} |
|
|
|
.msgbox-content { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 18px; |
|
margin: 0; |
|
} |
|
|
|
.msgbox-title { |
|
margin: 0; |
|
font-size: 1.25rem; |
|
border-bottom: 1px solid var(--mb-border); |
|
padding-bottom: 10px; |
|
} |
|
|
|
.msgbox-body { |
|
display: flex; |
|
align-items: center; |
|
gap: 12px; |
|
font-size: 1rem; |
|
line-height: 1.5; |
|
} |
|
|
|
.msgbox-icon { |
|
font-size: 2rem; |
|
} |
|
|
|
.msgbox-fields { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 14px; |
|
} |
|
|
|
.msgbox-field-group { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 4px; |
|
} |
|
|
|
.msgbox-field-label { |
|
font-weight: 600; |
|
font-size: 0.9rem; |
|
} |
|
|
|
.msgbox-field-desc { |
|
margin: 0 0 2px 0; |
|
font-size: 0.8rem; |
|
color: var(--mb-desc-text); |
|
} |
|
|
|
.msgbox-input { |
|
width: 100%; |
|
box-sizing: border-box; |
|
padding: 8px 12px; |
|
border-radius: 4px; |
|
border: 1px solid var(--mb-input-border); |
|
background-color: var(--mb-input-bg); |
|
color: var(--mb-input-text); |
|
font-family: inherit; |
|
font-size: 0.95rem; |
|
} |
|
|
|
.msgbox-input:focus { |
|
outline: 2px solid var(--btn-bg); |
|
outline-offset: -1px; |
|
} |
|
|
|
.msgbox-textarea { |
|
min-height: 80px; |
|
resize: vertical; |
|
} |
|
|
|
.msgbox-buttons { |
|
display: flex; |
|
justify-content: flex-end; |
|
gap: 8px; |
|
margin-top: 10px; |
|
} |
|
|
|
.msgbox-btn { |
|
padding: 8px 18px; |
|
border: none; |
|
border-radius: 4px; |
|
background-color: var(--btn-bg); |
|
color: var(--btn-text); |
|
cursor: pointer; |
|
font-weight: 500; |
|
font-size: 0.95rem; |
|
} |
|
|
|
.msgbox-btn:hover { |
|
filter: brightness(0.9); |
|
} |
|
`; |
|
document.head.appendChild(style); |
|
} |
|
|
|
return function ({ title, message, icon, buttons, fields, className, isDark }) { |
|
return new Promise((resolve) => { |
|
ensureStyles(); |
|
|
|
const dialog = document.createElement("dialog"); |
|
dialog.className = `custom-msgbox ${className || ""}`; |
|
|
|
// Apply theme dataset if explicitly specified |
|
if (typeof isDark === "boolean") { |
|
dialog.setAttribute("data-theme", isDark ? "dark" : "light"); |
|
} |
|
|
|
const iconMap = { |
|
error: "❌", |
|
question: "❓", |
|
warn: "⚠️", |
|
info: "ℹ️", |
|
}; |
|
const iconEmoji = iconMap[icon] || icon || ""; |
|
|
|
dialog.innerHTML = ` |
|
<form method="dialog" class="msgbox-content"> |
|
${title ? `<h3 class="msgbox-title">${title}</h3>` : ""} |
|
<div class="msgbox-body"> |
|
${iconEmoji ? `<span class="msgbox-icon">${iconEmoji}</span>` : ""} |
|
<span class="msgbox-message">${message}</span> |
|
</div> |
|
${fields && fields.length ? `<div class="msgbox-fields"></div>` : ""} |
|
<div class="msgbox-buttons"></div> |
|
</form> |
|
`; |
|
|
|
const fieldsContainer = dialog.querySelector(".msgbox-fields"); |
|
const buttonContainer = dialog.querySelector(".msgbox-buttons"); |
|
|
|
// Render Form Fields |
|
const fieldInputs = {}; |
|
if (fields && fields.length) { |
|
fields.forEach((field, idx) => { |
|
const fieldWrapper = document.createElement("div"); |
|
fieldWrapper.className = "msgbox-field-group"; |
|
|
|
const id = `mb-field-${idx}-${Date.now()}`; |
|
let labelHtml = `<label for="${id}" class="msgbox-field-label">${field.label}</label>`; |
|
let descHtml = field.description ? `<p class="msgbox-field-desc">${field.description}</p>` : ""; |
|
let inputHtml = ""; |
|
|
|
if (field.type === "textarea") { |
|
inputHtml = `<textarea id="${id}" class="msgbox-input msgbox-textarea">${field.value || ""}</textarea>`; |
|
} else if (field.type === "select") { |
|
const optionsHtml = (field.options || []).map(opt => { |
|
const val = opt.value !== undefined ? opt.value : opt.label; |
|
const selected = val === field.value ? "selected" : ""; |
|
return `<option value="${val}" ${selected}>${opt.label}</option>`; |
|
}).join(""); |
|
inputHtml = `<select id="${id}" class="msgbox-input msgbox-select">${optionsHtml}</select>`; |
|
} else { |
|
inputHtml = `<input type="text" id="${id}" class="msgbox-input" value="${field.value || ""}" />`; |
|
} |
|
|
|
fieldWrapper.innerHTML = `${labelHtml}${descHtml}${inputHtml}`; |
|
fieldsContainer.appendChild(fieldWrapper); |
|
fieldInputs[field.label] = fieldWrapper.querySelector(".msgbox-input"); |
|
}); |
|
} |
|
|
|
const getFormData = () => { |
|
const data = {}; |
|
for (const [label, inputNode] of Object.entries(fieldInputs)) { |
|
data[label] = inputNode.value; |
|
} |
|
return data; |
|
}; |
|
|
|
// Button processing |
|
let enterBtnIndex = -1; |
|
let escBtnIndex = -1; |
|
|
|
buttons.forEach((btn, index) => { |
|
if (btn.key === "enter") enterBtnIndex = index; |
|
if (btn.key === "esc") escBtnIndex = index; |
|
}); |
|
|
|
buttons.forEach((btn) => { |
|
const buttonEl = document.createElement("button"); |
|
buttonEl.type = "button"; |
|
buttonEl.className = "msgbox-btn"; |
|
|
|
if (btn.color) buttonEl.style.setProperty("--btn-bg", btn.color); |
|
buttonEl.innerHTML = btn.icon ? `${btn.icon} ${btn.label}` : btn.label; |
|
|
|
buttonEl.addEventListener("click", () => { |
|
cleanup(); |
|
resolve({ button: btn, data: getFormData() }); |
|
}); |
|
|
|
buttonContainer.appendChild(buttonEl); |
|
}); |
|
|
|
const handleKeyDown = (e) => { |
|
if (e.key === "Enter" && document.activeElement.tagName === "TEXTAREA") { |
|
return; |
|
} |
|
|
|
if (e.key === "Enter" && enterBtnIndex !== -1) { |
|
e.preventDefault(); |
|
cleanup(); |
|
resolve({ button: buttons[enterBtnIndex], data: getFormData() }); |
|
} else if (e.key === "Escape") { |
|
e.preventDefault(); |
|
if (escBtnIndex !== -1) { |
|
cleanup(); |
|
resolve({ button: buttons[escBtnIndex], data: getFormData() }); |
|
} |
|
} |
|
}; |
|
|
|
function cleanup() { |
|
window.removeEventListener("keydown", handleKeyDown); |
|
dialog.close(); |
|
dialog.remove(); |
|
} |
|
|
|
window.addEventListener("keydown", handleKeyDown); |
|
document.body.appendChild(dialog); |
|
dialog.showModal(); |
|
}); |
|
}; |
|
})(); |