From d9582062d2e661aa820c087d8395948155b8f70d Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Sat, 25 May 2024 00:44:09 +0200 Subject: [PATCH] Expand popup functionality - Add "custom buttons" functionality, each with their own popup result - Handle 'Enter' by defining a default action - Using default action to style the default button to make the default action visible - Allow override of ok/cancel button on any popup type to display those - Allow multiple popups to overlay each other - Small styling changes for bottom spacing on non-input popups --- public/index.html | 8 +- public/scripts/popup.js | 172 +++++++++++++++++++++++++++++++--------- public/style.css | 21 ++++- 3 files changed, 155 insertions(+), 46 deletions(-) diff --git a/public/index.html b/public/index.html index 49dd405f0..59dfafb8c 100644 --- a/public/index.html +++ b/public/index.html @@ -4856,8 +4856,8 @@
- - + +
@@ -4871,8 +4871,8 @@
- - + +
diff --git a/public/scripts/popup.js b/public/scripts/popup.js index 17d380367..7d85ef048 100644 --- a/public/scripts/popup.js +++ b/public/scripts/popup.js @@ -1,25 +1,46 @@ import { animation_duration, animation_easing } from '../script.js'; import { delay } from './utils.js'; - - -/**@readonly*/ -/**@enum {Number}*/ +/** @readonly */ +/** @enum {Number} */ export const POPUP_TYPE = { 'TEXT': 1, 'CONFIRM': 2, 'INPUT': 3, }; -/**@readonly*/ -/**@enum {Boolean}*/ +/** @readonly */ +/** @enum {number} */ export const POPUP_RESULT = { - 'AFFIRMATIVE': true, - 'NEGATIVE': false, + 'AFFIRMATIVE': 1, + 'NEGATIVE': 0, 'CANCELLED': undefined, }; +const POPUP_START_Z_INDEX = 9998; +let currentPopupZIndex = POPUP_START_Z_INDEX; +/** + * @typedef {object} PopupOptions + * @property {string|boolean?} [okButton] - Custom text for the OK button, or `true` to use the default (If set, the button will always be displayed, no matter the type of popup) + * @property {string|boolean?} [cancelButton] - Custom text for the Cancel button, or `true` to use the default (If set, the button will always be displayed, no matter the type of popup) + * @property {number?} [rows] - The number of rows for the input field + * @property {boolean?} [wide] - Whether to display the popup in wide mode + * @property {boolean?} [large] - Whether to display the popup in large mode + * @property {boolean?} [allowHorizontalScrolling] - Whether to allow horizontal scrolling in the popup + * @property {boolean?} [allowVerticalScrolling] - Whether to allow vertical scrolling in the popup + * @property {POPUP_RESULT|number?} [defaultResult] - The default result of this popup when Enter is pressed. Can be changed from `POPUP_RESULT.AFFIRMATIVE`. + * @property {CustomPopupButton[]|string[]?} [customButtons] - Custom buttons to add to the popup. If only strings are provided, the buttons will be added with default options, and their result will be in order from `2` onward. + */ + +/** + * @typedef {object} CustomPopupButton + * @property {string} text - The text of 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 {()=>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 + */ export class Popup { /**@type {POPUP_TYPE}*/ type; @@ -39,16 +60,15 @@ export class Popup { /**@type {Function}*/ keyListenerBound; - - /** - * @typedef {{okButton?: string, cancelButton?: string, rows?: number, wide?: boolean, large?: boolean, allowHorizontalScrolling?: boolean, allowVerticalScrolling?: boolean }} PopupOptions - Options for the popup. - * @param {JQuery|string|Element} text - Text to display in the popup. - * @param {POPUP_TYPE} type - One of Popup.TYPE - * @param {string} inputValue - Value to set the input to. - * @param {PopupOptions} options - Options for the popup. + * Constructs a new Popup object with the given text, type, inputValue, and options + * + * @param {JQuery|string|Element} text - Text to display in the popup + * @param {POPUP_TYPE} type - The type of the popup + * @param {string} [inputValue=''] - The initial value of the input field + * @param {PopupOptions} [options={}] - Additional options for the popup */ - constructor(text, type, inputValue = '', { okButton, cancelButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling } = {}) { + constructor(text, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, large = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null } = {}) { this.type = type; /**@type {HTMLTemplateElement}*/ @@ -60,6 +80,7 @@ export class Popup { this.dlg = dlg; this.text = this.dom.querySelector('.dialogue_popup_text'); this.input = this.dom.querySelector('.dialogue_popup_input'); + this.controls = this.dom.querySelector('.dialogue_popup_controls'); this.ok = this.dom.querySelector('.dialogue_popup_ok'); this.cancel = this.dom.querySelector('.dialogue_popup_cancel'); @@ -68,24 +89,53 @@ export class Popup { if (allowHorizontalScrolling) dlg.classList.add('horizontal_scrolling_dialogue_popup'); if (allowVerticalScrolling) dlg.classList.add('vertical_scrolling_dialogue_popup'); - this.ok.textContent = okButton ?? 'OK'; - this.cancel.textContent = cancelButton ?? template.getAttribute('popup_text_cancel'); + // If custom button captions are provided, we set them beforehand + this.ok.textContent = typeof okButton === 'string' ? okButton : 'OK'; + this.cancel.textContent = typeof cancelButton === 'string' ? cancelButton : template.getAttribute('popup_text_cancel'); + + this.defaultResult = defaultResult; + this.customButtons = customButtons; + this.customButtonElements = this.customButtons?.map((x, index) => { + /** @type {CustomPopupButton} */ + const button = typeof x === 'string' ? { text: x, result: index + 2 } : x; + const buttonElement = document.createElement('button'); + + buttonElement.classList.add('menu_button', 'menu_button_custom'); + buttonElement.classList.add(...(button.classes ?? [])); + + buttonElement.textContent = button.text; + if (button.action) buttonElement.addEventListener('click', button.action); + if (button.result) buttonElement.addEventListener('click', () => this.completeCustom(button.result)); + + buttonElement.setAttribute('data-result', String(button.result ?? undefined)); + + if (button.appendAtEnd) { + this.controls.appendChild(buttonElement); + } else { + this.controls.insertBefore(buttonElement, this.ok); + } + return buttonElement; + }); + + // Set the default button class + const defaultButton = this.controls.querySelector(`[data-result="${this.defaultResult}"]`); + if (defaultButton) defaultButton.classList.add('menu_button_default'); switch (type) { case POPUP_TYPE.TEXT: { this.input.style.display = 'none'; - this.cancel.style.display = 'none'; + if (!cancelButton) this.cancel.style.display = 'none'; break; } case POPUP_TYPE.CONFIRM: { this.input.style.display = 'none'; - this.ok.textContent = okButton ?? template.getAttribute('popup_text_yes'); - this.cancel.textContent = cancelButton ?? template.getAttribute('popup_text_no'); + if (!okButton) this.ok.textContent = template.getAttribute('popup_text_yes'); + if (!cancelButton) this.cancel.textContent = template.getAttribute('popup_text_no'); break; } case POPUP_TYPE.INPUT: { this.input.style.display = 'block'; - this.ok.textContent = okButton ?? template.getAttribute('popup_text_save'); + if (!okButton) this.ok.textContent = template.getAttribute('popup_text_save'); break; } default: { @@ -107,13 +157,6 @@ export class Popup { // illegal argument } - this.input.addEventListener('keydown', (evt) => { - if (evt.key != 'Enter' || evt.altKey || evt.ctrlKey || evt.shiftKey) return; - evt.preventDefault(); - evt.stopPropagation(); - this.completeAffirmative(); - }); - this.ok.addEventListener('click', () => this.completeAffirmative()); this.cancel.addEventListener('click', () => this.completeNegative()); const keyListener = (evt) => { @@ -129,13 +172,32 @@ export class Popup { break; } } + case 'Enter': { + // Only count enter if no modifier key is pressed + if (!evt.altKey && !evt.ctrlKey && !evt.shiftKey) { + evt.preventDefault(); + evt.stopPropagation(); + this.completeCustom(this.defaultResult); + window.removeEventListener('keydown', keyListenerBound); + } + break; + } } }; const keyListenerBound = keyListener.bind(this); window.addEventListener('keydown', keyListenerBound); } + /** + * Asynchronously shows the popup element by appending it to the document body, + * setting its display to 'block' and focusing on the input if the popup type is INPUT. + * + * @returns {Promise} A promise that resolves with the value of the popup when it is completed. + */ async show() { + // Set z-index, so popups can stack "on top" of each other + this.dom.style.zIndex = String(++currentPopupZIndex); + document.body.append(this.dom); this.dom.style.display = 'block'; switch (this.type) { @@ -143,6 +205,9 @@ export class Popup { this.input.focus(); break; } + default: + this.ok.focus(); + break; } $(this.dom).transition({ @@ -201,7 +266,40 @@ export class Popup { + /** + * Completes the popup with a custom result. + * Calls into the default three delete states, if a valid `POPUP_RESULT` is provided. + * + * @param {POPUP_RESULT|number} result - The result of the custom action + */ + completeCustom(result) { + switch (result) { + case POPUP_RESULT.AFFIRMATIVE: { + this.completeAffirmative(); + break; + } + case POPUP_RESULT.NEGATIVE: { + this.completeNegative(); + break; + } + case POPUP_RESULT.CANCELLED: { + this.completeCancelled(); + break; + } + default: { + this.value = this.type === POPUP_TYPE.INPUT ? this.input.value : result; + this.result = result ? POPUP_RESULT.AFFIRMATIVE : POPUP_RESULT.NEGATIVE; + this.hide(); + break; + } + } + } + + /** + * Hides the popup, using the internal resolver to return the value to the original show promise + */ hide() { + --currentPopupZIndex; $(this.dom).transition({ opacity: 0, duration: animation_duration, @@ -215,22 +313,20 @@ export class Popup { } } - - /** - * Displays a blocking popup with a given text and type. - * @param {JQuery|string|Element} text - Text to display in the popup. + * Displays a blocking popup with a given text and type + * @param {JQuery|string|Element} text - Text to display in the popup * @param {POPUP_TYPE} type - * @param {string} inputValue - Value to set the input to. - * @param {PopupOptions} options - Options for the popup. - * @returns + * @param {string} inputValue - Value to set the input to + * @param {PopupOptions} [popupOptions={}] - Options for the popup + * @returns {Promise} The value for this popup, which can either be the popup retult or the input value if chosen */ -export function callGenericPopup(text, type, inputValue = '', { okButton, cancelButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling } = {}) { +export function callGenericPopup(text, type, inputValue = '', popupOptions = {}) { const popup = new Popup( text, type, inputValue, - { okButton, cancelButton, rows, wide, large, allowHorizontalScrolling, allowVerticalScrolling }, + popupOptions, ); return popup.show(); } diff --git a/public/style.css b/public/style.css index ce84975e0..9cbe98738 100644 --- a/public/style.css +++ b/public/style.css @@ -3092,6 +3092,7 @@ grammarly-extension { #dialogue_popup_controls, .dialogue_popup_controls { + margin-top: 10px; display: flex; align-self: center; gap: 20px; @@ -3100,7 +3101,8 @@ grammarly-extension { #bulk_tag_popup_reset, #bulk_tag_popup_remove_mutual, #dialogue_popup_ok, -.dialogue_popup_ok { +.dialogue_popup_ok, +.menu_button.dialogue_popup_ok { background-color: var(--crimson70a); cursor: pointer; } @@ -3108,7 +3110,8 @@ grammarly-extension { #bulk_tag_popup_reset:hover, #bulk_tag_popup_remove_mutual:hover, #dialogue_popup_ok:hover, -.dialogue_popup_ok:hover { +.dialogue_popup_ok:hover, +.menu_button.dialogue_popup_ok:hover { background-color: var(--crimson-hover); } @@ -3118,7 +3121,7 @@ grammarly-extension { #dialogue_popup_input, .dialogue_popup_input { - margin: 10px 0; + margin: 10px 0 0 0; width: 100%; } @@ -3166,6 +3169,16 @@ grammarly-extension { text-align: center; } +.menu_button.menu_button_default { + border: 1px ridge var(--white30a); + box-shadow: 0 0 5px var(--SmartThemeBorderColor); +} + +.menu_button.menu_button_custom { + /** Custom buttons should not scale to smallest size, otherwise they will always break to multiline */ + width: unset; +} + .avatar_div .menu_button, .form_create_bottom_buttons_block .menu_button { font-weight: bold; @@ -5134,4 +5147,4 @@ body:not(.movingUI) .drawer-content.maximized { color: #FAF8F6; } -/* Pastel White */ \ No newline at end of file +/* Pastel White */