summaryrefslogtreecommitdiffstats
path: root/comm/mail/base/content/widgets/tree-view.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/base/content/widgets/tree-view.mjs')
-rw-r--r--comm/mail/base/content/widgets/tree-view.mjs2633
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",
+});