From 240e9ca9074fb21b65d7621351cf14dd01051e46 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Fri, 20 Mar 2026 00:14:49 +0100 Subject: [PATCH] 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> --- public/css/popup.css | 4 ++ public/scripts/popup.js | 51 +++++++++++++++--- public/scripts/slash-commands.js | 90 +++++++++++++++++++++++++++----- 3 files changed, 126 insertions(+), 19 deletions(-) diff --git a/public/css/popup.css b/public/css/popup.css index 5ab6f18d4..a2afe88c9 100644 --- a/public/css/popup.css +++ b/public/css/popup.css @@ -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; } diff --git a/public/scripts/popup.js b/public/scripts/popup.js index c257dffdd..3a87821da 100644 --- a/public/scripts/popup.js +++ b/public/scripts/popup.js @@ -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); diff --git a/public/scripts/slash-commands.js b/public/scripts/slash-commands.js index a47434de5..4ad9c71a4 100644 --- a/public/scripts/slash-commands.js +++ b/public/scripts/slash-commands.js @@ -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 default argument is the default value of the input field, and the text argument is the text to display.`} +
+ ${t`Example:`} + +
`, })); 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.`} +
+ ${t`Labels can be simple strings or objects with text, tooltip, and icon (Font Awesome class) properties.`} +
${t`Example:`}
`, @@ -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} */ 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} */ 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