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>

240e9ca9074fb21b65d7621351cf14dd01051e46

Wolfsblvt <wolfsblvt@gmail.com>

Signed
3 files changed, +125 -18Showing whitespace changes
public/css/popup.css+4 -0
@@ -161,6 +161,10 @@ body.no-blur .popup[open]::backdrop {
161161.popup-inputs {
162162 margin-top: 10px;
163163 font-size: smaller;
164+}
165+
166+.popup-inputs label span {
167+ /* Make the title of the custom inputs a bit darker, but keep their text at default opacity. */
164168 opacity: 0.7;
165169}
166170
public/scripts/popup.js+43 -6
@@ -40,6 +40,8 @@ export const POPUP_RESULT = {
4040 * @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.
4141 * @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.
4242 * @property {number?} [rows=1] - The number of rows for the input field
43+ * @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.
44+ * @property {string?} [tooltip=null] - Tooltip text shown on hover for the main interactive element or content area
4345 * @property {boolean?} [wide=false] - Whether to display the popup in wide mode (wide screen, 1/1 aspect ratio)
4446 * @property {boolean?} [wider=false] - Whether to display the popup in wider mode (just wider, no height scaling)
4547 * @property {boolean?} [large=false] - Whether to display the popup in large mode (90% of screen)
@@ -61,8 +63,10 @@ export const POPUP_RESULT = {
6163/**
6264 * @typedef {object} CustomPopupButton
6365 * @property {string} text - The text of the button
66+ * @property {string?} [tooltip] - Optional tooltip text displayed when hovering over the button
6467 * @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.
6568 * @property {string[]|string?} [classes] - Optional custom CSS classes applied to the button
69+ * @property {string?} [icon] - Optional Font Awesome icon class (e.g. 'fa-wand-magic-sparkles') to display before the text
6670 * @property {()=>void?} [action] - Optional action to perform when the button is clicked
6771 * @property {boolean?} [appendAtEnd] - Whether to append the button to the end of the popup - by default it will be prepended
6872 */
@@ -71,7 +75,7 @@ export const POPUP_RESULT = {
7175 * @typedef {object} CustomPopupInput
7276 * @property {string} id - The id for the html element
7377 * @property {string} label - The label text for the input
7478 * @property {string?} [tooltip=null] - Optional tooltip iconto be displayed. Default placeholder in input controls, tooltip icon behind the labelcheckbox for those.
7579 * @property {boolean|string|undefined} [defaultState=false] - The default state when opening the popup (false if not set)
7680 * @property {('checkbox'|'text'|'textarea')?} [type='checkbox'] - The type of the input (default is checkbox)
7781 * @property {number?} [rows=1] - The number of rows for the input field, if the input is 'textarea'
@@ -178,7 +182,7 @@ export class Popup {
178182 * @param {string} [inputValue=''] - The initial value of the input field
179183 * @param {PopupOptions} [options={}] - Additional options for the popup
180184 */
181185 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 } = {}) {
182186 Popup.util.popups.push(this);
183187
184188 // Make this popup uniquely identifiable
@@ -233,6 +237,15 @@ export class Popup {
233237 this.cancelButton.textContent = typeof cancelButton === 'string' ? cancelButton : template.getAttribute('popup-button-cancel');
234238 this.cancelButton.dataset.i18n = this.cancelButton.textContent;
235239
240+ /** @param {HTMLElement} control @param {string} text Sets the title attribute and translation, if text is provided */
241+ function setTitleFromTooltip(control, text) {
242+ if (!text) return;
243+ control.title = text;
244+ if (!control.dataset.i18n) {
245+ control.dataset.i18n = '[title]' + text; // Don't override an existing translation of main text with title translation
246+ }
247+ }
248+
236249 this.defaultResult = defaultResult;
237250 this.customButtons = customButtons;
238251 this.customButtons?.forEach((x, index) => {
@@ -243,9 +256,22 @@ export class Popup {
243256 buttonElement.classList.add('menu_button', 'popup-button-custom', 'result-control');
244257 buttonElement.classList.add(...(button.classes ?? []));
245258 buttonElement.dataset.result = String(button.result); // This is expected to also write 'null' or 'staging', to indicate cancel and no action respectively
259+ buttonElement.tabIndex = 0;
260+
261+ if (button.icon) {
262+ const icon = document.createElement('i');
263+ icon.className = `fa-solid ${button.icon}`;
264+ buttonElement.appendChild(icon);
265+ const textSpan = document.createElement('span');
266+ textSpan.textContent = button.text;
267+ textSpan.dataset.i18n = button.text;
268+ buttonElement.classList.add('menu_button_icon');
269+ buttonElement.appendChild(textSpan);
270+ } else {
246271 buttonElement.textContent = button.text;
247272 buttonElement.dataset.i18n = buttonElement.textContent;
248- buttonElement.tabIndex = 0;
273+ }
274+ setTitleFromTooltip(buttonElement, button.tooltip);
249275
250276 if (button.appendAtEnd) {
251277 this.buttonControls.appendChild(buttonElement);
@@ -282,8 +308,7 @@ export class Popup {
282308 if (input.tooltip) {
283309 const tooltip = document.createElement('div');
284310 tooltip.classList.add('fa-solid', 'fa-circle-info', 'opacity50p');
285311 setTitleFromTooltip(tooltip.title =, input.tooltip);
286- tooltip.dataset.i18n = '[title]' + input.tooltip;
287312 label.appendChild(tooltip);
288313 }
289314
@@ -299,6 +324,7 @@ export class Popup {
299324 inputElement.id = input.id;
300325 inputElement.value = String(input.defaultState ?? '');
301326 inputElement.placeholder = input.tooltip ?? '';
327+ setTitleFromTooltip(inputElement, input.tooltip);
302328
303329 const labelText = document.createElement('span');
304330 labelText.innerText = input.label;
@@ -317,8 +343,9 @@ export class Popup {
317343 inputElement.classList.add('text_pole', 'result-control');
318344 inputElement.id = input.id;
319345 inputElement.value = String(input.defaultState ?? '');
320- inputElement.placeholder = input.tooltip ?? '';
321346 inputElement.rows = input.rows ?? 1;
347+ inputElement.placeholder = input.tooltip ?? '';
348+ setTitleFromTooltip(inputElement, input.tooltip);
322349
323350 const labelText = document.createElement('span');
324351 labelText.innerText = input.label;
@@ -405,6 +432,16 @@ export class Popup {
405432 this.mainInput.value = inputValue;
406433 this.mainInput.rows = rows ?? 1;
407434
435+ // Apply placeholder and tooltip based on popup type
436+ if (type === POPUP_TYPE.INPUT) {
437+ // For INPUT type, apply to the main input element
438+ this.mainInput.placeholder = placeholder ?? '';
439+ setTitleFromTooltip(this.mainInput, tooltip);
440+ } else {
441+ // For other types, apply tooltip to the content area
442+ setTitleFromTooltip(this.content, tooltip);
443+ }
444+
408445 this.content.innerHTML = '';
409446 if (content instanceof jQuery) {
410447 $(this.content).append(content);
public/scripts/slash-commands.js+78 -12
@@ -2550,6 +2550,16 @@ export function initDefaultSlashCommands() {
25502550 typeList: [ARGUMENT_TYPE.NUMBER],
25512551 }),
25522552 SlashCommandNamedArgument.fromProps({
2553+ name: 'placeholder',
2554+ description: t`placeholder text displayed in the input field when empty`,
2555+ typeList: [ARGUMENT_TYPE.STRING],
2556+ }),
2557+ SlashCommandNamedArgument.fromProps({
2558+ name: 'tooltip',
2559+ description: t`tooltip text shown when hovering over the input field`,
2560+ typeList: [ARGUMENT_TYPE.STRING],
2561+ }),
2562+ SlashCommandNamedArgument.fromProps({
25532563 name: 'onSuccess',
25542564 description: t`closure to execute when the ok button is clicked or the input is closed as successful (via Enter, etc)`,
25552565 typeList: [ARGUMENT_TYPE.CLOSURE],
@@ -2571,6 +2581,14 @@ export function initDefaultSlashCommands() {
25712581 ${t`Shows a popup with the provided text and an input field.`}
25722582 ${t`The <code>default</code> argument is the default value of the input field, and the text argument is the text to display.`}
25732583 </div>
2584+ <div>
2585+ <strong>${t`Example:`}</strong>
2586+ <ul>
2587+ <li>
2588+ <pre><code>/input default="John" placeholder="Enter your name" tooltip="Your display name" What is your name?</code></pre>
2589+ </li>
2590+ </ul>
2591+ </div>
25742592 `,
25752593 }));
25762594 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
@@ -2682,7 +2700,7 @@ export function initDefaultSlashCommands() {
26822700 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
26832701 name: 'popup',
26842702 callback: popupCallback,
26852703 returns: t`popupPopup text`,
26862704 namedArgumentList: [
26872705 SlashCommandNamedArgument.fromProps({
26882706 name: 'scroll',
@@ -2737,6 +2755,11 @@ export function initDefaultSlashCommands() {
27372755 enumList: commonEnumProviders.boolean('trueFalse')(),
27382756 defaultValue: 'false',
27392757 }),
2758+ SlashCommandNamedArgument.fromProps({
2759+ name: 'tooltip',
2760+ description: t`tooltip text shown when hovering over the popup content area`,
2761+ typeList: [ARGUMENT_TYPE.STRING],
2762+ }),
27402763 ],
27412764 unnamedArgumentList: [
27422765 SlashCommandArgument.fromProps({
@@ -2770,7 +2793,7 @@ export function initDefaultSlashCommands() {
27702793 namedArgumentList: [
27712794 SlashCommandNamedArgument.fromProps({
27722795 name: 'labels',
2773- description: t`button labels`,
2796+ description: t`button labels - can be an array of strings or objects with text, tooltip, and icon properties`,
27742797 typeList: [ARGUMENT_TYPE.LIST],
27752798 isRequired: true,
27762799 }),
@@ -2795,11 +2818,17 @@ export function initDefaultSlashCommands() {
27952818 ${t`Returns the clicked button label into the pipe or empty string if canceled.`}
27962819 </div>
27972820 <div>
2821+ ${t`Labels can be simple strings or objects with <code>text</code>, <code>tooltip</code>, and <code>icon</code> (Font Awesome class) properties.`}
2822+ </div>
2823+ <div>
27982824 <strong>${t`Example:`}</strong>
27992825 <ul>
28002826 <li>
28012827 <pre><code>/buttons labels=["Yes","No"] Do you want to continue?</code></pre>
28022828 </li>
2829+ <li>
2830+ <pre><code>/buttons labels=[{"text":"Save","icon":"fa-floppy-disk","tooltip":"Save changes"},{"text":"Cancel"}] Choose an action</code></pre>
2831+ </li>
28032832 </ul>
28042833 </div>
28052834 `,
@@ -3953,10 +3982,15 @@ async function trimTokensCallback(arg, value) {
39533982}
39543983
39553984/**
3956- * Displays a popup with buttons based on provided labels and handles button interactions.
3985+ * @typedef {object} ButtonLabel
3957- *
3986+ * @property {string} text - The button text
3987+ * @property {string} [tooltip] - Optional tooltip text
3988+ * @property {string} [icon] - Optional Font Awesome icon class (e.g., 'fa-floppy-disk')
3989+ */
3990+
3991+/**
39583992 * @param {object} args - Named arguments for the command
39593993 * @param {string} args.labels - JSON string of an array of button labels (strings or ButtonLabel objects)
39603994 * @param {string} [args.multiple=false] - Flag indicating if multiple buttons can be toggled
39613995 * @param {string} text - The text content to be displayed within the popup
39623996 *
@@ -3966,19 +4000,30 @@ async function trimTokensCallback(arg, value) {
39664000 */
39674001async function buttonsCallback(args, text) {
39684002 try {
39694003 /** @type {(string|ButtonLabel)[]} */
39704004 const buttonsrawButtons = JSON.parse(resolveVariable(args?.labels));
39714005
39724006 if (!Array.isArray(buttonsrawButtons) || !buttonsrawButtons.length) {
39734007 console.warn('WARN: Invalid labels provided for /buttons command');
39744008 return '';
39754009 }
39764010
4011+ // Normalize buttons to ButtonLabel format for consistent handling
4012+ /** @type {ButtonLabel[]} */
4013+ const buttons = rawButtons.map(btn => typeof btn === 'string' ? { text: btn } : btn);
4014+
4015+ // Validate raw buttons: each entry must be a string or a non-null object with a string `text` field that has content
4016+ if (!buttons.every(btn => typeof btn === 'object' && btn !== null && typeof btn.text === 'string' && btn.text)) {
4017+ console.warn('WARN: Invalid button label entry provided for /buttons command: each entry must be a string or an object with a "text" property');
4018+ return '';
4019+ }
4020+
39774021 /** @type {Set<number>} */
39784022 const multipleToggledState = new Set();
39794023 const multiple = isTrueBoolean(args?.multiple);
39804024
39814025 // Map custom buttons to results. Start at 2 because 1 and 0 are reserved for ok and cancel
4026+ /** @type {Map<number, ButtonLabel>} */
39824027 const resultToButtonMap = new Map(buttons.map((button, index) => [index + 2, button]));
39834028
39844029 return new Promise(async (resolve) => {
@@ -4013,7 +4058,25 @@ async function buttonsCallback(args, text) {
40134058 buttonElement.dataset.result = String(result);
40144059 }
40154060
4016- buttonElement.innerText = button;
4061+ // Add icon if provided
4062+ if (button.icon) {
4063+ const icon = document.createElement('i');
4064+ icon.className = `fa-solid ${button.icon}`;
4065+ icon.style.marginRight = '0.5em';
4066+ buttonElement.appendChild(icon);
4067+ const textSpan = document.createElement('span');
4068+ textSpan.textContent = button.text;
4069+ buttonElement.appendChild(textSpan);
4070+ } else {
4071+ buttonElement.innerText = button.text;
4072+ }
4073+
4074+ // Add tooltip if provided
4075+ if (button.tooltip) {
4076+ buttonElement.title = button.tooltip;
4077+ buttonElement.dataset.i18n = '[title]' + button.tooltip;
4078+ }
4079+
40174080 buttonContainer.appendChild(buttonElement);
40184081 }
40194082
@@ -4036,10 +4099,10 @@ async function buttonsCallback(args, text) {
40364099 /** @returns {string} @param {string|number|boolean} result */
40374100 function getResult(result) {
40384101 if (multiple) {
40394102 const array = result === POPUP_RESULT.AFFIRMATIVE ? Array.from(multipleToggledState).map(r => resultToButtonMap.get(r)?.text ?? '') : [];
40404103 return JSON.stringify(array);
40414104 }
40424105 return typeof result === 'number' ? resultToButtonMap.get(result)?.text ?? '' : '';
40434106 }
40444107 });
40454108 } catch {
@@ -4061,6 +4124,7 @@ async function popupCallback(args, value) {
40614124 transparent: isTrueBoolean(args?.transparent),
40624125 okButton: args?.okButton !== undefined && typeof args?.okButton === 'string' ? args.okButton : t`OK`,
40634126 cancelButton: args?.cancelButton !== undefined && typeof args?.cancelButton === 'string' ? args.cancelButton : null,
4127+ tooltip: args?.tooltip !== undefined && typeof args?.tooltip === 'string' ? args.tooltip : null,
40644128 };
40654129 const result = await Popup.show.text(safeHeader, safeBody, popupOptions);
40664130 return String(requestedResult ? result ?? '' : value);
@@ -4216,6 +4280,8 @@ async function inputCallback(args, prompt) {
42164280 wide: isTrueBoolean(args?.wide),
42174281 okButton: args?.okButton !== undefined && typeof args?.okButton === 'string' ? args.okButton : t`Ok`,
42184282 rows: args?.rows !== undefined && typeof args?.rows === 'string' ? isNaN(Number(args.rows)) ? 4 : Number(args.rows) : 4,
4283+ placeholder: args?.placeholder !== undefined && typeof args?.placeholder === 'string' ? args.placeholder : null,
4284+ tooltip: args?.tooltip !== undefined && typeof args?.tooltip === 'string' ? args.tooltip : null,
42194285 };
42204286 // Do not remove this delay, otherwise the prompt will not show up
42214287 await delay(1);
@@ -4468,7 +4534,7 @@ async function echoCallback(args, value) {
44684534 if (args.timeout && !isNaN(parseInt(args.timeout))) options.timeOut = parseInt(args.timeout);
44694535 if (args.extendedTimeout && !isNaN(parseInt(args.extendedTimeout))) options.extendedTimeOut = parseInt(args.extendedTimeout);
44704536 if (isTrueBoolean(args.preventDuplicates)) options.preventDuplicates = true;
44714537 if (args.cssClass) options.toastClass = [options.toastClass, args.cssClass].filter(Boolean).join(' ');
44724538 options.escapeHtml = args.escapeHtml !== undefined ? isTrueBoolean(args.escapeHtml) : true;
44734539
44744540 // Prepare possible await handling