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
This commit is contained in:
Wolfsblvt 2024-05-25 00:44:09 +02:00
parent 26572458b6
commit d9582062d2
3 changed files with 155 additions and 46 deletions

View File

@ -4856,8 +4856,8 @@
</div>
<textarea class="dialogue_popup_input text_pole" rows="1"></textarea>
<div class="dialogue_popup_controls">
<div class="dialogue_popup_ok menu_button" data-i18n="Delete">Delete</div>
<div class="dialogue_popup_cancel menu_button" data-i18n="Cancel">Cancel</div>
<div class="dialogue_popup_ok menu_button" data-i18n="Delete" data-result="1">Delete</div>
<div class="dialogue_popup_cancel menu_button" data-i18n="Cancel" data-result="0">Cancel</div>
</div>
</div>
</div>
@ -4871,8 +4871,8 @@
</div>
<textarea id="dialogue_popup_input" class="text_pole" rows="1"></textarea>
<div id="dialogue_popup_controls">
<div id="dialogue_popup_ok" class="menu_button" data-i18n="Delete">Delete</div>
<div id="dialogue_popup_cancel" class="menu_button" data-i18n="Cancel">Cancel</div>
<div id="dialogue_popup_ok" class="menu_button" data-i18n="Delete" data-result="1">Delete</div>
<div id="dialogue_popup_cancel" class="menu_button" data-i18n="Cancel" data-result="0">Cancel</div>
</div>
</div>
</div>

View File

@ -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<HTMLElement>|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<HTMLElement>|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<string|number|boolean?>} 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<HTMLElement>|string|Element} text - Text to display in the popup.
* Displays a blocking popup with a given text and type
* @param {JQuery<HTMLElement>|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<POPUP_RESULT|string|boolean?>} 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();
}

View File

@ -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;