From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- .../unifiedtoolbar/content/list-box-selection.mjs | 549 +++++++++++++++++++++ 1 file changed, 549 insertions(+) create mode 100644 comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs (limited to 'comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs') diff --git a/comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs b/comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs new file mode 100644 index 0000000000..afe84921dd --- /dev/null +++ b/comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs @@ -0,0 +1,549 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +import "./customizable-element.mjs"; // eslint-disable-line import/no-unassigned-import + +/** + * Shared implementation for a list box used as both a palette of items to add + * to a toolbar and a toolbar of items. + */ +export default class ListBoxSelection extends HTMLUListElement { + /** + * The currently selected item for keyboard operations. + * + * @type {?CustomizableElement} + */ + selectedItem = null; + + /** + * The item the context menu is opened for. + * + * @type {?CustomizableElement} + */ + contextMenuFor = null; + + /** + * Key name the primary action is executed on. + * + * @type {string} + */ + actionKey = "Enter"; + + /** + * The ID of the menu to show as context menu. + * + * @type {string} + */ + contextMenuId = ""; + + /** + * If items can be reordered in this list box. + * + * @type {boolean} + */ + canMoveItems = false; + + /** + * @returns {boolean} If the widget has connected previously. + */ + connectedCallback() { + if (this.hasConnected) { + return true; + } + this.hasConnected = true; + + this.setAttribute("role", "listbox"); + this.setAttribute("tabindex", "0"); + + this.addEventListener("contextmenu", this.handleContextMenu, { + capture: true, + }); + document + .getElementById(this.contextMenuId) + .addEventListener("popuphiding", this.#handleContextMenuClose); + this.addEventListener("keydown", this.#handleKey, { capture: true }); + this.addEventListener("click", this.#handleClick, { capture: true }); + this.addEventListener("focus", this.#handleFocus); + this.addEventListener("dragstart", this.#handleDragstart); + this.addEventListener("dragenter", this.#handleDragenter); + this.addEventListener("dragover", this.#handleDragover); + this.addEventListener("dragleave", this.#handleDragleave); + this.addEventListener("drop", this.#handleDrop); + this.addEventListener("dragend", this.#handleDragend); + return false; + } + + disconnectedCallback() { + this.contextMenuFor = null; + this.selectedItem = null; + } + + /** + * Default context menu event handler. Simply forwards the call to + * initializeContextMenu. + * + * @param {MouseEvent} event - The contextmenu mouse click event. + */ + handleContextMenu = event => { + this.initializeContextMenu(event); + }; + + /** + * Store the clicked item and open the context menu. + * + * @param {MouseEvent} event - The contextmenu mouse click event. + */ + initializeContextMenu(event) { + // If the context menu was opened by keyboard, we already have the item. + if (!this.contextMenuFor) { + this.contextMenuFor = event.target.closest("li"); + this.#clearSelection(); + } + document + .getElementById(this.contextMenuId) + .openPopupAtScreen(event.screenX, event.screenY, true); + } + + /** + * Discard the reference to the item the context menu is triggered on when the + * menu is closed. + */ + #handleContextMenuClose = () => { + this.contextMenuFor = null; + }; + + /** + * Make sure some element is selected when focus enters the element. + */ + #handleFocus = () => { + if (!this.selectedItem) { + this.selectItem(this.firstElementChild); + } + }; + + /** + * Handles basic list box keyboard interactions. + * + * @param {KeyboardEvent} event - The event for the key down. + */ + #handleKey = event => { + // Clicking into the list might clear the selection while retaining focus, + // so we need to make sure we have a selected item here. + if (!this.selectedItem) { + this.selectItem(this.firstElementChild); + } + const rightIsForward = document.dir === "ltr"; + switch (event.key) { + case this.actionKey: + this.primaryAction(this.selectedItem); + break; + case "Home": + if (this.canMoveItems && event.altKey) { + this.moveItemToStart(this.selectedItem); + break; + } + this.selectItem(this.firstElementChild); + break; + case "End": + if (this.canMoveItems && event.altKey) { + this.moveItemToEnd(this.selectedItem); + break; + } + this.selectItem(this.lastElementChild); + break; + case "ArrowLeft": + if (this.canMoveItems && event.altKey) { + if (rightIsForward) { + this.moveItemBackward(this.selectedItem); + break; + } + this.moveItemForward(this.selectedItem); + break; + } + if (rightIsForward) { + this.selectItem(this.selectedItem?.previousElementSibling); + break; + } + this.selectItem(this.selectedItem?.nextElementSibling); + break; + case "ArrowRight": + if (this.canMoveItems && event.altKey) { + if (rightIsForward) { + this.moveItemForward(this.selectedItem); + break; + } + this.moveItemBackward(this.selectedItem); + break; + } + if (rightIsForward) { + this.selectItem(this.selectedItem?.nextElementSibling); + break; + } + this.selectItem(this.selectedItem?.previousElementSibling); + break; + case "ContextMenu": + this.contextMenuFor = this.selectedItem; + return; + default: + return; + } + + event.stopPropagation(); + event.preventDefault(); + }; + + /** + * Handles the click event on an item in the list box. Marks the item as + * selected. + * + * @param {MouseEvent} event - The event for the mouse click. + */ + #handleClick = event => { + const item = event.target.closest("li"); + if (item) { + this.selectItem(item); + } else { + this.#clearSelection(); + } + event.stopPropagation(); + event.preventDefault(); + }; + + /** + * Set up the drag data transfer. + * + * @param {DragEvent} event - Drag start event. + */ + #handleDragstart = event => { + // Only allow dragging the customizable elements themeselves. + if (event.target.getAttribute("is") !== "customizable-element") { + event.preventDefault(); + return; + } + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData( + "text/tb-item-id", + event.target.getAttribute("item-id") + ); + const customizableItem = event.target; + window.requestAnimationFrame(() => { + customizableItem.classList.add("dragging"); + }); + }; + + /** + * Calculate the drop position's closest sibling and the relative drop point. + * Assumes the list is laid out horizontally if canMoveItems is true. Else + * the sibling will be the event target and afterSibling will always be true. + * + * @param {DragEvent} event - The event the sibling being dragged over should + * be found in. + * @returns {{sibling: CustomizableElement, afterSibling: boolean}} + */ + #dragSiblingInfo(event) { + let sibling = event.target; + let afterSibling = true; + if (this.canMoveItems) { + const listBoundingRect = this.getBoundingClientRect(); + const listY = listBoundingRect.y + listBoundingRect.height / 2; + const element = this.getRootNode().elementFromPoint(event.x, listY); + sibling = element.closest('li[is="customizable-element"]'); + if (!sibling) { + if (!this.children.length) { + return {}; + } + sibling = this.lastElementChild; + } + const boundingRect = sibling.getBoundingClientRect(); + if (event.x < boundingRect.x + boundingRect.width / 2) { + afterSibling = false; + } + if (document.dir === "rtl") { + afterSibling = !afterSibling; + } + } + return { sibling, afterSibling }; + } + + /** + * Shared logic for when a drag event happens over a new part of the list. + * + * @param {DragEvent} event - Drag event. + */ + #dragIn(event) { + const itemId = event.dataTransfer.getData("text/tb-item-id"); + if (!itemId || !this.canAddElement(itemId)) { + event.dataTransfer.dropEffect = "none"; + event.preventDefault(); + return; + } + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "move"; + if (!this.canMoveItems) { + return; + } + const { sibling, afterSibling } = this.#dragSiblingInfo(event); + if (!sibling) { + return; + } + sibling.classList.toggle("drop-before", !afterSibling); + sibling.classList.toggle("drop-after", afterSibling); + sibling.nextElementSibling?.classList.remove("drop-before", "drop-after"); + sibling.previousElementSibling?.classList.remove( + "drop-before", + "drop-after" + ); + } + + /** + * Shared logic for when a drag leaves an element. + * + * @param {Element} element - Element the drag has left. + */ + #dragOut(element) { + element.classList.remove("drop-after", "drop-before"); + if (element !== this) { + return; + } + for (const child of this.querySelectorAll(".drop-after,.drop-before")) { + child.classList.remove("drop-after", "drop-before"); + } + } + + /** + * Prevents the default action for the dragenter event to enable dropping + * items on this list. Shows a drag position placeholder in the target if + * applicable. + * + * @param {DragEvent} event - Drag enter event. + */ + #handleDragenter = event => { + this.#dragIn(event); + }; + + /** + * Prevents the default for the dragover event to enable dropping items on + * this list. Shows a drag position placeholder in the target if applicable. + * + * @param {DragEvent} event - Drag over event. + */ + #handleDragover = event => { + this.#dragIn(event); + }; + + /** + * Hide the drag position placeholder. + * + * @param {DragEvent} event - Drag leave event. + */ + #handleDragleave = event => { + if (!this.canMoveItems) { + return; + } + this.#dragOut(event.target); + }; + + /** + * Move the item to the dragged into given position. Possibly moving adopting + * it from another list. + * + * @param {DragEvent} event - Drop event. + */ + #handleDrop = event => { + const itemId = event.dataTransfer.getData("text/tb-item-id"); + if ( + event.dataTransfer.dropEffect !== "move" || + !itemId || + !this.canAddElement(itemId) + ) { + return; + } + + const { sibling, afterSibling } = this.#dragSiblingInfo(event); + + event.preventDefault(); + this.#dragOut(sibling ?? this); + this.handleDrop(itemId, sibling, afterSibling); + }; + + /** + * Remove the item from this list if it was dropped into another list. Return + * it to its palette if dropped outside a valid target. + * + * @param {DragEvent} event - Drag end event. + */ + #handleDragend = event => { + event.target.classList.remove("dragging"); + if (event.dataTransfer.dropEffect === "move") { + this.handleDragSuccess(event.target); + return; + } + // If we can't move the item to the drop location, return it to its palette. + const palette = event.target.palette; + if (event.dataTransfer.dropEffect === "none" && palette !== this) { + event.preventDefault(); + this.handleDragSuccess(event.target); + palette.returnItem(event.target); + } + }; + + /** + * Handle an item from a drag operation being added to the list. The drag + * origin could be this list or another list. + * + * @param {string} itemId - Item ID to add to this list from a drop. + * @param {CustomizableElement} sibling - Sibling this item should end up next + * to. + * @param {boolean} afterSibling - If the item should be inserted after the + * sibling. + * @return {CustomizableElement} The dropped customizable element created by + * this handler. + */ + handleDrop(itemId, sibling, afterSibling) { + const item = document.createElement("li", { + is: "customizable-element", + }); + item.setAttribute("item-id", itemId); + item.draggable = true; + if (!this.canMoveItems || !sibling) { + this.appendChild(item); + return item; + } + if (afterSibling) { + sibling.after(item); + return item; + } + sibling.before(item); + return item; + } + + /** + * Handle an item from this list having been dragged somewhere else. + * + * @param {CustomizableElement} item - Item dragged somewhere else. + */ + handleDragSuccess(item) { + item.remove(); + } + + /** + * Check if a given item is allowed to be added to this list. Is false if the + * item is already in the list and moving around is not allowed. + * + * @param {string} itemId - The item ID of the item that wants to be added to + * this list. + * @returns {boolean} If this item can be added to this list. + */ + canAddElement(itemId) { + return this.canMoveItems || !this.querySelector(`li[item-id="${itemId}"]`); + } + + /** + * Move the item forward in the list box. Only works if canMoveItems is true. + * + * @param {CustomizableElement} item - The item to move forward. + */ + moveItemForward(item) { + if (!this.canMoveItems) { + return; + } + item.nextElementSibling?.after(item); + } + + /** + * Move the item backward in the list box. Only works if canMoveItems is true. + * + * @param {CustomizableElement} item - The item to move backward. + */ + moveItemBackward(item) { + if (!this.canMoveItems) { + return; + } + item.previousElementSibling?.before(item); + } + + /** + * Move the item to the start of the list. Only works if canMoveItems is + * true. + * + * @param {CustomizableElement} item - The item to move to the start. + */ + moveItemToStart(item) { + if (!this.canMoveItems || item === this.firstElementChild) { + return; + } + this.prepend(item); + } + + /** + * Move the item to the end of the list. Only works if canMoveItems is true. + * + * @param {CustomizableElement} item - The item to move to the end. + */ + moveItemToEnd(item) { + if (!this.canMoveItems || item === this.lastElementChild) { + return; + } + this.appendChild(item); + } + + /** + * Select the item. Removes the selection of the previous item. No-op if no + * item is passed. + * + * @param {CustomizableElement} item - The item to select. + */ + selectItem(item) { + if (item) { + this.selectedItem?.removeAttribute("aria-selected"); + item.setAttribute("aria-selected", "true"); + this.selectedItem = item; + this.setAttribute("aria-activedescendant", item.id); + } + } + + /** + * Clear the selection inside the list box. + */ + #clearSelection() { + this.selectedItem?.removeAttribute("aria-selected"); + this.selectedItem = null; + this.removeAttribute("aria-activedescendant"); + } + + /** + * Select the next item in the list. If there are no more items in either + * direction, the selection state is reset. + * + * @param {CustomizableElement} item - The item of which the next sibling + * should be the new selection. + */ + #selectNextItem(item) { + const nextItem = item.nextElementSibling || item.previousElementSibling; + if (nextItem) { + this.selectItem(nextItem); + return; + } + this.#clearSelection(); + } + + /** + * Execute the primary action on the item after it has been deselected and the + * next item was selected. Implementations are expected to override this + * method and call it as the first step, aborting if it returns true. + * + * @param {CustomizableElement} item - The item the primary action should be + * executed on. + * @returns {boolean} If the action should be aborted. + */ + primaryAction(item) { + if (!item) { + return true; + } + item.removeAttribute("aria-selected"); + this.#selectNextItem(item); + return false; + } +} -- cgit v1.2.3