summaryrefslogtreecommitdiffstats
path: root/comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs')
-rw-r--r--comm/mail/components/unifiedtoolbar/content/list-box-selection.mjs549
1 files changed, 549 insertions, 0 deletions
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;
+ }
+}