Revision control
Copy as Markdown
Other Tools
/* 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
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;
}
}