diff options
Diffstat (limited to 'comm/mail/base/content/widgets/tree-view.mjs')
-rw-r--r-- | comm/mail/base/content/widgets/tree-view.mjs | 2633 |
1 files changed, 2633 insertions, 0 deletions
diff --git a/comm/mail/base/content/widgets/tree-view.mjs b/comm/mail/base/content/widgets/tree-view.mjs new file mode 100644 index 0000000000..aef622fa27 --- /dev/null +++ b/comm/mail/base/content/widgets/tree-view.mjs @@ -0,0 +1,2633 @@ +/* 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/. */ + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +import { TreeSelection } from "chrome://messenger/content/tree-selection.mjs"; + +// Account for the mac OS accelerator key variation. +// Use these strings to check keyboard event properties. +const accelKeyName = AppConstants.platform == "macosx" ? "metaKey" : "ctrlKey"; +const otherKeyName = AppConstants.platform == "macosx" ? "ctrlKey" : "metaKey"; + +const ANIMATION_DURATION_MS = 200; +const reducedMotionMedia = matchMedia("(prefers-reduced-motion)"); + +/** + * Main tree view container that takes care of generating the main scrollable + * DIV and the tree table. + */ +class TreeView extends HTMLElement { + static observedAttributes = ["rows"]; + + /** + * The number of rows on either side to keep of the visible area to keep in + * memory in order to avoid visible blank spaces while the user scrolls. + * + * This member is visible for testing and should not be used outside of this + * class in production code. + * + * @type {integer} + */ + _toleranceSize = 0; + + /** + * Set the size of the tolerance buffer based on the number of rows which can + * be visible at once. + */ + #calculateToleranceBufferSize() { + this._toleranceSize = this.#calculateVisibleRowCount() * 2; + } + + /** + * Index of the first row that exists in the DOM. Includes rows in the + * tolerance buffer if they have been added. + * + * @type {integer} + */ + #firstBufferRowIndex = 0; + + /** + * Index of the last row that exists in the DOM. Includes rows in the + * tolerance buffer if they have been added. + * + * @type {integer} + */ + #lastBufferRowIndex = 0; + + /** + * Index of the first visible row. + * + * @type {integer} + */ + #firstVisibleRowIndex = 0; + + /** + * Index of the last visible row. + * + * @type {integer} + */ + #lastVisibleRowIndex = 0; + + /** + * Row indices mapped to the row elements that exist in the DOM. + * + * @type {Map<integer, HTMLTableRowElement>} + */ + _rows = new Map(); + + /** + * The current view. + * + * @type {nsITreeView} + */ + _view = null; + + /** + * The current selection. + * + * @type {nsITreeSelection} + */ + _selection = null; + + /** + * The function storing the timeout callback for the delayed select feature in + * order to clear it when not needed. + * + * @type {integer} + */ + _selectTimeout = null; + + /** + * A handle to the callback to fill the buffer when we aren't busy painting. + * + * @type {number} + */ + #bufferFillIdleCallbackHandle = null; + + /** + * The virtualized table containing our rows. + * + * @type {TreeViewTable} + */ + table = null; + + /** + * An event to fire to indicate the work of filling the buffer is complete. + * This will fire once both visible and tolerance rows are ready. It will also + * fire if no change to the buffer is required. + * + * This member is visible in order to provide a reliable indicator to tests + * that all expected rows should be in place. It should not be used in + * production code. + * + * @type {Event} + */ + _rowBufferReadyEvent = null; + + /** + * Fire the provided event, if any, in order to indicate that any necessary + * buffer modification work is complete, including if no work is necessary. + */ + #dispatchRowBufferReadyEvent() { + // Don't fire if we're currently waiting on buffer fills; let the callback + // do that when it's finished. + if (this._rowBufferReadyEvent && !this.#bufferFillIdleCallbackHandle) { + this.dispatchEvent(this._rowBufferReadyEvent); + } + } + + /** + * Determine the height of the visible row area, excluding any chrome which + * covers elements. + * + * WARNING: This may cause synchronous reflow if used after modifying the DOM. + * + * @returns {integer} - The height of the area into which visible rows are + * rendered. + */ + #calculateVisibleHeight() { + // Account for the table header height in a sticky position above the body. + return this.clientHeight - this.table.header.clientHeight; + } + + /** + * Determine how many rows are visible in the client presently. + * + * WARNING: This may cause synchronous reflow if used after modifying the DOM. + * + * @returns {integer} - The number of visible or partly-visible rows. + */ + #calculateVisibleRowCount() { + return Math.ceil( + this.#calculateVisibleHeight() / this._rowElementClass.ROW_HEIGHT + ); + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + // Prevent this element from being part of the roving tab focus since we + // handle that independently for the TreeViewTableBody and we don't want any + // interference from this. + this.tabIndex = -1; + this.classList.add("tree-view-scrollable-container"); + + this.table = document.createElement("table", { is: "tree-view-table" }); + this.appendChild(this.table); + + this.placeholder = this.querySelector(`slot[name="placeholders"]`); + + this.addEventListener("scroll", this); + + let lastHeight = 0; + this.resizeObserver = new ResizeObserver(entries => { + // The width of the table isn't important to virtualizing the table. Skip + // updating if the height hasn't changed. + if (this.clientHeight == lastHeight) { + this.#dispatchRowBufferReadyEvent(); + return; + } + + if (!this._rowElementClass) { + this.#dispatchRowBufferReadyEvent(); + return; + } + + // The number of rows in the tolerance buffer is based on the number of + // rows which can be visible. Update it. + this.#calculateToleranceBufferSize(); + + // There's not much point in reducing the number of rows on resize. Scroll + // height remains the same and we can retain the extra rows in the buffer. + if (this.clientHeight > lastHeight) { + this._ensureVisibleRowsAreDisplayed(); + } else { + this.#dispatchRowBufferReadyEvent(); + } + + lastHeight = this.clientHeight; + }); + this.resizeObserver.observe(this); + } + + disconnectedCallback() { + this.#resetRowBuffer(); + this.resizeObserver.disconnect(); + } + + attributeChangedCallback(attrName, oldValue, newValue) { + this._rowElementName = newValue || "tree-view-table-row"; + this._rowElementClass = customElements.get(this._rowElementName); + + this.#calculateToleranceBufferSize(); + + if (this._view) { + this.reset(); + } + } + + handleEvent(event) { + switch (event.type) { + case "keyup": { + if ( + ["Tab", "F6"].includes(event.key) && + this.currentIndex == -1 && + this._view?.rowCount + ) { + let selectionChanged = false; + if (this.selectedIndex == -1) { + this._selection.select(0); + selectionChanged = true; + } + this.currentIndex = this.selectedIndex; + if (selectionChanged) { + this.onSelectionChanged(); + } + } + break; + } + case "click": { + if (event.button !== 0) { + return; + } + + let row = event.target.closest(`tr[is="${this._rowElementName}"]`); + if (!row) { + return; + } + + let index = row.index; + + if (event.target.classList.contains("tree-button-thread")) { + if (this._view.isContainerOpen(index)) { + let children = 0; + for ( + let i = index + 1; + i < this._view.rowCount && this._view.getLevel(i) > 0; + i++ + ) { + children++; + } + this._selectRange(index, index + children, event[accelKeyName]); + } else { + let addedRows = this.expandRowAtIndex(index); + this._selectRange(index, index + addedRows, event[accelKeyName]); + } + this.table.body.focus(); + return; + } + + if (this._view.isContainer(index) && event.target.closest(".twisty")) { + if (this._view.isContainerOpen(index)) { + this.collapseRowAtIndex(index); + } else { + let addedRows = this.expandRowAtIndex(index); + this.scrollToIndex( + index + Math.min(addedRows, this.#calculateVisibleRowCount() - 1) + ); + } + this.table.body.focus(); + return; + } + + // Handle the click as a CTRL extension if it happens on the checkbox + // image inside the selection column. + if (event.target.classList.contains("tree-view-row-select-checkbox")) { + if (event.shiftKey) { + this._selectRange(-1, index, event[accelKeyName]); + } else { + this._toggleSelected(index); + } + this.table.body.focus(); + return; + } + + if (event.target.classList.contains("tree-button-request-delete")) { + this.table.body.dispatchEvent( + new CustomEvent("request-delete", { + bubbles: true, + detail: { + index, + }, + }) + ); + this.table.body.focus(); + return; + } + + if (event.target.classList.contains("tree-button-flag")) { + this.table.body.dispatchEvent( + new CustomEvent("toggle-flag", { + bubbles: true, + detail: { + isFlagged: row.dataset.properties.includes("flagged"), + index, + }, + }) + ); + this.table.body.focus(); + return; + } + + if (event.target.classList.contains("tree-button-unread")) { + this.table.body.dispatchEvent( + new CustomEvent("toggle-unread", { + bubbles: true, + detail: { + isUnread: row.dataset.properties.includes("unread"), + index, + }, + }) + ); + this.table.body.focus(); + return; + } + + if (event.target.classList.contains("tree-button-spam")) { + this.table.body.dispatchEvent( + new CustomEvent("toggle-spam", { + bubbles: true, + detail: { + isJunk: row.dataset.properties.split(" ").includes("junk"), + index, + }, + }) + ); + this.table.body.focus(); + return; + } + + if (event[accelKeyName] && !event.shiftKey) { + this._toggleSelected(index); + } else if (event.shiftKey) { + this._selectRange(-1, index, event[accelKeyName]); + } else { + this._selectSingle(index); + } + + this.table.body.focus(); + break; + } + case "keydown": { + if (event.altKey || event[otherKeyName]) { + return; + } + + let currentIndex = this.currentIndex == -1 ? 0 : this.currentIndex; + let newIndex; + switch (event.key) { + case "ArrowUp": + newIndex = currentIndex - 1; + break; + case "ArrowDown": + newIndex = currentIndex + 1; + break; + case "ArrowLeft": + case "ArrowRight": { + event.preventDefault(); + if (this.currentIndex == -1) { + return; + } + let isArrowRight = event.key == "ArrowRight"; + let isRTL = this.matches(":dir(rtl)"); + if (isArrowRight == isRTL) { + // Collapse action. + let currentLevel = this._view.getLevel(this.currentIndex); + if (this._view.isContainerOpen(this.currentIndex)) { + this.collapseRowAtIndex(this.currentIndex); + return; + } else if (currentLevel == 0) { + return; + } + + let parentIndex = this._view.getParentIndex(this.currentIndex); + if (parentIndex != -1) { + newIndex = parentIndex; + } + } else if (this._view.isContainer(this.currentIndex)) { + // Expand action. + if (!this._view.isContainerOpen(this.currentIndex)) { + let addedRows = this.expandRowAtIndex(this.currentIndex); + this.scrollToIndex( + this.currentIndex + + Math.min(addedRows, this.#calculateVisibleRowCount() - 1) + ); + } else { + newIndex = this.currentIndex + 1; + } + } + if (newIndex != undefined) { + this._selectSingle(newIndex); + } + return; + } + case "Home": + newIndex = 0; + break; + case "End": + newIndex = this._view.rowCount - 1; + break; + case "PageUp": + newIndex = Math.max( + 0, + currentIndex - this.#calculateVisibleRowCount() + ); + break; + case "PageDown": + newIndex = Math.min( + this._view.rowCount - 1, + currentIndex + this.#calculateVisibleRowCount() + ); + break; + } + + if (newIndex != undefined) { + newIndex = this._clampIndex(newIndex); + if (newIndex != null) { + if (event[accelKeyName] && !event.shiftKey) { + // Change focus, but not selection. + this.currentIndex = newIndex; + } else if (event.shiftKey) { + this._selectRange(-1, newIndex, event[accelKeyName]); + } else { + this._selectSingle(newIndex, true); + } + } + event.preventDefault(); + return; + } + + // Space bar keystroke selection toggling. + if (event.key == " " && this.currentIndex != -1) { + // Don't do anything if we're on macOS and the target row is already + // selected. + if ( + AppConstants.platform == "macosx" && + this._selection.isSelected(this.currentIndex) + ) { + return; + } + + // Handle the macOS exception of toggling the selection with only + // the space bar since CMD+Space is captured by the OS. + if (event[accelKeyName] || AppConstants.platform == "macosx") { + this._toggleSelected(this.currentIndex); + event.preventDefault(); + } else if (!this._selection.isSelected(this.currentIndex)) { + // The target row is not currently selected. + this._selectSingle(this.currentIndex, true); + event.preventDefault(); + } + } + break; + } + case "scroll": + this._ensureVisibleRowsAreDisplayed(); + break; + } + } + + /** + * The current view for this list. + * + * @type {nsITreeView} + */ + get view() { + return this._view; + } + + set view(view) { + this._selection = null; + if (this._view) { + this._view.setTree(null); + this._view.selection = null; + } + if (this._selection) { + this._selection.view = null; + } + + this._view = view; + if (view) { + try { + this._selection = new TreeSelection(); + this._selection.tree = this; + this._selection.view = view; + + view.selection = this._selection; + view.setTree(this); + } catch (ex) { + // This isn't a XULTreeElement, and we can't make it one, so if the + // `setTree` call crosses XPCOM, an exception will be thrown. + if (ex.result != Cr.NS_ERROR_XPC_BAD_CONVERT_JS) { + throw ex; + } + } + } + + // Clear the height of the top spacer to avoid confusing + // `_ensureVisibleRowsAreDisplayed`. + this.table.spacerTop.setHeight(0); + this.reset(); + + this.dispatchEvent(new CustomEvent("viewchange")); + } + + /** + * Set the colspan of the spacer row cells. + * + * @param {int} count - The amount of visible columns. + */ + setSpacersColspan(count) { + // Add an extra column if the table is editable to account for the column + // picker column. + if (this.parentNode.editable) { + count++; + } + this.table.spacerTop.setColspan(count); + this.table.spacerBottom.setColspan(count); + } + + /** + * Clear all rows from the buffer, empty the table body, and reset spacers. + */ + #resetRowBuffer() { + this.#cancelToleranceFillCallback(); + this.table.body.replaceChildren(); + this._rows.clear(); + this.#firstBufferRowIndex = 0; + this.#lastBufferRowIndex = 0; + this.#firstVisibleRowIndex = 0; + + // Set the height of the bottom spacer to account for the now-missing rows. + // We want to ensure that the overall scroll height does not decrease. + // Otherwise, we may lose our scroll position and cause unnecessary + // scrolling. However, we don't always want to change the height of the top + // spacer for the same reason. + let rowCount = this._view?.rowCount ?? 0; + this.table.spacerBottom.setHeight( + rowCount * this._rowElementClass.ROW_HEIGHT + ); + } + + /** + * Clear all rows from the list and create them again. + */ + reset() { + this.#resetRowBuffer(); + this._ensureVisibleRowsAreDisplayed(); + } + + /** + * Updates all existing rows in place, without removing all the rows and + * starting again. This can be used if the row element class hasn't changed + * and its `index` setter is capable of handling any modifications required. + */ + invalidate() { + this.invalidateRange(this.#firstBufferRowIndex, this.#lastBufferRowIndex); + } + + /** + * Perform the actions necessary to invalidate the specified row. Implemented + * separately to allow {@link invalidateRange} to handle testing event fires + * on its own. + * + * @param {integer} index + */ + #doInvalidateRow(index) { + const rowCount = this._view?.rowCount ?? 0; + let row = this.getRowAtIndex(index); + if (row) { + if (index >= rowCount) { + this._removeRowAtIndex(index); + } else { + row.index = index; + row.selected = this._selection.isSelected(index); + } + } else if ( + index >= this.#firstBufferRowIndex && + index <= Math.min(rowCount - 1, this.#lastBufferRowIndex) + ) { + this._addRowAtIndex(index); + } + } + + /** + * Invalidate the rows between `startIndex` and `endIndex`. + * + * @param {integer} startIndex + * @param {integer} endIndex + */ + invalidateRange(startIndex, endIndex) { + for ( + let index = Math.max(startIndex, this.#firstBufferRowIndex), + last = Math.min(endIndex, this.#lastBufferRowIndex); + index <= last; + index++ + ) { + this.#doInvalidateRow(index); + } + this._ensureVisibleRowsAreDisplayed(); + } + + /** + * Invalidate the row at `index` in place. If `index` refers to a row that + * should exist but doesn't (because the row count increased), adds a row. + * If `index` refers to a row that does exist but shouldn't (because the + * row count decreased), removes it. + * + * @param {integer} index + */ + invalidateRow(index) { + this.#doInvalidateRow(index); + this.#dispatchRowBufferReadyEvent(); + } + + /** + * A contiguous range, inclusive of both extremes. + * + * @typedef InclusiveRange + * @property {integer} first - The inclusive start of the range. + * @property {integer} last - The inclusive end of the range. + */ + + /** + * Calculate the range of rows we wish to have in a filled tolerance buffer + * based on a given range of visible rows. + * + * @param {integer} firstVisibleRow - The first visible row in the range. + * @param {integer} lastVisibleRow - The last visible row in the range. + * @param {integer} dataRowCount - The total number of available rows in the + * source data. + * @returns {InclusiveRange} - The full range of the desired buffer. + */ + #calculateDesiredBufferRange(firstVisibleRow, lastVisibleRow, dataRowCount) { + const desiredRowRange = {}; + + desiredRowRange.first = Math.max(firstVisibleRow - this._toleranceSize, 0); + desiredRowRange.last = Math.min( + lastVisibleRow + this._toleranceSize, + dataRowCount - 1 + ); + + return desiredRowRange; + } + + #createToleranceFillCallback() { + // Don't schedule a new buffer fill callback if we already have one. + if (!this.#bufferFillIdleCallbackHandle) { + this.#bufferFillIdleCallbackHandle = requestIdleCallback(deadline => + this.#fillToleranceBuffer(deadline) + ); + } + } + + #cancelToleranceFillCallback() { + cancelIdleCallback(this.#bufferFillIdleCallbackHandle); + this.#bufferFillIdleCallbackHandle = null; + } + + /** + * Fill the buffer with tolerance rows above and below the visible rows. + * + * As fetching data and modifying the DOM is expensive, this is intended to be + * run within an idle callback and includes management of the idle callback + * handle and creation of further callbacks if work is not completed. + * + * @param {IdleDeadline} deadline - A deadline object for fetching the + * remaining time in the idle tick. + */ + #fillToleranceBuffer(deadline) { + this.#bufferFillIdleCallbackHandle = null; + + const rowCount = this._view?.rowCount ?? 0; + if (!rowCount) { + return; + } + + const bufferRange = this.#calculateDesiredBufferRange( + this.#firstVisibleRowIndex, + this.#lastVisibleRowIndex, + rowCount + ); + + // Set the amount of time to leave in the deadline to fill another row. In + // order to cooperatively schedule work, we shouldn't overrun the time + // allotted for the idle tick. This value should be set such that it leaves + // enough time to perform another row fill and adjust the relevant spacer + // while doing the maximal amount of work per callback. + const MS_TO_LEAVE_PER_FILL = 1.25; + + // Fill in the beginning of the buffer. + if (bufferRange.first < this.#firstBufferRowIndex) { + for ( + let i = this.#firstBufferRowIndex - 1; + i >= bufferRange.first && + deadline.timeRemaining() > MS_TO_LEAVE_PER_FILL; + i-- + ) { + this._addRowAtIndex(i, this.table.body.firstElementChild); + + // Update as we go in case we need to wait for the next idle. + this.#firstBufferRowIndex = i; + } + + // Adjust the height of the top spacer to account for the new rows we've + // added. + this.table.spacerTop.setHeight( + this.#firstBufferRowIndex * this._rowElementClass.ROW_HEIGHT + ); + + // If we haven't completed the work of filling the tolerance buffer, + // schedule a new job to do so. + if (this.#firstBufferRowIndex != bufferRange.first) { + this.#createToleranceFillCallback(); + return; + } + } + + // Fill in the end of the buffer. + if (bufferRange.last > this.#lastBufferRowIndex) { + for ( + let i = this.#lastBufferRowIndex + 1; + i <= bufferRange.last && + deadline.timeRemaining() > MS_TO_LEAVE_PER_FILL; + i++ + ) { + this._addRowAtIndex(i); + + // Update as we go in case we need to wait for the next idle. + this.#lastBufferRowIndex = i; + } + + // Adjust the height of the bottom spacer to account for the new rows + // we've added. + this.table.spacerBottom.setHeight( + (rowCount - 1 - this.#lastBufferRowIndex) * + this._rowElementClass.ROW_HEIGHT + ); + + // If we haven't completed the work of filling the tolerance buffer, + // schedule a new job to do so. + if (this.#lastBufferRowIndex != bufferRange.last) { + this.#createToleranceFillCallback(); + return; + } + } + + // Notify tests that we have finished work. + this.#dispatchRowBufferReadyEvent(); + } + + /** + * The calculated ranges which determine the shape of the row buffer at + * various stages of processing. + * + * @typedef RowBufferRanges + * @property {InclusiveRange} visibleRows - The range of rows which should be + * displayed to the user. + * @property {integer?} pruneBefore - The index of the row before which any + * additional rows should be discarded. + * @property {integer?} pruneAfter - The index of the row after which any + * additional rows should be discarded. + * @property {InclusiveRange} finalizedRows - The range of rows which should + * exist in the row buffer after any additions and removals have been made. + */ + + /** + * Calculate the values necessary for building the list of visible rows and + * retaining any rows in the buffer which fall inside the desired tolerance + * and form a contiguous range with the visible rows. + * + * WARNING: This function makes calculations based on existing DOM dimensions. + * Do not use it after you have modified the DOM. + * + * @returns {RowBufferRanges} + */ + #calculateRowBufferRanges(dataRowCount) { + /** @type {RowBufferRanges} */ + const ranges = { + visibleRows: {}, + pruneBefore: null, + pruneAfter: null, + finalizedRows: {}, + }; + + // We adjust the row buffer in several stages. First, we'll use the new + // scroll position to determine the boundaries of the buffer. Then, we'll + // create and add any new rows which are necessary to fit the new + // boundaries. Next, we prune rows added in previous scrolls which now fall + // outside the boundaries. Finally, we recalculate the height of the spacers + // which position the visible rows within the rendered area. + ranges.visibleRows.first = Math.max( + Math.floor(this.scrollTop / this._rowElementClass.ROW_HEIGHT), + 0 + ); + + const lastPossibleVisibleRow = Math.ceil( + (this.scrollTop + this.#calculateVisibleHeight()) / + this._rowElementClass.ROW_HEIGHT + ); + + ranges.visibleRows.last = + Math.min(lastPossibleVisibleRow, dataRowCount) - 1; + + // Determine the number of rows desired in the tolerance buffer in order to + // determine whether there are any that we can save. + const desiredRowRange = this.#calculateDesiredBufferRange( + ranges.visibleRows.first, + ranges.visibleRows.last, + dataRowCount + ); + + // Determine which rows are no longer wanted in the buffer. If we've + // scrolled past the previous visible rows, it's possible that the tolerance + // buffer will still contain some rows we'd like to have in the buffer. Note + // that we insist on a contiguous range of rows in the buffer to simplify + // determining which rows exist and appropriately spacing the viewport. + if (this.#lastBufferRowIndex < ranges.visibleRows.first) { + // There is a discontiguity between the visible rows and anything that's + // in the buffer. Prune everything before the visible rows. + ranges.pruneBefore = ranges.visibleRows.first; + ranges.finalizedRows.first = ranges.visibleRows.first; + } else if (this.#firstBufferRowIndex < desiredRowRange.first) { + // The range of rows in the buffer overlaps the start of the visible rows, + // but there are rows outside of the desired buffer as well. Prune them. + ranges.pruneBefore = desiredRowRange.first; + ranges.finalizedRows.first = desiredRowRange.first; + } else { + // Determine the beginning of the finalized buffer based on whether the + // buffer contains rows before the start of the visible rows. + ranges.finalizedRows.first = Math.min( + ranges.visibleRows.first, + this.#firstBufferRowIndex + ); + } + + if (this.#firstBufferRowIndex > ranges.visibleRows.last) { + // There is a discontiguity between the visible rows and anything that's + // in the buffer. Prune everything after the visible rows. + ranges.pruneAfter = ranges.visibleRows.last; + ranges.finalizedRows.last = ranges.visibleRows.last; + } else if (this.#lastBufferRowIndex > desiredRowRange.last) { + // The range of rows in the buffer overlaps the end of the visible rows, + // but there are rows outside of the desired buffer as well. Prune them. + ranges.pruneAfter = desiredRowRange.last; + ranges.finalizedRows.last = desiredRowRange.last; + } else { + // Determine the end of the finalized buffer based on whether the buffer + // contains rows after the end of the visible rows. + ranges.finalizedRows.last = Math.max( + ranges.visibleRows.last, + this.#lastBufferRowIndex + ); + } + + return ranges; + } + + /** + * Display the table rows which should be shown in the visible area and + * request filling of the tolerance buffer when idle. + */ + _ensureVisibleRowsAreDisplayed() { + this.#cancelToleranceFillCallback(); + + let rowCount = this._view?.rowCount ?? 0; + this.placeholder?.classList.toggle("show", !rowCount); + + if (!rowCount || this.#calculateVisibleRowCount() == 0) { + return; + } + + if (this.scrollTop > rowCount * this._rowElementClass.ROW_HEIGHT) { + // Beyond the end of the list. We're about to scroll anyway, so clear + // everything out and wait for it to happen. Don't call `invalidate` here, + // or you'll end up in an infinite loop. + this.table.spacerTop.setHeight(0); + this.#resetRowBuffer(); + return; + } + + const ranges = this.#calculateRowBufferRanges(rowCount); + + // *WARNING: Do not request any DOM dimensions after this point. Modifying + // the DOM will invalidate existing calculations and any additional requests + // will cause synchronous reflow. + + // Add a row if the table is empty. Either we're initializing or have + // invalidated the tree, and the next two steps pass over row zero if there + // are no rows already in the buffer. + if ( + this.#lastBufferRowIndex == 0 && + this.table.body.childElementCount == 0 && + ranges.visibleRows.first == 0 + ) { + this._addRowAtIndex(0); + } + + // Expand the row buffer to include newly-visible rows which weren't already + // visible or preloaded in the tolerance buffer. + + const earliestMissingEndRowIdx = Math.max( + this.#lastBufferRowIndex + 1, + ranges.visibleRows.first + ); + for (let i = earliestMissingEndRowIdx; i <= ranges.visibleRows.last; i++) { + // We are missing rows at the end of the buffer. Either the last row of + // the existing buffer lies within the range of visible rows and we begin + // there, or the entire range of visible rows occurs after the end of the + // buffer and we fill in from the start. + this._addRowAtIndex(i); + } + + const latestMissingStartRowIdx = Math.min( + this.#firstBufferRowIndex - 1, + ranges.visibleRows.last + ); + for (let i = latestMissingStartRowIdx; i >= ranges.visibleRows.first; i--) { + // We are missing rows at the start of the buffer. We'll add them working + // backwards so that we can prepend. Either the first row of the existing + // buffer lies within the range of visible rows and we begin there, or the + // entire range of visible rows occurs before the end of the buffer and we + // fill in from the end. + this._addRowAtIndex(i, this.table.body.firstElementChild); + } + + // Prune the buffer of any rows outside of our desired buffer range. + if (ranges.pruneBefore !== null) { + const pruneBeforeRow = this.getRowAtIndex(ranges.pruneBefore); + let rowToPrune = pruneBeforeRow.previousElementSibling; + while (rowToPrune) { + this._removeRowAtIndex(rowToPrune.index); + rowToPrune = pruneBeforeRow.previousElementSibling; + } + } + + if (ranges.pruneAfter !== null) { + const pruneAfterRow = this.getRowAtIndex(ranges.pruneAfter); + let rowToPrune = pruneAfterRow.nextElementSibling; + while (rowToPrune) { + this._removeRowAtIndex(rowToPrune.index); + rowToPrune = pruneAfterRow.nextElementSibling; + } + } + + // Set the indices of the new first and last rows in the DOM. They may come + // from the tolerance buffer if we haven't exhausted it. + this.#firstBufferRowIndex = ranges.finalizedRows.first; + this.#lastBufferRowIndex = ranges.finalizedRows.last; + + this.#firstVisibleRowIndex = ranges.visibleRows.first; + this.#lastVisibleRowIndex = ranges.visibleRows.last; + + // Adjust the height of the spacers to ensure that visible rows fall within + // the visible space and the overall scroll height is correct. + this.table.spacerTop.setHeight( + this.#firstBufferRowIndex * this._rowElementClass.ROW_HEIGHT + ); + + this.table.spacerBottom.setHeight( + (rowCount - this.#lastBufferRowIndex - 1) * + this._rowElementClass.ROW_HEIGHT + ); + + // The row buffer ideally contains some tolerance on either end to avoid + // creating rows and fetching data for them during short scrolls. However, + // actually creating those rows can be expensive, and during a long scroll + // we may throw them away very quickly. To save the expense, only fill the + // buffer while idle. + + this.#createToleranceFillCallback(); + } + + /** + * Index of the first visible or partly visible row. + * + * @returns {integer} + */ + getFirstVisibleIndex() { + return this.#firstVisibleRowIndex; + } + + /** + * Index of the last visible or partly visible row. + * + * @returns {integer} + */ + getLastVisibleIndex() { + return this.#lastVisibleRowIndex; + } + + /** + * Ensures that the row at `index` is on the screen. + * + * @param {integer} index + */ + scrollToIndex(index, instant = false) { + const rowCount = this._view.rowCount; + if (rowCount == 0) { + // If there are no rows, make sure we're scrolled to the top. + this.scrollTo({ top: 0, behavior: "instant" }); + return; + } + if (index < 0 || index >= rowCount) { + // Bad index. Report, and do nothing. + console.error( + `<${this.localName} id="${this.id}"> tried to scroll to a row that doesn't exist: ${index}` + ); + return; + } + + const topOfRow = this._rowElementClass.ROW_HEIGHT * index; + let scrollTop = this.scrollTop; + const visibleHeight = this.#calculateVisibleHeight(); + const behavior = instant ? "instant" : "auto"; + + // Scroll up to the row. + if (topOfRow < scrollTop) { + this.scrollTo({ top: topOfRow, behavior }); + return; + } + + // Scroll down to the row. + const bottomOfRow = topOfRow + this._rowElementClass.ROW_HEIGHT; + if (bottomOfRow > scrollTop + visibleHeight) { + this.scrollTo({ top: bottomOfRow - visibleHeight, behavior }); + return; + } + + // Call `scrollTo` even if the row is in view, to stop any earlier smooth + // scrolling that might be happening. + this.scrollTo({ top: this.scrollTop, behavior }); + } + + /** + * Updates the list to reflect added or removed rows. + * + * @param {integer} index - The position in the existing list where rows were + * added or removed. + * @param {integer} delta - The change in number of rows; positive if rows + * were added and negative if rows were removed. + */ + rowCountChanged(index, delta) { + if (!this._selection) { + return; + } + + this._selection.adjustSelection(index, delta); + this._updateCurrentIndexClasses(); + this.dispatchEvent(new CustomEvent("rowcountchange")); + } + + /** + * Clamps `index` to a value between 0 and `rowCount - 1`. + * + * @param {integer} index + * @returns {integer} + */ + _clampIndex(index) { + if (!this._view.rowCount) { + return null; + } + if (index < 0) { + return 0; + } + if (index >= this._view.rowCount) { + return this._view.rowCount - 1; + } + return index; + } + + /** + * Creates a new row element and adds it to the DOM. + * + * @param {integer} index + */ + _addRowAtIndex(index, before = null) { + let row = document.createElement("tr", { is: this._rowElementName }); + row.setAttribute("is", this._rowElementName); + this.table.body.insertBefore(row, before); + row.setAttribute("aria-setsize", this._view.rowCount); + row.style.height = `${this._rowElementClass.ROW_HEIGHT}px`; + row.index = index; + if (this._selection?.isSelected(index)) { + row.selected = true; + } + if (this.currentIndex === index) { + row.classList.add("current"); + this.table.body.setAttribute("aria-activedescendant", row.id); + } + this._rows.set(index, row); + } + + /** + * Removes the row element at `index` from the DOM and map of rows. + * + * @param {integer} index + */ + _removeRowAtIndex(index) { + const row = this._rows.get(index); + row?.remove(); + this._rows.delete(index); + } + + /** + * Returns the row element at `index` or null if `index` is out of range. + * + * @param {integer} index + * @returns {HTMLTableRowElement} + */ + getRowAtIndex(index) { + return this._rows.get(index) ?? null; + } + + /** + * 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) { + if (!this._view.isContainerOpen(index)) { + return; + } + + // If the selected row is going to be collapsed, move the selection. + // Even if the row to be collapsed is already selected, set + // selectIndex to ensure currentIndex also points to the correct row. + let selectedIndex = this.selectedIndex; + while (selectedIndex >= index) { + if (selectedIndex == index) { + this.selectedIndex = index; + break; + } + selectedIndex = this._view.getParentIndex(selectedIndex); + } + + // Check if the view calls rowCountChanged. If it didn't, we'll have to + // call it. This can happen if the view has no reference to the tree. + let rowCountDidChange = false; + let rowCountChangeListener = () => { + rowCountDidChange = true; + }; + + let countBefore = this._view.rowCount; + this.addEventListener("rowcountchange", rowCountChangeListener); + this._view.toggleOpenState(index); + this.removeEventListener("rowcountchange", rowCountChangeListener); + let countAdded = this._view.rowCount - countBefore; + + // Call rowCountChanged, if it hasn't already happened. + if (countAdded && !rowCountDidChange) { + this.invalidateRow(index); + this.rowCountChanged(index + 1, countAdded); + } + + this.dispatchEvent( + new CustomEvent("collapsed", { bubbles: true, detail: index }) + ); + } + + /** + * Expands the row at `index` if it can be expanded. + * + * @param {integer} index + * @returns {integer} - the number of rows that were added + */ + expandRowAtIndex(index) { + if (!this._view.isContainer(index) || this._view.isContainerOpen(index)) { + return 0; + } + + // Check if the view calls rowCountChanged. If it didn't, we'll have to + // call it. This can happen if the view has no reference to the tree. + let rowCountDidChange = false; + let rowCountChangeListener = () => { + rowCountDidChange = true; + }; + + let countBefore = this._view.rowCount; + this.addEventListener("rowcountchange", rowCountChangeListener); + this._view.toggleOpenState(index); + this.removeEventListener("rowcountchange", rowCountChangeListener); + let countAdded = this._view.rowCount - countBefore; + + // Call rowCountChanged, if it hasn't already happened. + if (countAdded && !rowCountDidChange) { + this.invalidateRow(index); + this.rowCountChanged(index + 1, countAdded); + } + + this.dispatchEvent( + new CustomEvent("expanded", { bubbles: true, detail: index }) + ); + + return countAdded; + } + + /** + * In a selection, index of the most-recently-selected row. + * + * @type {integer} + */ + get currentIndex() { + return this._selection ? this._selection.currentIndex : -1; + } + + set currentIndex(index) { + if (!this._view) { + return; + } + + this._selection.currentIndex = index; + this._updateCurrentIndexClasses(); + if (index >= 0 && index < this._view.rowCount) { + this.scrollToIndex(index); + } + } + + /** + * Set the "current" class on the right row, and remove it from all other rows. + */ + _updateCurrentIndexClasses() { + let index = this.currentIndex; + + for (let row of this.querySelectorAll( + `tr[is="${this._rowElementName}"].current` + )) { + row.classList.remove("current"); + } + + if (!this._view || index < 0 || index > this._view.rowCount - 1) { + this.table.body.removeAttribute("aria-activedescendant"); + return; + } + + let row = this.getRowAtIndex(index); + if (row) { + // We need to clear the attribute in order to let screen readers know that + // a new message has been selected even if the ID is identical. For + // example when we delete the first message with ID 0, the next message + // becomes ID 0 itself. Therefore the attribute wouldn't trigger the screen + // reader to announce the new message without being cleared first. + this.table.body.removeAttribute("aria-activedescendant"); + row.classList.add("current"); + this.table.body.setAttribute("aria-activedescendant", row.id); + } + } + + /** + * Select and focus the given index. + * + * @param {integer} index - The index to select. + * @param {boolean} [delaySelect=false] - If the selection should be delayed. + */ + _selectSingle(index, delaySelect = false) { + let changeSelection = + this._selection.count != 1 || !this._selection.isSelected(index); + // Update the TreeSelection selection to trigger a tree reset(). + if (changeSelection) { + this._selection.select(index); + } + this.currentIndex = index; + if (changeSelection) { + this.onSelectionChanged(delaySelect); + } + } + + /** + * Start or extend a range selection to the given index and focus it. + * + * @param {number} start - Start index of selection. -1 for current index. + * @param {number} end - End index of selection. + * @param {boolean} extend[false] - If the new selection range should extend + * the current selection. + */ + _selectRange(start, end, extend = false) { + this._selection.rangedSelect(start, end, extend); + this.currentIndex = start == -1 ? end : start; + this.onSelectionChanged(); + } + + /** + * Toggle the selection state at the given index and focus it. + * + * @param {integer} index - The index to toggle. + */ + _toggleSelected(index) { + this._selection.toggleSelect(index); + // We hack the internals of the TreeSelection to clear the + // shiftSelectPivot. + this._selection._shiftSelectPivot = null; + this.currentIndex = index; + this.onSelectionChanged(); + } + + /** + * Select all rows. + */ + selectAll() { + this._selection.selectAll(); + this.onSelectionChanged(); + } + + /** + * Toggle between selecting all rows or none, depending on the current + * selection state. + */ + toggleSelectAll() { + if (!this.selectedIndices.length) { + const index = this._view.rowCount - 1; + this._selection.selectAll(); + this.currentIndex = index; + } else { + this._selection.clearSelection(); + } + // Make sure the body is focused when the selection is changed as + // clicking on the "select all" header button steals the focus. + this.focus(); + + this.onSelectionChanged(); + } + + /** + * In a selection, index of the most-recently-selected row. + * + * @type {integer} + */ + get selectedIndex() { + if (!this._selection?.count) { + return -1; + } + + let min = {}; + this._selection.getRangeAt(0, min, {}); + return min.value; + } + + set selectedIndex(index) { + this._selectSingle(index); + } + + /** + * An array of the indices of all selected rows. + * + * @type {integer[]} + */ + get selectedIndices() { + let indices = []; + let rangeCount = this._selection.getRangeCount(); + + for (let range = 0; range < rangeCount; range++) { + let min = {}; + let max = {}; + this._selection.getRangeAt(range, min, max); + + if (min.value == -1) { + continue; + } + + for (let index = min.value; index <= max.value; index++) { + indices.push(index); + } + } + + return indices; + } + + set selectedIndices(indices) { + this.setSelectedIndices(indices); + } + + /** + * An array of the indices of all selected rows. + * + * @param {integer[]} indices + * @param {boolean} suppressEvent - Prevent a "select" event firing. + */ + setSelectedIndices(indices, suppressEvent) { + this._selection.clearSelection(); + for (let index of indices) { + this._selection.toggleSelect(index); + } + this.onSelectionChanged(false, suppressEvent); + } + + /** + * Changes the selection state of the row at `index`. + * + * @param {integer} index + * @param {boolean?} selected - if set, set the selection state to this + * value, otherwise toggle the current state + * @param {boolean?} suppressEvent - prevent a "select" event firing + * @returns {boolean} - if the index is now selected + */ + toggleSelectionAtIndex(index, selected, suppressEvent) { + let wasSelected = this._selection.isSelected(index); + if (selected === undefined) { + selected = !wasSelected; + } + + if (selected != wasSelected) { + this._selection.toggleSelect(index); + this.onSelectionChanged(false, suppressEvent); + } + + return selected; + } + + /** + * Loop through all available child elements of the placeholder slot and + * show those that are needed. + * @param {array} idsToShow - Array of ids to show. + */ + updatePlaceholders(idsToShow) { + for (let element of this.placeholder.children) { + element.hidden = !idsToShow.includes(element.id); + } + } + + /** + * Update the classes on the table element to reflect the current selection + * state, and dispatch an event to allow implementations to handle the + * change in the selection state. + * + * @param {boolean} [delaySelect=false] - If the selection should be delayed. + * @param {boolean} [suppressEvent=false] - Prevent a "select" event firing. + */ + onSelectionChanged(delaySelect = false, suppressEvent = false) { + const selectedCount = this._selection.count; + const allSelected = selectedCount == this._view.rowCount; + + this.table.classList.toggle("all-selected", allSelected); + this.table.classList.toggle("some-selected", !allSelected && selectedCount); + this.table.classList.toggle("multi-selected", selectedCount > 1); + + const selectButton = this.table.querySelector(".tree-view-header-select"); + // Some implementations might not use a select header. + if (selectButton) { + // Only mark the `select` button as "checked" if all rows are selected. + selectButton.toggleAttribute("aria-checked", allSelected); + // The default action for the header button is to deselect all messages + // if even one message is currently selected. + document.l10n.setAttributes( + selectButton, + selectedCount + ? "threadpane-column-header-deselect-all" + : "threadpane-column-header-select-all" + ); + } + + if (suppressEvent) { + return; + } + + // No need to handle a delayed select if not required. + if (!delaySelect) { + // Clear the timeout in case something was still running. + if (this._selectTimeout) { + window.clearTimeout(this._selectTimeout); + } + this.dispatchEvent(new CustomEvent("select", { bubbles: true })); + return; + } + + let delay = this.dataset.selectDelay || 50; + if (delay != -1) { + if (this._selectTimeout) { + window.clearTimeout(this._selectTimeout); + } + this._selectTimeout = window.setTimeout(() => { + this.dispatchEvent(new CustomEvent("select", { bubbles: true })); + this._selectTimeout = null; + }, delay); + } + } +} +customElements.define("tree-view", TreeView); + +/** + * The main <table> element containing the thead and the TreeViewTableBody + * tbody. This class is used to expose all those methods and custom events + * needed at the implementation level. + */ +class TreeViewTable extends HTMLTableElement { + /** + * The array of objects containing the data to generate the needed columns. + * Keep this public so child elements can access it if needed. + * @type {Array} + */ + columns; + + /** + * The header row for the table. + * + * @type {TreeViewTableHeader} + */ + header; + + /** + * Array containing the IDs of templates holding menu items to dynamically add + * to the menupopup of the column picker. + * @type {Array} + */ + popupMenuTemplates = []; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "tree-view-table"); + this.classList.add("tree-table"); + + // Use a fragment to append child elements to later add them all at once + // to the DOM. Performance is important. + const fragment = new DocumentFragment(); + + this.header = document.createElement("thead", { + is: "tree-view-table-header", + }); + fragment.append(this.header); + + this.spacerTop = document.createElement("tbody", { + is: "tree-view-table-spacer", + }); + fragment.append(this.spacerTop); + + this.body = document.createElement("tbody", { + is: "tree-view-table-body", + }); + fragment.append(this.body); + + this.spacerBottom = document.createElement("tbody", { + is: "tree-view-table-spacer", + }); + fragment.append(this.spacerBottom); + + this.append(fragment); + } + + /** + * If set to TRUE before generating the columns, the table will + * automatically create a column picker in the table header. + * + * @type {boolean} + */ + set editable(val) { + this.dataset.editable = val; + } + + get editable() { + return this.dataset.editable === "true"; + } + + /** + * Set the id attribute of the TreeViewTableBody for selection and styling + * purpose. + * + * @param {string} id - The string ID to set. + */ + setBodyID(id) { + this.body.id = id; + } + + setPopupMenuTemplates(array) { + this.popupMenuTemplates = array; + } + + /** + * Set the columns array of the table. This should only be used during + * initialization and any following change to the columns visibility should + * be handled via the updateColumns() method. + * + * @param {Array} columns - The array of columns to generate. + */ + setColumns(columns) { + this.columns = columns; + this.header.setColumns(); + this.#updateView(); + } + + /** + * Update the currently visible columns. + * + * @param {Array} columns - The array of columns to update. It should match + * the original array set via the setColumn() method since this method will + * only update the column visibility without generating new elements. + */ + updateColumns(columns) { + this.columns = columns; + this.#updateView(); + } + + /** + * Store the newly resized column values in the xul store. + * + * @param {string} url - The document URL used to store the values. + * @param {DOMEvent} event - The dom event bubbling from the resized action. + */ + setColumnsWidths(url, event) { + const width = event.detail.splitter.width; + const column = event.detail.column; + const newValue = `${column}:${width}`; + let newWidths; + + // Check if we already have stored values and update it if so. + let columnsWidths = Services.xulStore.getValue(url, "columns", "widths"); + if (columnsWidths) { + let updated = false; + columnsWidths = columnsWidths.split(","); + for (let index = 0; index < columnsWidths.length; index++) { + const cw = columnsWidths[index].split(":"); + if (cw[0] == column) { + cw[1] = width; + updated = true; + columnsWidths[index] = newValue; + break; + } + } + // Push the new value into the array if we didn't have an existing one. + if (!updated) { + columnsWidths.push(newValue); + } + newWidths = columnsWidths.join(","); + } else { + newWidths = newValue; + } + + // Store the values as a plain string with the current format: + // columnID:width,columnID:width,... + Services.xulStore.setValue(url, "columns", "widths", newWidths); + } + + /** + * Restore the previously saved widths of the various columns if we have + * any. + * + * @param {string} url - The document URL used to store the values. + */ + restoreColumnsWidths(url) { + let columnsWidths = Services.xulStore.getValue(url, "columns", "widths"); + if (!columnsWidths) { + return; + } + + for (let column of columnsWidths.split(",")) { + column = column.split(":"); + this.querySelector(`#${column[0]}`)?.style.setProperty( + `--${column[0]}Splitter-width`, + `${column[1]}px` + ); + } + } + + /** + * Update the visibility of the currently available columns. + */ + #updateView() { + let lastResizableColumn = this.columns.findLast( + c => !c.hidden && (c.resizable ?? true) + ); + + for (let column of this.columns) { + document.getElementById(column.id).hidden = column.hidden; + + // No need to update the splitter visibility if the column is + // specifically not resizable. + if (column.resizable === false) { + continue; + } + + document.getElementById(column.id).resizable = + column != lastResizableColumn; + } + } +} +customElements.define("tree-view-table", TreeViewTable, { extends: "table" }); + +/** + * Class used to generate the thead of the TreeViewTable. This class will take + * care of handling columns sizing and sorting order, with bubbling events to + * allow listening for those changes on the implementation level. + */ +class TreeViewTableHeader extends HTMLTableSectionElement { + /** + * An array of all table header cells that can be reordered. + * + * @returns {HTMLTableCellElement[]} + */ + get #orderableChildren() { + return [...this.querySelectorAll("th[draggable]:not([hidden])")]; + } + + /** + * Used to simulate a change in the order. The element remains in the same + * DOM position. + * + * @param {HTMLTableRowElement} 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 ease`; + } + element.style.transform = to ? `translateX(${to}px)` : null; + } + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "tree-view-table-header"); + this.classList.add("tree-table-header"); + this.row = document.createElement("tr"); + this.appendChild(this.row); + + this.addEventListener("keypress", this); + this.addEventListener("dragstart", this); + this.addEventListener("dragover", this); + this.addEventListener("dragend", this); + this.addEventListener("drop", this); + } + + handleEvent(event) { + switch (event.type) { + case "keypress": + this.#onKeyPress(event); + break; + case "dragstart": + this.#onDragStart(event); + break; + case "dragover": + this.#onDragOver(event); + break; + case "dragend": + this.#onDragEnd(); + break; + case "drop": + this.#onDrop(event); + break; + } + } + + #onKeyPress(event) { + if (!event.altKey || !["ArrowRight", "ArrowLeft"].includes(event.key)) { + this.triggerTableHeaderRovingTab(event); + return; + } + + let column = event.target.closest(`th[is="tree-view-table-header-cell"]`); + if (!column) { + return; + } + + let visibleColumns = this.parentNode.columns.filter(c => !c.hidden); + let forward = + event.key == (document.dir === "rtl" ? "ArrowLeft" : "ArrowRight"); + + // Bail out if the user is trying to shift backward the first column, or + // shift forward the last column. + if ( + (!forward && visibleColumns.at(0)?.id == column.id) || + (forward && visibleColumns.at(-1)?.id == column.id) + ) { + return; + } + + event.preventDefault(); + this.dispatchEvent( + new CustomEvent("shift-column", { + bubbles: true, + detail: { + column: column.id, + forward, + }, + }) + ); + } + + #onDragStart(event) { + if (!event.target.closest("th[draggable]")) { + // This shouldn't be necessary, but is?! + event.preventDefault(); + return; + } + + const orderable = this.#orderableChildren; + if (orderable.length < 2) { + return; + } + + const headerCell = orderable.find(th => th.contains(event.target)); + const rect = headerCell.getBoundingClientRect(); + + this._dragInfo = { + cell: headerCell, + // How far can we move `headerCell` horizontally. + min: orderable.at(0).getBoundingClientRect().left - rect.left, + max: orderable.at(-1).getBoundingClientRect().right - rect.right, + // Where is the drag event starting. + startX: event.clientX, + offsetX: event.clientX - rect.left, + }; + + headerCell.classList.add("column-dragging"); + // Prevent `headerCell` 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); + } + + #onDragOver(event) { + if (!this._dragInfo) { + return; + } + + const { cell, min, max, startX, offsetX } = this._dragInfo; + // Move `cell` with the mouse pointer. + let dragX = Math.min(max, Math.max(min, event.clientX - startX)); + cell.style.transform = `translateX(${dragX}px)`; + + let thisRect = this.getBoundingClientRect(); + + // How much space is there before the `cell`? We'll see how many cells fit + // in the space and put the `cell` in after them. + let spaceBefore = Math.max( + 0, + event.clientX + this.scrollLeft - offsetX - thisRect.left + ); + // The width of all cells seen in the loop so far. + let totalWidth = 0; + // If we've looped past the cell being dragged. + let afterDraggedTh = false; + // The cell before where a drop would take place. If null, drop would + // happen at the start of the table header. + let header = null; + + for (let headerCell of this.#orderableChildren) { + if (headerCell == cell) { + afterDraggedTh = true; + continue; + } + + let rect = headerCell.getBoundingClientRect(); + let enoughSpace = spaceBefore > totalWidth + rect.width / 2; + + let multiplier = 0; + if (enoughSpace) { + if (afterDraggedTh) { + multiplier = -1; + } + header = headerCell; + } else if (!afterDraggedTh) { + multiplier = 1; + } + TreeViewTableHeader._transitionTranslation( + headerCell, + multiplier * cell.clientWidth + ); + + totalWidth += rect.width; + } + + this._dragInfo.dropTarget = header; + + event.preventDefault(); + } + + #onDragEnd() { + if (!this._dragInfo) { + return; + } + + this._dragInfo.cell.classList.remove("column-dragging"); + delete this._dragInfo; + + for (let headerCell of this.#orderableChildren) { + headerCell.style.transform = null; + headerCell.style.transition = null; + } + } + + #onDrop(event) { + if (!this._dragInfo) { + return; + } + + let { cell, startX, dropTarget } = this._dragInfo; + + let newColumns = this.parentNode.columns.map(column => ({ ...column })); + + const draggedColumn = newColumns.find(c => c.id == cell.id); + const initialPosition = newColumns.indexOf(draggedColumn); + + let targetCell; + let newPosition; + if (!dropTarget) { + // Get the first visible cell. + targetCell = this.querySelector("th:not([hidden])"); + newPosition = newColumns.indexOf( + newColumns.find(c => c.id == targetCell.id) + ); + } else { + // Get the next non hidden sibling. + targetCell = dropTarget.nextElementSibling; + while (targetCell.hidden) { + targetCell = targetCell.nextElementSibling; + } + newPosition = newColumns.indexOf( + newColumns.find(c => c.id == targetCell.id) + ); + } + + // Reduce the new position index if we're moving forward in order to get the + // accurate index position of the column we're taking the position of. + if (event.clientX > startX) { + newPosition -= 1; + } + + newColumns.splice(newPosition, 0, newColumns.splice(initialPosition, 1)[0]); + + // Update the ordinal of the columns to reflect the new positions. + newColumns.forEach((column, index) => { + column.ordinal = index; + }); + + this.querySelector("tr").insertBefore(cell, targetCell); + + this.dispatchEvent( + new CustomEvent("reorder-columns", { + bubbles: true, + detail: { + columns: newColumns, + }, + }) + ); + event.preventDefault(); + } + + /** + * Create all the table header cells based on the currently set columns. + */ + setColumns() { + this.row.replaceChildren(); + + for (let column of this.parentNode.columns) { + /** @type {TreeViewTableHeaderCell} */ + let cell = document.createElement("th", { + is: "tree-view-table-header-cell", + }); + this.row.appendChild(cell); + cell.setColumn(column); + } + + // Create a column picker if the table is editable. + if (this.parentNode.editable) { + const picker = document.createElement("th", { + is: "tree-view-table-column-picker", + }); + this.row.appendChild(picker); + } + + this.updateRovingTab(); + } + + /** + * Get all currently visible columns of the table header. + * + * @returns {Array} An array of buttons. + */ + get headerColumns() { + return this.row.querySelectorAll(`th:not([hidden]) button`); + } + + /** + * Update the `tabindex` attribute of the currently visible columns. + */ + updateRovingTab() { + for (let button of this.headerColumns) { + button.tabIndex = -1; + } + // Allow focus on the first available button. + this.headerColumns[0].tabIndex = 0; + } + + /** + * Handles the keypress event on the table header. + * + * @param {Event} event - The keypress DOMEvent. + */ + triggerTableHeaderRovingTab(event) { + if (!["ArrowRight", "ArrowLeft"].includes(event.key)) { + return; + } + + const headerColumns = [...this.headerColumns]; + let focusableButton = headerColumns.find(b => b.tabIndex != -1); + let elementIndex = headerColumns.indexOf(focusableButton); + + // Find the adjacent focusable element based on the pressed key. + let isRTL = document.dir == "rtl"; + if ( + (isRTL && event.key == "ArrowLeft") || + (!isRTL && event.key == "ArrowRight") + ) { + elementIndex++; + if (elementIndex > headerColumns.length - 1) { + elementIndex = 0; + } + } else if ( + (!isRTL && event.key == "ArrowLeft") || + (isRTL && event.key == "ArrowRight") + ) { + elementIndex--; + if (elementIndex == -1) { + elementIndex = headerColumns.length - 1; + } + } + + // Move the focus to a new column and update the tabindex attribute. + let newFocusableButton = headerColumns[elementIndex]; + if (newFocusableButton) { + focusableButton.tabIndex = -1; + newFocusableButton.tabIndex = 0; + newFocusableButton.focus(); + } + } +} +customElements.define("tree-view-table-header", TreeViewTableHeader, { + extends: "thead", +}); + +/** + * Class to generated the TH elements for the TreeViewTableHeader. + */ +class TreeViewTableHeaderCell extends HTMLTableCellElement { + /** + * The div needed to handle the header button in an absolute position. + * @type {HTMLElement} + */ + #container; + + /** + * The clickable button to change the sorting of the table. + * @type {HTMLButtonElement} + */ + #button; + + /** + * If this cell is resizable. + * @type {boolean} + */ + #resizable = true; + + /** + * If this cell can be clicked to affect the sorting order of the tree. + * @type {boolean} + */ + #sortable = true; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "tree-view-table-header-cell"); + this.draggable = true; + + this.#container = document.createElement("div"); + this.#container.classList.add( + "tree-table-cell", + "tree-table-cell-container" + ); + + this.#button = document.createElement("button"); + this.#container.appendChild(this.#button); + this.appendChild(this.#container); + } + + /** + * Set the proper data to the newly generated table header cell and create + * the needed child elements. + * + * @param {object} column - The column object with all the data to generate + * the correct header cell. + */ + setColumn(column) { + // Set a public ID so parent elements can loop through the available + // columns after they're created. + this.id = column.id; + this.#button.id = `${column.id}Button`; + + // Add custom classes if needed. + if (column.classes) { + this.#button.classList.add(...column.classes); + } + + if (column.l10n?.header) { + document.l10n.setAttributes(this.#button, column.l10n.header); + } + + // Add an image if this is a table header that needs to display an icon, + // and set the column as icon. + if (column.icon) { + this.dataset.type = "icon"; + const img = document.createElement("img"); + img.src = ""; + img.alt = ""; + this.#button.appendChild(img); + } + + this.resizable = column.resizable ?? true; + + this.hidden = column.hidden; + + this.#sortable = column.sortable ?? true; + // Make the button clickable if the column can trigger a sorting of rows. + if (this.#sortable) { + this.#button.addEventListener("click", () => { + this.dispatchEvent( + new CustomEvent("sort-changed", { + bubbles: true, + detail: { + column: column.id, + }, + }) + ); + }); + } + + this.#button.addEventListener("contextmenu", event => { + event.stopPropagation(); + const table = this.closest("table"); + if (table.editable) { + table + .querySelector("#columnPickerMenuPopup") + .openPopup(event.target, { triggerEvent: event }); + } + }); + + // This is the column handling the thread toggling. + if (column.thread) { + this.#button.classList.add("tree-view-header-thread"); + this.#button.addEventListener("click", () => { + this.dispatchEvent( + new CustomEvent("thread-changed", { + bubbles: true, + }) + ); + }); + } + + // This is the column handling bulk selection. + if (column.select) { + this.#button.classList.add("tree-view-header-select"); + this.#button.addEventListener("click", () => { + this.closest("tree-view").toggleSelectAll(); + }); + } + + // This is the column handling delete actions. + if (column.delete) { + this.#button.classList.add("tree-view-header-delete"); + } + } + + /** + * Set this table header as responsible for the sorting of rows. + * + * @param {string["ascending"|"descending"]} direction - The new sorting + * direction. + */ + setSorting(direction) { + this.#button.classList.add("sorting", direction); + } + + /** + * If this current column can be resized. + * + * @type {boolean} + */ + set resizable(val) { + this.#resizable = val; + this.dataset.resizable = val; + + let splitter = this.querySelector("hr"); + + // Add a splitter if we don't have one already. + if (!splitter) { + splitter = document.createElement("hr", { is: "pane-splitter" }); + splitter.setAttribute("is", "pane-splitter"); + this.appendChild(splitter); + splitter.resizeDirection = "horizontal"; + splitter.resizeElement = this; + splitter.id = `${this.id}Splitter`; + // Emit a custom event after a resize action. Methods at implementation + // level should listen to this event if the edited column size needs to + // be stored or used. + splitter.addEventListener("splitter-resized", () => { + this.dispatchEvent( + new CustomEvent("column-resized", { + bubbles: true, + detail: { + splitter, + column: this.id, + }, + }) + ); + }); + } + + this.style.setProperty("width", val ? `var(--${splitter.id}-width)` : null); + // Disable the splitter if this is not a resizable column. + splitter.isDisabled = !val; + } + + get resizable() { + return this.#resizable; + } + + /** + * If the current column can trigger a sorting of rows. + * + * @type {boolean} + */ + set sortable(val) { + this.#sortable = val; + this.#button.disabled = !val; + } + + get sortable() { + return this.#sortable; + } +} +customElements.define("tree-view-table-header-cell", TreeViewTableHeaderCell, { + extends: "th", +}); + +/** + * Class used to generate a column picker used for the TreeViewTableHeader in + * case the visibility of the columns of a table can be changed. + * + * Include treeView.ftl for strings. + */ +class TreeViewTableColumnPicker extends HTMLTableCellElement { + /** + * The clickable button triggering the picker context menu. + * @type {HTMLButtonElement} + */ + #button; + + /** + * The menupopup allowing users to show and hide columns. + * @type {XULElement} + */ + #context; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.setAttribute("is", "tree-view-table-column-picker"); + this.classList.add("tree-table-cell-container"); + + this.#button = document.createElement("button"); + document.l10n.setAttributes(this.#button, "tree-list-view-column-picker"); + this.#button.classList.add("button-flat", "button-column-picker"); + this.appendChild(this.#button); + + const img = document.createElement("img"); + img.src = ""; + img.alt = ""; + this.#button.appendChild(img); + + this.#context = document.createXULElement("menupopup"); + this.#context.id = "columnPickerMenuPopup"; + this.#context.setAttribute("position", "bottomleft topleft"); + this.appendChild(this.#context); + this.#context.addEventListener("popupshowing", event => { + // Bail out if we're opening a submenu. + if (event.target.id != this.#context.id) { + return; + } + + if (!this.#context.hasChildNodes()) { + this.#initPopup(); + } + + let columns = this.closest("table").columns; + for (let column of columns) { + let item = this.#context.querySelector(`[value="${column.id}"]`); + if (!item) { + continue; + } + + if (!column.hidden) { + item.setAttribute("checked", "true"); + continue; + } + + item.removeAttribute("checked"); + } + }); + + this.#button.addEventListener("click", event => { + this.#context.openPopup(event.target, { triggerEvent: event }); + }); + } + + /** + * Add all toggable columns to the context menu popup of the picker button. + */ + #initPopup() { + let table = this.closest("table"); + let columns = table.columns; + let items = new DocumentFragment(); + for (let column of columns) { + // Skip those columns we don't want to allow hiding. + if (column.picker === false) { + continue; + } + + let menuitem = document.createXULElement("menuitem"); + items.append(menuitem); + menuitem.setAttribute("type", "checkbox"); + menuitem.setAttribute("name", "toggle"); + menuitem.setAttribute("value", column.id); + menuitem.setAttribute("closemenu", "none"); + if (column.l10n?.menuitem) { + document.l10n.setAttributes(menuitem, column.l10n.menuitem); + } + + menuitem.addEventListener("command", () => { + this.dispatchEvent( + new CustomEvent("columns-changed", { + bubbles: true, + detail: { + target: menuitem, + value: column.id, + }, + }) + ); + }); + } + + items.append(document.createXULElement("menuseparator")); + let restoreItem = document.createXULElement("menuitem"); + restoreItem.id = "restoreColumnOrder"; + restoreItem.addEventListener("command", () => { + this.dispatchEvent( + new CustomEvent("restore-columns", { + bubbles: true, + }) + ); + }); + document.l10n.setAttributes( + restoreItem, + "tree-list-view-column-picker-restore" + ); + items.append(restoreItem); + + for (const templateID of table.popupMenuTemplates) { + items.append(document.getElementById(templateID).content.cloneNode(true)); + } + + this.#context.replaceChildren(items); + } +} +customElements.define( + "tree-view-table-column-picker", + TreeViewTableColumnPicker, + { extends: "th" } +); + +/** + * A more powerful list designed to be used with a view (nsITreeView or + * whatever replaces it in time) and be scalable to a very large number of + * items if necessary. Multiple selections are possible and changes in the + * connected view are cause updates to the list (provided `rowCountChanged`/ + * `invalidate` are called as appropriate). + * + * Rows are provided by a custom element that inherits from + * TreeViewTableRow below. Set the name of the custom element as the "rows" + * attribute. + * + * Include tree-listbox.css for appropriate styling. + */ +class TreeViewTableBody extends HTMLTableSectionElement { + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.tabIndex = 0; + this.setAttribute("is", "tree-view-table-body"); + this.setAttribute("role", "tree"); + this.setAttribute("aria-multiselectable", "true"); + + let treeView = this.closest("tree-view"); + this.addEventListener("keyup", treeView); + this.addEventListener("click", treeView); + this.addEventListener("keydown", treeView); + + if (treeView.dataset.labelId) { + this.setAttribute("aria-labelledby", treeView.dataset.labelId); + } + } +} +customElements.define("tree-view-table-body", TreeViewTableBody, { + extends: "tbody", +}); + +/** + * Base class for rows in a TreeViewTableBody. Rows have a fixed height and + * their position on screen is managed by the owning list. + * + * Sub-classes should override ROW_HEIGHT, styles, and fragment to suit the + * intended layout. The index getter/setter should be overridden to fill the + * layout with values. + */ +class TreeViewTableRow extends HTMLTableRowElement { + /** + * Fixed height of this row. Rows in the list will be spaced this far + * apart. This value must not change at runtime. + * + * @type {integer} + */ + static ROW_HEIGHT = 50; + + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.tabIndex = -1; + this.list = this.closest("tree-view"); + this.view = this.list.view; + this.setAttribute("aria-selected", !!this.selected); + } + + /** + * The 0-based position of this row in the list. Override this setter to + * fill layout based on values from the list's view. Always call back to + * this class's getter/setter when inheriting. + * + * @note Don't short-circuit the setter if the given index is equal to the + * existing index. Rows can be reused to display new data at the same index. + * + * @type {integer} + */ + get index() { + return this._index; + } + + set index(index) { + this.setAttribute( + "role", + this.list.table.body.getAttribute("role") === "tree" + ? "treeitem" + : "option" + ); + this.setAttribute("aria-posinset", index + 1); + this.id = `${this.list.id}-row${index}`; + + const isGroup = this.view.isContainer(index); + this.classList.toggle("children", isGroup); + + const isGroupOpen = this.view.isContainerOpen(index); + if (isGroup) { + this.setAttribute("aria-expanded", isGroupOpen); + } else { + this.removeAttribute("aria-expanded"); + } + this.classList.toggle("collapsed", !isGroupOpen); + this._index = index; + + let table = this.closest("table"); + for (let column of table.columns) { + let cell = this.querySelector(`.${column.id.toLowerCase()}-column`); + // No need to do anything if this cell doesn't exist. This can happen + // for non-table layouts. + if (!cell) { + continue; + } + + // Always clear the colspan when updating the columns. + cell.removeAttribute("colspan"); + + // No need to do anything if this column is hidden. + if (cell.hidden) { + continue; + } + + // Handle the special case for the selectable checkbox column. + if (column.select) { + let img = cell.firstElementChild; + if (!img) { + cell.classList.add("tree-view-row-select"); + img = document.createElement("img"); + img.src = ""; + img.tabIndex = -1; + img.classList.add("tree-view-row-select-checkbox"); + cell.replaceChildren(img); + } + document.l10n.setAttributes( + img, + this.list._selection.isSelected(index) + ? "tree-list-view-row-deselect" + : "tree-list-view-row-select" + ); + continue; + } + + // No need to do anything if an earlier call to this function already + // added the cell contents. + if (cell.firstElementChild) { + continue; + } + } + + // Account for the column picker in the last visible column if the table + // if editable. + if (table.editable) { + let last = table.columns.filter(c => !c.hidden).pop(); + this.querySelector(`.${last.id.toLowerCase()}-column`)?.setAttribute( + "colspan", + "2" + ); + } + } + + /** + * Tracks the selection state of the current row. + * + * @type {boolean} + */ + get selected() { + return this.classList.contains("selected"); + } + + set selected(selected) { + this.setAttribute("aria-selected", !!selected); + this.classList.toggle("selected", !!selected); + } +} +customElements.define("tree-view-table-row", TreeViewTableRow, { + extends: "tr", +}); + +/** + * Simple tbody spacer used above and below the main tbody for space + * allocation and ensuring the correct scrollable height. + */ +class TreeViewTableSpacer extends HTMLTableSectionElement { + connectedCallback() { + if (this.hasConnected) { + return; + } + this.hasConnected = true; + + this.cell = document.createElement("td"); + const row = document.createElement("tr"); + row.appendChild(this.cell); + this.appendChild(row); + } + + /** + * Set the cell colspan to reflect the number of visible columns in order + * to generate a correct HTML markup. + * + * @param {int} count - The columns count. + */ + setColspan(count) { + this.cell.setAttribute("colspan", count); + } + + /** + * Set the height of the cell in order to occupy the empty area that will + * be filled by new rows on demand when needed. + * + * @param {int} val - The pixel height the row should occupy. + */ + setHeight(val) { + this.cell.style.height = `${val}px`; + } +} +customElements.define("tree-view-table-spacer", TreeViewTableSpacer, { + extends: "tbody", +}); |