/* 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/. */ { // Animation variables for expanding and collapsing child lists. const ANIMATION_DURATION_MS = 200; const ANIMATION_EASING = "ease"; let reducedMotionMedia = matchMedia("(prefers-reduced-motion)"); /** * Provides keyboard and mouse interaction to a (possibly nested) list. * It is intended for lists with a small number (up to 1000?) of items. * Only one item can be selected at a time. Maintenance of the items in the * list is not managed here. Styling of the list is not managed here. * * The following class names apply to list items: * - selected: Indicates the currently selected list item. * - children: If the list item has descendants. * - collapsed: If the list item's descendants are hidden. * * List items can provide their own twisty element, which will operate when * clicked on if given the class name "twisty". * * This class fires "collapsed", "expanded" and "select" events. */ let TreeListboxMixin = Base => class extends Base { /** * The selected and focused item, or null if there is none. * * @type {?HTMLLIElement} */ _selectedRow = null; connectedCallback() { if (this.hasConnected) { return; } this.hasConnected = true; this.setAttribute("is", "tree-listbox"); switch (this.getAttribute("role")) { case "tree": this.isTree = true; break; case "listbox": this.isTree = false; break; default: throw new RangeError( `Unsupported role ${this.getAttribute("role")}` ); } this.tabIndex = 0; this.domChanged(); this._initRows(); let rows = this.rows; if (!this.selectedRow && rows.length) { // TODO: This should only really happen on "focus". this.selectedRow = rows[0]; } this.addEventListener("click", this); this.addEventListener("keydown", this); this._mutationObserver.observe(this, { subtree: true, childList: true, }); } handleEvent(event) { switch (event.type) { case "click": this._onClick(event); break; case "keydown": this._onKeyDown(event); break; } } _onClick(event) { if (event.button !== 0) { return; } let row = event.target.closest("li:not(.unselectable)"); if (!row) { return; } if ( row.classList.contains("children") && (event.target.closest(".twisty") || event.detail == 2) ) { if (row.classList.contains("collapsed")) { this.expandRow(row); } else { this.collapseRow(row); } return; } this.selectedRow = row; if (document.activeElement != this) { // Overflowing elements with tabindex=-1 steal focus. Grab it back. this.focus(); } } _onKeyDown(event) { if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { return; } switch (event.key) { case "ArrowUp": this.selectedIndex = this._clampIndex(this.selectedIndex - 1); break; case "ArrowDown": this.selectedIndex = this._clampIndex(this.selectedIndex + 1); break; case "Home": this.selectedIndex = 0; break; case "End": this.selectedIndex = this.rowCount - 1; break; case "PageUp": { if (!this.selectedRow) { break; } // Get the top of the selected row, and remove the page height. let selectedBox = this.selectedRow.getBoundingClientRect(); let y = selectedBox.top - this.clientHeight; // Find the last row below there. let rows = this.rows; let i = this.selectedIndex - 1; while (i > 0 && rows[i].getBoundingClientRect().top >= y) { i--; } this.selectedIndex = i; break; } case "PageDown": { if (!this.selectedRow) { break; } // Get the top of the selected row, and add the page height. let selectedBox = this.selectedRow.getBoundingClientRect(); let y = selectedBox.top + this.clientHeight; // Find the last row below there. let rows = this.rows; let i = rows.length - 1; while ( i > this.selectedIndex && rows[i].getBoundingClientRect().top >= y ) { i--; } this.selectedIndex = i; break; } case "ArrowLeft": case "ArrowRight": { let selected = this.selectedRow; if (!selected) { break; } let isArrowRight = event.key == "ArrowRight"; let isRTL = this.matches(":dir(rtl)"); if (isArrowRight == isRTL) { let parent = selected.parentNode.closest( ".children:not(.unselectable)" ); if ( parent && (!selected.classList.contains("children") || selected.classList.contains("collapsed")) ) { this.selectedRow = parent; break; } if (selected.classList.contains("children")) { this.collapseRow(selected); } } else if (selected.classList.contains("children")) { if (selected.classList.contains("collapsed")) { this.expandRow(selected); } else { this.selectedRow = selected.querySelector("li"); } } break; } case "Enter": { const selected = this.selectedRow; if (!selected?.classList.contains("children")) { return; } if (selected.classList.contains("collapsed")) { this.expandRow(selected); } else { this.collapseRow(selected); } break; } default: return; } event.preventDefault(); } /** * Data for the rows in the DOM. * * @typedef {object} TreeRowData * @property {HTMLLIElement} row - The row item. * @property {HTMLLIElement[]} ancestors - The ancestors of the row, * ordered closest to furthest away. */ /** * Data for all items beneath this node, including collapsed items, * ordered as they are in the DOM. * * @type {TreeRowData[]} */ _rowsData = []; /** * Call whenever the tree nodes or ordering changes. This should only be * called externally if the mutation observer has been dis-connected and * re-connected. */ domChanged() { this._rowsData = Array.from(this.querySelectorAll("li"), row => { let ancestors = []; for ( let parentRow = row.parentNode.closest("li"); this.contains(parentRow); parentRow = parentRow.parentNode.closest("li") ) { ancestors.push(parentRow); } return { row, ancestors }; }); } _mutationObserver = new MutationObserver(mutations => { for (let mutation of mutations) { for (let node of mutation.addedNodes) { if (node.nodeType != Node.ELEMENT_NODE || !node.matches("li")) { continue; } // No item can already be selected on addition. node.classList.remove("selected"); } } let oldRowsData = this._rowsData; this.domChanged(); this._initRows(); let newRows = this.rows; if (!newRows.length) { this.selectedRow = null; return; } if (!this.selectedRow) { // TODO: This should only really happen on "focus". this.selectedRow = newRows[0]; return; } if (newRows.includes(this.selectedRow)) { // Selected row is still visible. return; } let oldSelectedIndex = oldRowsData.findIndex( entry => entry.row == this.selectedRow ); if (oldSelectedIndex < 0) { // Unexpected, the selectedRow was not in our _rowsData list. this.selectedRow = newRows[0]; return; } // Find the closest ancestor that is still shown. let existingAncestor = oldRowsData[oldSelectedIndex].ancestors.find( row => newRows.includes(row) ); if (existingAncestor) { // We search as if the existingAncestor is the full list. This keeps // the selection within the ancestor, or moves it to the ancestor if // no child is found. // NOTE: Includes existingAncestor itself, so should be non-empty. newRows = newRows.filter(row => existingAncestor.contains(row)); } // We have lost the selectedRow, so we select a new row. We want to try // and find the element that exists both in the new rows and in the old // rows, that directly preceded the previously selected row. We then // want to select the next visible row that follows this found element // in the new rows. // If rows were replaced with new rows, this will select the first of // the new rows. // If rows were simply removed, this will select the next row that was // not removed. let beforeIndex = -1; for (let i = oldSelectedIndex; i >= 0; i--) { beforeIndex = this._rowsData.findIndex( entry => entry.row == oldRowsData[i].row ); if (beforeIndex >= 0) { break; } } // Start from just after the found item, or 0 if none were found // (beforeIndex == -1), find the next visible item. Otherwise we default // to selecting the last row. let selectRow = newRows[newRows.length - 1]; for (let i = beforeIndex + 1; i < this._rowsData.length; i++) { if (newRows.includes(this._rowsData[i].row)) { selectRow = this._rowsData[i].row; break; } } this.selectedRow = selectRow; }); /** * Set the role attribute and classes for all descendants of the widget. */ _initRows() { let descendantItems = this.querySelectorAll("li"); let descendantLists = this.querySelectorAll("ol, ul"); for (let i = 0; i < descendantItems.length; i++) { let row = descendantItems[i]; row.setAttribute("role", this.isTree ? "treeitem" : "option"); if ( i + 1 < descendantItems.length && row.contains(descendantItems[i + 1]) ) { row.classList.add("children"); if (this.isTree) { row.setAttribute( "aria-expanded", !row.classList.contains("collapsed") ); } } else { row.classList.remove("children"); row.classList.remove("collapsed"); row.removeAttribute("aria-expanded"); } row.setAttribute("aria-selected", row.classList.contains("selected")); } if (this.isTree) { for (let list of descendantLists) { list.setAttribute("role", "group"); } } for (let childList of this.querySelectorAll( "li.collapsed > :is(ol, ul)" )) { childList.style.height = "0"; } } /** * Every visible row. Rows with collapsed ancestors are not included. * * @type {HTMLLIElement[]} */ get rows() { return [...this.querySelectorAll("li:not(.unselectable)")].filter( row => { let collapsed = row.parentNode.closest("li.collapsed"); if (collapsed && this.contains(collapsed)) { return false; } return true; } ); } /** * The number of visible rows. * * @type {integer} */ get rowCount() { return this.rows.length; } /** * Clamps `index` to a value between 0 and `rowCount - 1`. * * @param {integer} index * @returns {integer} */ _clampIndex(index) { if (index >= this.rowCount) { return this.rowCount - 1; } if (index < 0) { return 0; } return index; } /** * Ensures that the row at `index` is on the screen. * * @param {integer} index */ scrollToIndex(index) { this.getRowAtIndex(index)?.scrollIntoView({ block: "nearest" }); } /** * Returns the row element at `index` or null if `index` is out of range. * * @param {integer} index * @returns {HTMLLIElement?} */ getRowAtIndex(index) { return this.rows[index]; } /** * The index of the selected row. If there are no rows, the value is -1. * Otherwise, should always have a value between 0 and `rowCount - 1`. * It is set to 0 in `connectedCallback` if there are rows. * * @type {integer} */ get selectedIndex() { return this.rows.findIndex(row => row == this.selectedRow); } set selectedIndex(index) { index = this._clampIndex(index); this.selectedRow = this.getRowAtIndex(index); } /** * The selected and focused item, or null if there is none. * * @type {?HTMLLIElement} */ get selectedRow() { return this._selectedRow; } set selectedRow(row) { if (row == this._selectedRow) { return; } if (this._selectedRow) { this._selectedRow.classList.remove("selected"); this._selectedRow.setAttribute("aria-selected", "false"); } this._selectedRow = row ?? null; if (row) { row.classList.add("selected"); row.setAttribute("aria-selected", "true"); this.setAttribute("aria-activedescendant", row.id); row.firstElementChild.scrollIntoView({ block: "nearest" }); } else { this.removeAttribute("aria-activedescendant"); } this.dispatchEvent(new CustomEvent("select")); } /** * Collapses the row at `index` if it can be collapsed. If the selected * row is a descendant of the collapsing row, selection is moved to the * collapsing row. * * @param {integer} index */ collapseRowAtIndex(index) { this.collapseRow(this.getRowAtIndex(index)); } /** * Expands the row at `index` if it can be expanded. * * @param {integer} index */ expandRowAtIndex(index) { this.expandRow(this.getRowAtIndex(index)); } /** * Collapses the row if it can be collapsed. If the selected row is a * descendant of the collapsing row, selection is moved to the collapsing * row. * * @param {HTMLLIElement} row - The row to collapse. */ collapseRow(row) { if ( row.classList.contains("children") && !row.classList.contains("collapsed") ) { if (row.contains(this.selectedRow)) { this.selectedRow = row; } row.classList.add("collapsed"); if (this.isTree) { row.setAttribute("aria-expanded", "false"); } row.dispatchEvent(new CustomEvent("collapsed", { bubbles: true })); this._animateCollapseRow(row); } } /** * Expands the row if it can be expanded. * * @param {HTMLLIElement} row - The row to expand. */ expandRow(row) { if ( row.classList.contains("children") && row.classList.contains("collapsed") ) { row.classList.remove("collapsed"); if (this.isTree) { row.setAttribute("aria-expanded", "true"); } row.dispatchEvent(new CustomEvent("expanded", { bubbles: true })); this._animateExpandRow(row); } } /** * Animate the collapsing of a row containing child items. * * @param {HTMLLIElement} row - The parent row element. */ _animateCollapseRow(row) { let childList = row.querySelector("ol, ul"); if (reducedMotionMedia.matches) { if (childList) { childList.style.height = "0"; } return; } let childListHeight = childList.scrollHeight; let animation = childList.animate( [{ height: `${childListHeight}px` }, { height: "0" }], { duration: ANIMATION_DURATION_MS, easing: ANIMATION_EASING, fill: "both", } ); animation.onfinish = () => { childList.style.height = "0"; animation.cancel(); }; } /** * Animate the revealing of a row containing child items. * * @param {HTMLLIElement} row - The parent row element. */ _animateExpandRow(row) { let childList = row.querySelector("ol, ul"); if (reducedMotionMedia.matches) { if (childList) { childList.style.height = null; } return; } let childListHeight = childList.scrollHeight; let animation = childList.animate( [{ height: "0" }, { height: `${childListHeight}px` }], { duration: ANIMATION_DURATION_MS, easing: ANIMATION_EASING, fill: "both", } ); animation.onfinish = () => { childList.style.height = null; animation.cancel(); }; } }; /** * An unordered list with the functionality of TreeListboxMixin. */ class TreeListbox extends TreeListboxMixin(HTMLUListElement) {} customElements.define("tree-listbox", TreeListbox, { extends: "ul" }); /** * An ordered list with the functionality of TreeListboxMixin, plus the * ability to re-order the top-level list by drag-and-drop/Alt+Up/Alt+Down. * * This class fires an "ordered" event when the list is re-ordered. * * @note All children of this element should be HTML. If there are XUL * elements, you're gonna have a bad time. */ class OrderableTreeListbox extends TreeListboxMixin(HTMLOListElement) { connectedCallback() { super.connectedCallback(); this.setAttribute("is", "orderable-tree-listbox"); this.addEventListener("dragstart", this); window.addEventListener("dragover", this); window.addEventListener("drop", this); window.addEventListener("dragend", this); } handleEvent(event) { super.handleEvent(event); switch (event.type) { case "dragstart": this._onDragStart(event); break; case "dragover": this._onDragOver(event); break; case "drop": this._onDrop(event); break; case "dragend": this._onDragEnd(event); break; } } /** * An array of all top-level rows that can be reordered. Override this * getter to prevent reordering of one or more rows. * * @note So far this has only been used to prevent the last row being * moved. Any other use is untested. It likely also works for rows at * the top of the list. * * @returns {HTMLLIElement[]} */ get _orderableChildren() { return [...this.children]; } _onKeyDown(event) { super._onKeyDown(event); if ( !event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || !["ArrowUp", "ArrowDown"].includes(event.key) ) { return; } let row = this.selectedRow; if (!row || row.parentElement != this) { return; } let otherRow; if (event.key == "ArrowUp") { otherRow = row.previousElementSibling; } else { otherRow = row.nextElementSibling; } if (!otherRow) { return; } // Check we can move these rows. let orderable = this._orderableChildren; if (!orderable.includes(row) || !orderable.includes(otherRow)) { return; } let reducedMotion = reducedMotionMedia.matches; this.scrollToIndex(this.rows.indexOf(otherRow)); // Temporarily disconnect the mutation observer to stop it changing things. this._mutationObserver.disconnect(); if (event.key == "ArrowUp") { if (!reducedMotion) { let { top: otherTop } = otherRow.getBoundingClientRect(); let { top: rowTop, height: rowHeight } = row.getBoundingClientRect(); OrderableTreeListbox._animateTranslation(otherRow, 0 - rowHeight); OrderableTreeListbox._animateTranslation(row, rowTop - otherTop); } this.insertBefore(row, otherRow); } else { if (!reducedMotion) { let { top: otherTop, height: otherHeight } = otherRow.getBoundingClientRect(); let { top: rowTop, height: rowHeight } = row.getBoundingClientRect(); OrderableTreeListbox._animateTranslation(otherRow, rowHeight); OrderableTreeListbox._animateTranslation( row, rowTop - otherTop - otherHeight + rowHeight ); } this.insertBefore(row, otherRow.nextElementSibling); } this._mutationObserver.observe(this, { subtree: true, childList: true }); // Rows moved. this.domChanged(); this.dispatchEvent(new CustomEvent("ordered", { detail: row })); } _onDragStart(event) { if (!event.target.closest("[draggable]")) { // This shouldn't be necessary, but is?! event.preventDefault(); return; } let orderable = this._orderableChildren; if (orderable.length < 2) { return; } for (let topLevelRow of orderable) { if (topLevelRow.contains(event.target)) { let rect = topLevelRow.getBoundingClientRect(); this._dragInfo = { row: topLevelRow, // How far can we move `topLevelRow` upwards? min: orderable[0].getBoundingClientRect().top - rect.top, // How far can we move `topLevelRow` downwards? max: orderable[orderable.length - 1].getBoundingClientRect().bottom - rect.bottom, // Where is the pointer relative to the scroll box of the list? // (Not quite, the Y position of `this` is not removed, but we'd // only have to do the same where this value is used.) scrollY: event.clientY + this.scrollTop, // Where is the pointer relative to `topLevelRow`? offsetY: event.clientY - rect.top, }; topLevelRow.classList.add("dragging"); // Prevent `topLevelRow` being used as the drag image. We don't // really want any drag image, but there's no way to not have one. event.dataTransfer.setDragImage(document.createElement("img"), 0, 0); return; } } } _onDragOver(event) { if (!this._dragInfo) { return; } let { row, min, max, scrollY, offsetY } = this._dragInfo; // Move `row` with the mouse pointer. let dragY = Math.min( max, Math.max(min, event.clientY + this.scrollTop - scrollY) ); row.style.transform = `translateY(${dragY}px)`; let thisRect = this.getBoundingClientRect(); // How much space is there above `row`? We'll see how many rows fit in // the space and put `row` in after them. let spaceAbove = Math.max( 0, event.clientY + this.scrollTop - offsetY - thisRect.top ); // The height of all rows seen in the loop so far. let totalHeight = 0; // If we've looped past the row being dragged. let afterDraggedRow = false; // The row before where a drop would take place. If null, drop would // happen at the start of the list. let targetRow = null; for (let topLevelRow of this._orderableChildren) { if (topLevelRow == row) { afterDraggedRow = true; continue; } let rect = topLevelRow.getBoundingClientRect(); let enoughSpace = spaceAbove > totalHeight + rect.height / 2; let multiplier = 0; if (enoughSpace) { if (afterDraggedRow) { multiplier = -1; } targetRow = topLevelRow; } else if (!afterDraggedRow) { multiplier = 1; } OrderableTreeListbox._transitionTranslation( topLevelRow, multiplier * row.clientHeight ); totalHeight += rect.height; } this._dragInfo.dropTarget = targetRow; event.preventDefault(); } _onDrop(event) { if (!this._dragInfo) { return; } let { row, dropTarget } = this._dragInfo; let targetRow; if (dropTarget) { targetRow = dropTarget.nextElementSibling; } else { targetRow = this.firstElementChild; } event.preventDefault(); // Temporarily disconnect the mutation observer to stop it changing things. this._mutationObserver.disconnect(); this.insertBefore(row, targetRow); this._mutationObserver.observe(this, { subtree: true, childList: true }); // Rows moved. this.domChanged(); this.dispatchEvent(new CustomEvent("ordered", { detail: row })); } _onDragEnd(event) { if (!this._dragInfo) { return; } this._dragInfo.row.classList.remove("dragging"); delete this._dragInfo; for (let topLevelRow of this.children) { topLevelRow.style.transition = null; topLevelRow.style.transform = null; } } /** * Used to animate a real change in the order. The element is moved in the * DOM, then the animation makes it appear to move from the original * position to the new position * * @param {HTMLLIElement} element - The row to animate. * @param {number} from - Original Y position of the element relative to * its current position. */ static _animateTranslation(element, from) { let animation = element.animate( [ { transform: `translateY(${from}px)` }, { transform: "translateY(0px)" }, ], { duration: ANIMATION_DURATION_MS, fill: "both", } ); animation.onfinish = () => animation.cancel(); } /** * Used to simulate a change in the order. The element remains in the same * DOM position. * * @param {HTMLLIElement} element - The row to animate. * @param {number} to - The new Y position of the element after animation. */ static _transitionTranslation(element, to) { if (!reducedMotionMedia.matches) { element.style.transition = `transform ${ANIMATION_DURATION_MS}ms`; } element.style.transform = to ? `translateY(${to}px)` : null; } } customElements.define("orderable-tree-listbox", OrderableTreeListbox, { extends: "ol", }); }