summaryrefslogtreecommitdiffstats
path: root/comm/mail/base/content/widgets/tree-listbox.js
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/base/content/widgets/tree-listbox.js')
-rw-r--r--comm/mail/base/content/widgets/tree-listbox.js914
1 files changed, 914 insertions, 0 deletions
diff --git a/comm/mail/base/content/widgets/tree-listbox.js b/comm/mail/base/content/widgets/tree-listbox.js
new file mode 100644
index 0000000000..81d42ca72b
--- /dev/null
+++ b/comm/mail/base/content/widgets/tree-listbox.js
@@ -0,0 +1,914 @@
+/* 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",
+ });
+}