Extend Popup System with Placeholder, Tooltip, and Icon Support (+ those in slash commands) (#5322)

* feat: add placeholder and tooltip support to popup system with icon buttons

Add `placeholder` and `tooltip` options to main popup configuration. For INPUT type popups, placeholder applies to input field; for other types, tooltip applies to content area. Enhance custom buttons with optional `icon` parameter for Font Awesome icons and `tooltip` for hover text. Add tooltip support to custom inputs (placeholder for text/textarea, tooltip icon for checkboxes).

* fix: preserve default toastClass when applying custom cssClass in /echo command

Modify cssClass argument handling in echoCallback to append custom class to existing toastClass instead of replacing it. Use filter(Boolean).join(' ') to combine default and custom classes while handling undefined values.

* feat: add placeholder, tooltip, and icon support to popup system slash commands

Add `placeholder` and `tooltip` named arguments to /input command for input field customization. Add `tooltip` argument to /popup command for content area hover text. Enhance /buttons command to support button objects with `text`, `tooltip`, and `icon` (Font Awesome) properties alongside simple string labels. Update help text and examples for all three commands. Normalize button labels to ButtonLabel format internally

* Fix jsdoc wording

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: add validation for button labels in /buttons command

Add validation check to ensure each button entry is either a string or an object with a non-empty string `text` property. Return empty string and log warning if validation fails. Fix capitalization of 'Popup' in /popup command return value description.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Wolfsblvt
2026-03-20 00:14:49 +01:00
committed by GitHub
parent a6486d7f08
commit 240e9ca907
3 changed files with 126 additions and 19 deletions
+4
View File
@@ -161,6 +161,10 @@ body.no-blur .popup[open]::backdrop {
.popup-inputs {
margin-top: 10px;
font-size: smaller;
}
.popup-inputs label span {
/* Make the title of the custom inputs a bit darker, but keep their text at default opacity. */
opacity: 0.7;
}
+44 -7
View File
@@ -40,6 +40,8 @@ export const POPUP_RESULT = {
* @property {string|boolean?} [okButton=null] - Custom text for the OK button. A set text will always show the button. `true` or `false` to explicitly show or hide the button. `null` will leave the behavior and display of the button unchanged, based on the popup type.
* @property {string|boolean?} [cancelButton=null] - Custom text for the Cancel button. A set text will always show the button. `true` or `false` to explicitly show or hide the button. `null` will leave the behavior and display of the button unchanged, based on the popup type.
* @property {number?} [rows=1] - The number of rows for the input field
* @property {string?} [placeholder=null] - Placeholder text for the main interactive element (input field for INPUT type). For other popup types, use tooltip for additional hints or to describe content elements.
* @property {string?} [tooltip=null] - Tooltip text shown on hover for the main interactive element or content area
* @property {boolean?} [wide=false] - Whether to display the popup in wide mode (wide screen, 1/1 aspect ratio)
* @property {boolean?} [wider=false] - Whether to display the popup in wider mode (just wider, no height scaling)
* @property {boolean?} [large=false] - Whether to display the popup in large mode (90% of screen)
@@ -61,8 +63,10 @@ export const POPUP_RESULT = {
/**
* @typedef {object} CustomPopupButton
* @property {string} text - The text of the button
* @property {string?} [tooltip] - Optional tooltip text displayed when hovering over the button
* @property {POPUP_RESULT|number?} [result] - The result of the button - can also be a custom result value to make be able to find out that this button was clicked. If no result is specified, this button will **not** close the popup.
* @property {string[]|string?} [classes] - Optional custom CSS classes applied to the button
* @property {string?} [icon] - Optional Font Awesome icon class (e.g. 'fa-wand-magic-sparkles') to display before the text
* @property {()=>void?} [action] - Optional action to perform when the button is clicked
* @property {boolean?} [appendAtEnd] - Whether to append the button to the end of the popup - by default it will be prepended
*/
@@ -71,7 +75,7 @@ export const POPUP_RESULT = {
* @typedef {object} CustomPopupInput
* @property {string} id - The id for the html element
* @property {string} label - The label text for the input
* @property {string?} [tooltip=null] - Optional tooltip icon displayed behind the label
* @property {string?} [tooltip=null] - Optional tooltip to be displayed. Default placeholder in input controls, tooltip icon behind the checkbox for those.
* @property {boolean|string|undefined} [defaultState=false] - The default state when opening the popup (false if not set)
* @property {('checkbox'|'text'|'textarea')?} [type='checkbox'] - The type of the input (default is checkbox)
* @property {number?} [rows=1] - The number of rows for the input field, if the input is 'textarea'
@@ -178,7 +182,7 @@ export class Popup {
* @param {string} [inputValue=''] - The initial value of the input field
* @param {PopupOptions} [options={}] - Additional options for the popup
*/
constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, leftAlign = false, animation = 'fast', defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, customInputs = null, onClosing = null, onClose = null, onOpen = null, cropAspect = null, cropImage = null } = {}) {
constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, placeholder = null, tooltip = null, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, leftAlign = false, animation = 'fast', defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, customInputs = null, onClosing = null, onClose = null, onOpen = null, cropAspect = null, cropImage = null } = {}) {
Popup.util.popups.push(this);
// Make this popup uniquely identifiable
@@ -233,6 +237,15 @@ export class Popup {
this.cancelButton.textContent = typeof cancelButton === 'string' ? cancelButton : template.getAttribute('popup-button-cancel');
this.cancelButton.dataset.i18n = this.cancelButton.textContent;
/** @param {HTMLElement} control @param {string} text Sets the title attribute and translation, if text is provided */
function setTitleFromTooltip(control, text) {
if (!text) return;
control.title = text;
if (!control.dataset.i18n) {
control.dataset.i18n = '[title]' + text; // Don't override an existing translation of main text with title translation
}
}
this.defaultResult = defaultResult;
this.customButtons = customButtons;
this.customButtons?.forEach((x, index) => {
@@ -243,10 +256,23 @@ export class Popup {
buttonElement.classList.add('menu_button', 'popup-button-custom', 'result-control');
buttonElement.classList.add(...(button.classes ?? []));
buttonElement.dataset.result = String(button.result); // This is expected to also write 'null' or 'staging', to indicate cancel and no action respectively
buttonElement.textContent = button.text;
buttonElement.dataset.i18n = buttonElement.textContent;
buttonElement.tabIndex = 0;
if (button.icon) {
const icon = document.createElement('i');
icon.className = `fa-solid ${button.icon}`;
buttonElement.appendChild(icon);
const textSpan = document.createElement('span');
textSpan.textContent = button.text;
textSpan.dataset.i18n = button.text;
buttonElement.classList.add('menu_button_icon');
buttonElement.appendChild(textSpan);
} else {
buttonElement.textContent = button.text;
buttonElement.dataset.i18n = buttonElement.textContent;
}
setTitleFromTooltip(buttonElement, button.tooltip);
if (button.appendAtEnd) {
this.buttonControls.appendChild(buttonElement);
} else {
@@ -282,8 +308,7 @@ export class Popup {
if (input.tooltip) {
const tooltip = document.createElement('div');
tooltip.classList.add('fa-solid', 'fa-circle-info', 'opacity50p');
tooltip.title = input.tooltip;
tooltip.dataset.i18n = '[title]' + input.tooltip;
setTitleFromTooltip(tooltip, input.tooltip);
label.appendChild(tooltip);
}
@@ -299,6 +324,7 @@ export class Popup {
inputElement.id = input.id;
inputElement.value = String(input.defaultState ?? '');
inputElement.placeholder = input.tooltip ?? '';
setTitleFromTooltip(inputElement, input.tooltip);
const labelText = document.createElement('span');
labelText.innerText = input.label;
@@ -317,8 +343,9 @@ export class Popup {
inputElement.classList.add('text_pole', 'result-control');
inputElement.id = input.id;
inputElement.value = String(input.defaultState ?? '');
inputElement.placeholder = input.tooltip ?? '';
inputElement.rows = input.rows ?? 1;
inputElement.placeholder = input.tooltip ?? '';
setTitleFromTooltip(inputElement, input.tooltip);
const labelText = document.createElement('span');
labelText.innerText = input.label;
@@ -405,6 +432,16 @@ export class Popup {
this.mainInput.value = inputValue;
this.mainInput.rows = rows ?? 1;
// Apply placeholder and tooltip based on popup type
if (type === POPUP_TYPE.INPUT) {
// For INPUT type, apply to the main input element
this.mainInput.placeholder = placeholder ?? '';
setTitleFromTooltip(this.mainInput, tooltip);
} else {
// For other types, apply tooltip to the content area
setTitleFromTooltip(this.content, tooltip);
}
this.content.innerHTML = '';
if (content instanceof jQuery) {
$(this.content).append(content);
+78 -12
View File
@@ -2549,6 +2549,16 @@ export function initDefaultSlashCommands() {
description: t`number of rows for the input field (lines being displayed)`,
typeList: [ARGUMENT_TYPE.NUMBER],
}),
SlashCommandNamedArgument.fromProps({
name: 'placeholder',
description: t`placeholder text displayed in the input field when empty`,
typeList: [ARGUMENT_TYPE.STRING],
}),
SlashCommandNamedArgument.fromProps({
name: 'tooltip',
description: t`tooltip text shown when hovering over the input field`,
typeList: [ARGUMENT_TYPE.STRING],
}),
SlashCommandNamedArgument.fromProps({
name: 'onSuccess',
description: t`closure to execute when the ok button is clicked or the input is closed as successful (via Enter, etc)`,
@@ -2571,6 +2581,14 @@ export function initDefaultSlashCommands() {
${t`Shows a popup with the provided text and an input field.`}
${t`The <code>default</code> argument is the default value of the input field, and the text argument is the text to display.`}
</div>
<div>
<strong>${t`Example:`}</strong>
<ul>
<li>
<pre><code>/input default="John" placeholder="Enter your name" tooltip="Your display name" What is your name?</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
@@ -2682,7 +2700,7 @@ export function initDefaultSlashCommands() {
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'popup',
callback: popupCallback,
returns: t`popup text`,
returns: t`Popup text`,
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'scroll',
@@ -2737,6 +2755,11 @@ export function initDefaultSlashCommands() {
enumList: commonEnumProviders.boolean('trueFalse')(),
defaultValue: 'false',
}),
SlashCommandNamedArgument.fromProps({
name: 'tooltip',
description: t`tooltip text shown when hovering over the popup content area`,
typeList: [ARGUMENT_TYPE.STRING],
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
@@ -2770,7 +2793,7 @@ export function initDefaultSlashCommands() {
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'labels',
description: t`button labels`,
description: t`button labels - can be an array of strings or objects with text, tooltip, and icon properties`,
typeList: [ARGUMENT_TYPE.LIST],
isRequired: true,
}),
@@ -2794,12 +2817,18 @@ export function initDefaultSlashCommands() {
${t`Shows a blocking popup with the specified text and buttons.`}
${t`Returns the clicked button label into the pipe or empty string if canceled.`}
</div>
<div>
${t`Labels can be simple strings or objects with <code>text</code>, <code>tooltip</code>, and <code>icon</code> (Font Awesome class) properties.`}
</div>
<div>
<strong>${t`Example:`}</strong>
<ul>
<li>
<pre><code>/buttons labels=["Yes","No"] Do you want to continue?</code></pre>
</li>
<li>
<pre><code>/buttons labels=[{"text":"Save","icon":"fa-floppy-disk","tooltip":"Save changes"},{"text":"Cancel"}] Choose an action</code></pre>
</li>
</ul>
</div>
`,
@@ -3953,10 +3982,15 @@ async function trimTokensCallback(arg, value) {
}
/**
* Displays a popup with buttons based on provided labels and handles button interactions.
*
* @typedef {object} ButtonLabel
* @property {string} text - The button text
* @property {string} [tooltip] - Optional tooltip text
* @property {string} [icon] - Optional Font Awesome icon class (e.g., 'fa-floppy-disk')
*/
/**
* @param {object} args - Named arguments for the command
* @param {string} args.labels - JSON string of an array of button labels
* @param {string} args.labels - JSON string of an array of button labels (strings or ButtonLabel objects)
* @param {string} [args.multiple=false] - Flag indicating if multiple buttons can be toggled
* @param {string} text - The text content to be displayed within the popup
*
@@ -3966,19 +4000,30 @@ async function trimTokensCallback(arg, value) {
*/
async function buttonsCallback(args, text) {
try {
/** @type {string[]} */
const buttons = JSON.parse(resolveVariable(args?.labels));
/** @type {(string|ButtonLabel)[]} */
const rawButtons = JSON.parse(resolveVariable(args?.labels));
if (!Array.isArray(buttons) || !buttons.length) {
if (!Array.isArray(rawButtons) || !rawButtons.length) {
console.warn('WARN: Invalid labels provided for /buttons command');
return '';
}
// Normalize buttons to ButtonLabel format for consistent handling
/** @type {ButtonLabel[]} */
const buttons = rawButtons.map(btn => typeof btn === 'string' ? { text: btn } : btn);
// Validate raw buttons: each entry must be a string or a non-null object with a string `text` field that has content
if (!buttons.every(btn => typeof btn === 'object' && btn !== null && typeof btn.text === 'string' && btn.text)) {
console.warn('WARN: Invalid button label entry provided for /buttons command: each entry must be a string or an object with a "text" property');
return '';
}
/** @type {Set<number>} */
const multipleToggledState = new Set();
const multiple = isTrueBoolean(args?.multiple);
// Map custom buttons to results. Start at 2 because 1 and 0 are reserved for ok and cancel
/** @type {Map<number, ButtonLabel>} */
const resultToButtonMap = new Map(buttons.map((button, index) => [index + 2, button]));
return new Promise(async (resolve) => {
@@ -4013,7 +4058,25 @@ async function buttonsCallback(args, text) {
buttonElement.dataset.result = String(result);
}
buttonElement.innerText = button;
// Add icon if provided
if (button.icon) {
const icon = document.createElement('i');
icon.className = `fa-solid ${button.icon}`;
icon.style.marginRight = '0.5em';
buttonElement.appendChild(icon);
const textSpan = document.createElement('span');
textSpan.textContent = button.text;
buttonElement.appendChild(textSpan);
} else {
buttonElement.innerText = button.text;
}
// Add tooltip if provided
if (button.tooltip) {
buttonElement.title = button.tooltip;
buttonElement.dataset.i18n = '[title]' + button.tooltip;
}
buttonContainer.appendChild(buttonElement);
}
@@ -4036,10 +4099,10 @@ async function buttonsCallback(args, text) {
/** @returns {string} @param {string|number|boolean} result */
function getResult(result) {
if (multiple) {
const array = result === POPUP_RESULT.AFFIRMATIVE ? Array.from(multipleToggledState).map(r => resultToButtonMap.get(r) ?? '') : [];
const array = result === POPUP_RESULT.AFFIRMATIVE ? Array.from(multipleToggledState).map(r => resultToButtonMap.get(r)?.text ?? '') : [];
return JSON.stringify(array);
}
return typeof result === 'number' ? resultToButtonMap.get(result) ?? '' : '';
return typeof result === 'number' ? resultToButtonMap.get(result)?.text ?? '' : '';
}
});
} catch {
@@ -4061,6 +4124,7 @@ async function popupCallback(args, value) {
transparent: isTrueBoolean(args?.transparent),
okButton: args?.okButton !== undefined && typeof args?.okButton === 'string' ? args.okButton : t`OK`,
cancelButton: args?.cancelButton !== undefined && typeof args?.cancelButton === 'string' ? args.cancelButton : null,
tooltip: args?.tooltip !== undefined && typeof args?.tooltip === 'string' ? args.tooltip : null,
};
const result = await Popup.show.text(safeHeader, safeBody, popupOptions);
return String(requestedResult ? result ?? '' : value);
@@ -4216,6 +4280,8 @@ async function inputCallback(args, prompt) {
wide: isTrueBoolean(args?.wide),
okButton: args?.okButton !== undefined && typeof args?.okButton === 'string' ? args.okButton : t`Ok`,
rows: args?.rows !== undefined && typeof args?.rows === 'string' ? isNaN(Number(args.rows)) ? 4 : Number(args.rows) : 4,
placeholder: args?.placeholder !== undefined && typeof args?.placeholder === 'string' ? args.placeholder : null,
tooltip: args?.tooltip !== undefined && typeof args?.tooltip === 'string' ? args.tooltip : null,
};
// Do not remove this delay, otherwise the prompt will not show up
await delay(1);
@@ -4468,7 +4534,7 @@ async function echoCallback(args, value) {
if (args.timeout && !isNaN(parseInt(args.timeout))) options.timeOut = parseInt(args.timeout);
if (args.extendedTimeout && !isNaN(parseInt(args.extendedTimeout))) options.extendedTimeOut = parseInt(args.extendedTimeout);
if (isTrueBoolean(args.preventDuplicates)) options.preventDuplicates = true;
if (args.cssClass) options.toastClass = args.cssClass;
if (args.cssClass) options.toastClass = [options.toastClass, args.cssClass].filter(Boolean).join(' ');
options.escapeHtml = args.escapeHtml !== undefined ? isTrueBoolean(args.escapeHtml) : true;
// Prepare possible await handling