summaryrefslogtreecommitdiffstats
path: root/comm/mail/base/test/browser/browser_treeView.js
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/base/test/browser/browser_treeView.js')
-rw-r--r--comm/mail/base/test/browser/browser_treeView.js1941
1 files changed, 1941 insertions, 0 deletions
diff --git a/comm/mail/base/test/browser/browser_treeView.js b/comm/mail/base/test/browser/browser_treeView.js
new file mode 100644
index 0000000000..bf1eeda35a
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_treeView.js
@@ -0,0 +1,1941 @@
+/* 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/. */
+
+let tabmail = document.getElementById("tabmail");
+registerCleanupFunction(() => {
+ tabmail.closeOtherTabs(tabmail.tabInfo[0]);
+});
+
+// We wish to run several variants of each test with minor differences, based on
+// changes in the source file, in order to verify that certain variables don't
+// impact behavior.
+const TEST_VARIANTS = ["header", "no-header"];
+
+/**
+ * Run a given test in a new tab and script sandbox.
+ *
+ * @param {Function} test - The test function to run in the sandbox.
+ * @param {string} filenameFragment - The fragment of the filename representing
+ * the test variant to run.
+ * @param {object[]} sandboxArgs - Arguments to the sandbox spawner to pass to
+ * the test function.
+ */
+async function runTestInSandbox(test, filenameFragment, sandboxArgs = []) {
+ // Create a new tab with our custom content.
+ const tab = tabmail.openTab("contentTab", {
+ url: `chrome://mochitests/content/browser/comm/mail/base/test/browser/files/tree-element-test-${filenameFragment}.xhtml`,
+ });
+
+ await BrowserTestUtils.browserLoaded(tab.browser);
+ tab.browser.focus();
+
+ // Spawn a new JavaScript sandbox for the tab and run the test function inside
+ // of it.
+ await SpecialPowers.spawn(tab.browser, sandboxArgs, test);
+
+ tabmail.closeTab(tab);
+}
+
+/**
+ * Checks that interactions with the widget do as expected.
+ */
+add_task(async function testKeyboardAndMouse() {
+ for (const variant of TEST_VARIANTS) {
+ info(`Running keyboard and mouse test for ${variant}`);
+ await runTestInSandbox(subtestKeyboardAndMouse, variant, [variant]);
+ }
+});
+
+async function subtestKeyboardAndMouse(variant) {
+ let doc = content.document;
+
+ let list = doc.getElementById("testTree");
+ Assert.ok(!!list, "the list exists");
+
+ async function doListActionAndWaitForRowBuffer(actionFn) {
+ // Filling the row buffer is fiddly timing, so provide an event to indicate
+ // that actions which may trigger changes in the row buffer have finished.
+ const eventName = "_treerowbufferfill";
+ list._rowBufferReadyEvent = new content.CustomEvent(eventName);
+
+ const promise = new Promise(resolve =>
+ list.addEventListener(eventName, resolve, { once: true })
+ );
+
+ await actionFn();
+ await promise;
+
+ list._rowBufferReadyEvent = null;
+ }
+
+ async function scrollListToPosition(topOfScroll) {
+ await doListActionAndWaitForRowBuffer(() => {
+ list.scrollTo(0, topOfScroll);
+ });
+ }
+
+ Assert.equal(list._rowElementName, "test-row");
+ Assert.equal(list._rowElementClass, content.customElements.get("test-row"));
+ Assert.equal(
+ list._toleranceSize,
+ 26,
+ "list should have tolerance twice the number of visible rows"
+ );
+
+ // We should be scrolled to the top already, but this will ensure we get an
+ // event fire one way or another.
+ await scrollListToPosition(0);
+
+ let rows = list.querySelectorAll(`tr[is="test-row"]`);
+ // Count is calculated from the height of `list` divided by
+ // TestCardRow.ROW_HEIGHT, plus list._toleranceSize.
+ Assert.equal(rows.length, 13 + 26, "the list has the right number of rows");
+
+ Assert.equal(doc.activeElement, doc.body);
+
+ // Verify the tab order of list elements by tabbing both forward and backward
+ // through them.
+ EventUtils.synthesizeKey("VK_TAB", {}, content);
+ Assert.equal(
+ doc.activeElement.id,
+ "before",
+ "the element before the list should have focus"
+ );
+
+ if (variant == "header") {
+ // Tab order changes slightly if the table has a header versus if it
+ // doesn't, so we need to account for that variation.
+ EventUtils.synthesizeKey("VK_TAB", {}, content);
+ Assert.equal(
+ doc.activeElement.id,
+ "testColButton",
+ "the list header button should have focus"
+ );
+ }
+
+ EventUtils.synthesizeKey("VK_TAB", {}, content);
+ Assert.equal(doc.activeElement.id, "testBody", "the list should have focus");
+
+ EventUtils.synthesizeKey("VK_TAB", {}, content);
+ Assert.equal(
+ doc.activeElement.id,
+ "after",
+ "the element after the list should have focus"
+ );
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, content);
+ Assert.equal(doc.activeElement.id, "testBody", "the list should have focus");
+
+ if (variant == "header") {
+ // Tab order changes slightly if the table has a header versus if it
+ // doesn't, so we need to account for that variation.
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, content);
+ Assert.equal(
+ doc.activeElement.id,
+ "testColButton",
+ "the list header button should have focus"
+ );
+ }
+
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }, content);
+ Assert.equal(
+ doc.activeElement.id,
+ "before",
+ "the element before the list should have focus"
+ );
+
+ // Check initial selection.
+
+ let selectHandler = {
+ seenEvent: null,
+ currentAtEvent: null,
+ selectedAtEvent: null,
+ t0: Date.now(),
+ time: 0,
+
+ reset() {
+ this.seenEvent = null;
+ this.currentAtEvent = null;
+ this.selectedAtEvent = null;
+ this.t0 = Date.now();
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.currentAtEvent = list.currentIndex;
+ this.selectedAtEvent = list.selectedIndices;
+ this.time = Date.now() - this.t0;
+ },
+ };
+
+ /**
+ * Check if the spacerTop TBODY of the TreeViewTable is properly allocating
+ * the height of non existing rows.
+ *
+ * @param {int} rows - The number of rows that the spacerTop should be
+ * simulating their height allocation.
+ */
+ function checkTopSpacerHeight(rows) {
+ let table = doc.querySelector(`[is="tree-view-table"]`);
+ // -26 to account for the tolerance buffer.
+ Assert.equal(
+ table.spacerTop.clientHeight,
+ list.getRowAtIndex(rows).clientHeight * (rows - 26),
+ "The top spacer has the correct height"
+ );
+ }
+
+ function checkCurrent(expectedIndex) {
+ Assert.equal(list.currentIndex, expectedIndex, "currentIndex is correct");
+ if (selectHandler.currentAtEvent !== null) {
+ Assert.equal(
+ selectHandler.currentAtEvent,
+ expectedIndex,
+ "currentIndex was correct at the last 'select' event"
+ );
+ }
+
+ let current = list.querySelectorAll(".current");
+ if (expectedIndex == -1) {
+ Assert.equal(current.length, 0, "no rows have the 'current' class");
+ } else {
+ Assert.equal(current.length, 1, "only one row has the 'current' class");
+ Assert.equal(
+ current[0].index,
+ expectedIndex,
+ "correct row has the 'current' class"
+ );
+ }
+ }
+
+ function checkSelected(...expectedIndices) {
+ Assert.deepEqual(
+ list.selectedIndices,
+ expectedIndices,
+ "selectedIndices are correct"
+ );
+
+ if (selectHandler.selectedAtEvent !== null) {
+ // Check the value was already set when the select event fired.
+ Assert.deepEqual(
+ selectHandler.selectedAtEvent,
+ expectedIndices,
+ "selectedIndices were correct at the last 'select' event"
+ );
+ }
+
+ let selected = [...list.querySelectorAll(".selected")].map(
+ row => row.index
+ );
+ expectedIndices.sort((a, b) => a - b);
+ Assert.deepEqual(
+ selected,
+ expectedIndices,
+ "correct rows have the 'selected' class"
+ );
+ }
+
+ checkCurrent(0);
+ checkSelected(0);
+
+ // Click on some individual rows.
+
+ const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+ );
+ const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+ );
+
+ async function clickOnRow(index, modifiers = {}, expectEvent = true) {
+ if (modifiers.shiftKey) {
+ info(`clicking on row ${index} with shift key`);
+ } else if (modifiers.accelKey) {
+ info(`clicking on row ${index} with ctrl key`);
+ } else {
+ info(`clicking on row ${index}`);
+ }
+
+ let x = list.clientWidth / 2;
+ let y = list.table.header.clientHeight + index * 50 + 25;
+
+ selectHandler.reset();
+ list.addEventListener("select", selectHandler, { once: true });
+ EventUtils.synthesizeMouse(list, x, y, modifiers, content);
+ await TestUtils.waitForCondition(
+ () => !!selectHandler.seenEvent == expectEvent,
+ `'select' event should ${expectEvent ? "" : "not "}get fired`
+ );
+ }
+
+ await clickOnRow(0, {}, false);
+ checkCurrent(0);
+ checkSelected(0);
+
+ await clickOnRow(1);
+ checkCurrent(1);
+ checkSelected(1);
+
+ await clickOnRow(2);
+ checkCurrent(2);
+ checkSelected(2);
+
+ // Select multiple rows by shift-clicking.
+
+ await clickOnRow(4, { shiftKey: true });
+ checkCurrent(4);
+ checkSelected(2, 3, 4);
+
+ // Holding ctrl and shift should always produce a range selection.
+ await clickOnRow(6, { accelKey: true, shiftKey: true });
+ checkCurrent(6);
+ checkSelected(2, 3, 4, 5, 6);
+
+ await clickOnRow(0, { shiftKey: true });
+ checkCurrent(0);
+ checkSelected(0, 1, 2);
+
+ await clickOnRow(2, { shiftKey: true });
+ checkCurrent(2);
+ checkSelected(2);
+
+ // Select multiple rows by ctrl-clicking.
+
+ await clickOnRow(5, { accelKey: true });
+ checkCurrent(5);
+ checkSelected(2, 5);
+
+ await clickOnRow(1, { accelKey: true });
+ checkCurrent(1);
+ checkSelected(1, 2, 5);
+
+ await clickOnRow(5, { accelKey: true });
+ checkCurrent(5);
+ checkSelected(1, 2);
+
+ await clickOnRow(1, { accelKey: true });
+ checkCurrent(1);
+ checkSelected(2);
+
+ await clickOnRow(2, { accelKey: true });
+ checkCurrent(2);
+ checkSelected();
+
+ // Move around by pressing keys.
+
+ async function pressKey(key, modifiers = {}, expectEvent = true) {
+ if (modifiers.shiftKey) {
+ info(`pressing ${key} with shift key`);
+ } else if (modifiers.accelKey) {
+ info(`pressing ${key} with accel key`);
+ } else {
+ info(`pressing ${key}`);
+ }
+
+ selectHandler.reset();
+ list.addEventListener("select", selectHandler, { once: true });
+ EventUtils.synthesizeKey(key, modifiers, content);
+ await TestUtils.waitForCondition(
+ () => !!selectHandler.seenEvent == expectEvent,
+ `'select' event should ${expectEvent ? "" : "not "}get fired`
+ );
+ // We don't enforce any delay on multiselection.
+ let multiselect =
+ (AppConstants.platform == "macosx" && key == " ") ||
+ modifiers.shiftKey ||
+ modifiers.accelKey;
+ if (expectEvent && !multiselect) {
+ // We have data-select-delay="250" in treeView.xhtml
+ Assert.greater(selectHandler.time, 240, "should select only after delay");
+ }
+ }
+
+ await pressKey("VK_UP");
+ checkCurrent(1);
+ checkSelected(1);
+
+ await pressKey("VK_UP", { accelKey: true }, false);
+ checkCurrent(0);
+ checkSelected(1);
+
+ // Without Ctrl selection moves with focus again.
+ await pressKey("VK_UP");
+ checkCurrent(0);
+ checkSelected(0);
+
+ // Does nothing.
+ await pressKey("VK_UP", {}, false);
+ checkCurrent(0);
+ checkSelected(0);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(1);
+ checkSelected(0);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(2);
+ checkSelected(0);
+
+ // Multi select with only Space on macOS on a focused row, since Cmd+Space is
+ // captured by the OS.
+ if (AppConstants.platform == "macosx") {
+ await pressKey(" ");
+ } else {
+ // Multi select with Ctrl+Space for Windows and Linux.
+ await pressKey(" ", { accelKey: true });
+ }
+ checkCurrent(2);
+ checkSelected(0, 2);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(3);
+ checkSelected(0, 2);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(4);
+ checkSelected(0, 2);
+
+ if (AppConstants.platform == "macosx") {
+ await pressKey(" ");
+ } else {
+ await pressKey(" ", { accelKey: true });
+ }
+ checkCurrent(4);
+ checkSelected(0, 2, 4);
+
+ // Single selection restored with normal navigation.
+ await pressKey("VK_UP");
+ checkCurrent(3);
+ checkSelected(3);
+
+ // We don't allow unselecting a selected row with Space on macOS due to
+ // conflict with the `mail.advance_on_spacebar` pref.
+ if (AppConstants.platform != "macosx") {
+ // Can select none using Ctrl+Space.
+ await pressKey(" ", { accelKey: true });
+ checkCurrent(3);
+ checkSelected();
+ }
+
+ await pressKey("VK_DOWN");
+ checkCurrent(4);
+ checkSelected(4);
+
+ await pressKey("VK_HOME", { accelKey: true }, false);
+ checkCurrent(0);
+ checkSelected(4);
+
+ if (AppConstants.platform == "macosx") {
+ // We can't clear the selection with only Space on macOS, simulate a Arrow
+ // Up to force clear selection and only select the top most row.
+ await pressKey("VK_UP");
+ } else {
+ // Select only the current item with Space (no modifier).
+ await pressKey(" ");
+ }
+ checkCurrent(0);
+ checkSelected(0);
+
+ // The list is 630px high, so rows 0-12 are fully or partly visible.
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_PAGE_DOWN");
+ });
+ checkCurrent(13);
+ checkSelected(13);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 1,
+ "should have scrolled down a page"
+ );
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_PAGE_UP", { shiftKey: true });
+ });
+ checkCurrent(0);
+ checkSelected(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 0,
+ "should have scrolled up a page"
+ );
+
+ // Shrink shift selection.
+ await pressKey("VK_DOWN", { shiftKey: true });
+ checkCurrent(1);
+ checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(2);
+ checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13);
+
+ await pressKey("VK_DOWN", { accelKey: true }, false);
+ checkCurrent(3);
+ checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13);
+
+ if (AppConstants.platform == "macosx") {
+ // We don't allow unselecting a selected row with Space on macOS due to
+ // conflict with the `mail.advance_on_spacebar` pref, so simulate a
+ // CMD+click to unselect it.
+ await clickOnRow(3, { accelKey: true });
+ } else {
+ // Break the shift sequence by Ctrl+Space.
+ await pressKey(" ", { accelKey: true });
+ }
+ checkCurrent(3);
+ checkSelected(1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13);
+
+ await pressKey("VK_DOWN", { shiftKey: true });
+ checkCurrent(4);
+ checkSelected(3, 4);
+
+ // Reverse selection direction.
+ await pressKey("VK_HOME", { shiftKey: true });
+ checkCurrent(0);
+ checkSelected(0, 1, 2, 3);
+
+ // Now rows 138-149 are fully visible.
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_END");
+ });
+ checkCurrent(149);
+ checkSelected(149);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 137,
+ "should have scrolled to the end"
+ );
+ checkTopSpacerHeight(137);
+
+ // Does nothing.
+ await pressKey("VK_DOWN", {}, false);
+ checkCurrent(149);
+ checkSelected(149);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 137,
+ "should not have changed view"
+ );
+ checkTopSpacerHeight(137);
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_PAGE_UP");
+ });
+ checkCurrent(136);
+ checkSelected(136);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 136,
+ "should have scrolled up a page"
+ );
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_PAGE_DOWN", { shiftKey: true });
+ });
+ checkCurrent(149);
+ checkSelected(
+ 136,
+ 137,
+ 138,
+ 139,
+ 140,
+ 141,
+ 142,
+ 143,
+ 144,
+ 145,
+ 146,
+ 147,
+ 148,
+ 149
+ );
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 137,
+ "should have scrolled down a page"
+ );
+ checkTopSpacerHeight(137);
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_HOME");
+ });
+ checkCurrent(0);
+ checkSelected(0);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 0,
+ "should have scrolled to the beginning"
+ );
+
+ // Scroll around. Which rows are current and selected should be remembered
+ // even if the row element itself disappears.
+
+ selectHandler.reset();
+ await scrollListToPosition(125);
+ checkCurrent(0);
+ checkSelected(0);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 2,
+ "getFirstVisibleIndex is correct"
+ );
+
+ await scrollListToPosition(2525);
+ Assert.equal(list.currentIndex, 0, "currentIndex is still set");
+ Assert.ok(
+ !list.querySelector(".current"),
+ "no visible rows have the 'current' class"
+ );
+ Assert.deepEqual(list.selectedIndices, [0], "selectedIndices are still set");
+ Assert.ok(
+ !list.querySelector(".selected"),
+ "no visible rows have the 'selected' class"
+ );
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 50,
+ "getFirstVisibleIndex is correct"
+ );
+ Assert.ok(!selectHandler.seenEvent, "should not have fired 'select' event");
+ checkTopSpacerHeight(50);
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await pressKey("VK_DOWN");
+ });
+ checkCurrent(1);
+ checkSelected(1);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 1,
+ "should have scrolled so that the second row is in view"
+ );
+
+ selectHandler.reset();
+ await scrollListToPosition(0);
+ checkCurrent(1);
+ checkSelected(1);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 0,
+ "getFirstVisibleIndex is correct"
+ );
+ Assert.ok(
+ !selectHandler.seenEvent,
+ "'select' event did not fire as expected"
+ );
+
+ await pressKey("VK_UP");
+ checkCurrent(0);
+ checkSelected(0);
+ Assert.equal(
+ list.getFirstVisibleIndex(),
+ 0,
+ "should have scrolled so that the first row is in view"
+ );
+
+ // Some literal edge cases. Clicking on a partially visible row should
+ // scroll it into view.
+
+ // Calculate the visible area in order to verify that rows appear where they
+ // are intended to appear.
+ const listRect = list.getBoundingClientRect();
+ const headerHeight = list.table.header.clientHeight;
+
+ const visibleRect = new content.DOMRect(
+ listRect.x,
+ listRect.y + headerHeight,
+ listRect.width,
+ listRect.height - headerHeight
+ );
+
+ Assert.equal(visibleRect.height, 630, "the table body should be 630px tall");
+
+ rows = list.querySelectorAll(`tr[is="test-row"]`);
+ let bcr = rows[12].getBoundingClientRect();
+ Assert.less(
+ Math.round(bcr.top),
+ Math.round(visibleRect.bottom),
+ "top of row 12 is visible"
+ );
+ Assert.greater(
+ Math.round(bcr.bottom),
+ Math.round(visibleRect.bottom),
+ "bottom of row 12 is not visible"
+ );
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await clickOnRow(12);
+ });
+ rows = list.querySelectorAll(`tr[is="test-row"]`);
+ bcr = rows[12].getBoundingClientRect();
+ Assert.less(
+ Math.round(bcr.top),
+ Math.round(visibleRect.bottom),
+ "the top of row 12 should be visible"
+ );
+ Assert.equal(
+ Math.round(bcr.bottom),
+ Math.round(visibleRect.bottom),
+ "row 12 should be at the bottom of the visible area"
+ );
+
+ bcr = rows[0].getBoundingClientRect();
+ Assert.less(
+ Math.round(bcr.top),
+ Math.round(visibleRect.top),
+ "top of row 0 is not visible"
+ );
+ Assert.greater(
+ Math.round(bcr.bottom),
+ Math.round(visibleRect.top),
+ "bottom of row 0 is visible"
+ );
+
+ await doListActionAndWaitForRowBuffer(async () => {
+ await clickOnRow(0);
+ });
+ rows = list.querySelectorAll(`tr[is="test-row"]`);
+ bcr = rows[0].getBoundingClientRect();
+ Assert.equal(
+ Math.round(bcr.top),
+ Math.round(visibleRect.top),
+ "row 0 should be at the top of the visible area"
+ );
+ Assert.greater(
+ Math.round(bcr.bottom),
+ Math.round(visibleRect.top),
+ "the bottom of row 0 should be visible"
+ );
+}
+
+/**
+ * Checks that changes in the view are propagated to the list.
+ */
+add_task(async function testRowCountChange() {
+ for (const variant of TEST_VARIANTS) {
+ info(`Running row count change test for ${variant}`);
+ await runTestInSandbox(subtestRowCountChange, variant);
+ }
+});
+
+async function subtestRowCountChange() {
+ let doc = content.document;
+
+ let ROW_HEIGHT = 50;
+ let list = doc.getElementById("testTree");
+
+ async function doListActionAndWaitForRowBuffer(actionFn) {
+ // Filling the row buffer is fiddly timing, so provide an event to indicate
+ // that actions which may trigger changes in the row buffer have finished.
+ const eventName = "_treerowbufferfill";
+ list._rowBufferReadyEvent = new content.CustomEvent(eventName);
+
+ const promise = new Promise(resolve =>
+ list.addEventListener(eventName, resolve, { once: true })
+ );
+
+ await actionFn();
+ await promise;
+
+ list._rowBufferReadyEvent = null;
+ }
+
+ let view = list.view;
+ let rows;
+
+ // Check the initial state.
+
+ function checkRows(first, last) {
+ let expectedIndices = [];
+ for (let i = first; i <= last; i++) {
+ expectedIndices.push(i);
+ }
+ rows = list.querySelectorAll(`tr[is="test-row"]`);
+ Assert.deepEqual(
+ Array.from(rows, r => r.index),
+ expectedIndices,
+ "the list has the right rows"
+ );
+ Assert.deepEqual(
+ Array.from(rows, r => r.dataset.value),
+ view.values.slice(first, last + 1),
+ "the list has the right rows"
+ );
+ }
+
+ function checkSelected(indices, existingIndices) {
+ Assert.deepEqual(list.selectedIndices, indices);
+ let selectedRows = list.querySelectorAll(`tr[is="test-row"].selected`);
+ Assert.deepEqual(
+ Array.from(selectedRows, r => r.index),
+ existingIndices
+ );
+ }
+
+ let expectedCount = 150;
+
+ // Select every tenth row. We'll check what is selected remains selected.
+
+ list.selectedIndices = [4, 14, 24, 34, 44];
+
+ function getRowsHeight() {
+ return list.scrollHeight - list.table.header.clientHeight;
+ }
+
+ async function addValues(index, values) {
+ view.values.splice(index, 0, ...values);
+ info(`Added ${values.join(", ")} at ${index}`);
+ info(view.values);
+
+ expectedCount += values.length;
+ Assert.equal(
+ view.rowCount,
+ expectedCount,
+ "the view has the right number of rows"
+ );
+
+ await doListActionAndWaitForRowBuffer(() => {
+ list.rowCountChanged(index, values.length);
+ });
+
+ Assert.equal(
+ getRowsHeight(),
+ expectedCount * ROW_HEIGHT,
+ "space for all rows is allocated"
+ );
+ }
+
+ async function removeValues(index, count, expectedRemoved) {
+ let values = view.values.splice(index, count);
+ info(`Removed ${values.join(", ")} from ${index}`);
+ info(view.values);
+
+ Assert.deepEqual(values, expectedRemoved);
+
+ expectedCount -= values.length;
+ Assert.equal(
+ view.rowCount,
+ expectedCount,
+ "the view has the right number of rows"
+ );
+
+ await doListActionAndWaitForRowBuffer(() => {
+ list.rowCountChanged(index, -count);
+ });
+
+ Assert.equal(
+ getRowsHeight(),
+ expectedCount * ROW_HEIGHT,
+ "space for all rows is allocated"
+ );
+ }
+
+ async function scrollListToPosition(topOfScroll) {
+ await doListActionAndWaitForRowBuffer(() => {
+ list.scrollTo(0, topOfScroll);
+ });
+ }
+
+ Assert.equal(
+ view.rowCount,
+ expectedCount,
+ "the view has the right number of rows"
+ );
+ Assert.equal(list.scrollTop, 0, "the list is scrolled to the top");
+ Assert.equal(
+ getRowsHeight(),
+ expectedCount * ROW_HEIGHT,
+ "space for all rows is allocated"
+ );
+
+ // We should be scrolled to the top already, but this will ensure we get an
+ // event fire one way or another.
+ await scrollListToPosition(0);
+
+ checkRows(0, 38);
+ checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]);
+ Assert.equal(getRowsHeight(), 150 * 50);
+
+ // Add a value at the end. Only the scroll height should change.
+
+ await addValues(150, [150]);
+ checkRows(0, 38);
+ checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]);
+ Assert.equal(getRowsHeight(), 151 * 50);
+
+ // Add more values at the end. Only the scroll height should change.
+
+ await addValues(151, [151, 152, 153]);
+ checkRows(0, 38);
+ checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]);
+ Assert.equal(getRowsHeight(), 154 * 50);
+
+ // Add values between the last row and the end.
+ // Only the scroll height should change.
+
+ await addValues(40, ["39a", "39b"]);
+ checkRows(0, 38);
+ checkSelected([4, 14, 24, 34, 46], [4, 14, 24, 34]);
+ Assert.equal(getRowsHeight(), 156 * 50);
+
+ // Add values between the last visible row and the last row.
+ // The changed rows and those below them should be updated.
+
+ await addValues(18, ["17a", "17b", "17c"]);
+ checkRows(0, 38);
+ // Hard-coded sanity checks to prove checkRows is working as intended.
+ Assert.equal(rows[17].dataset.value, "17");
+ Assert.equal(rows[18].dataset.value, "17a");
+ Assert.equal(rows[19].dataset.value, "17b");
+ Assert.equal(rows[20].dataset.value, "17c");
+ Assert.equal(rows[21].dataset.value, "18");
+ checkSelected([4, 14, 27, 37, 49], [4, 14, 27, 37]);
+ Assert.equal(getRowsHeight(), 159 * 50);
+
+ // Add values in the visible rows.
+ // The changed rows and those below them should be updated.
+
+ await addValues(8, ["7a", "7b"]);
+ checkRows(0, 38);
+ Assert.equal(rows[7].dataset.value, "7");
+ Assert.equal(rows[8].dataset.value, "7a");
+ Assert.equal(rows[9].dataset.value, "7b");
+ Assert.equal(rows[10].dataset.value, "8");
+ Assert.equal(rows[22].dataset.value, "17c");
+ checkSelected([4, 16, 29, 39, 51], [4, 16, 29]);
+ Assert.equal(getRowsHeight(), 161 * 50);
+
+ // Add a value at the start. All rows should be updated.
+
+ await addValues(0, [-1]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-1");
+ Assert.equal(rows[1].dataset.value, "0");
+ Assert.equal(rows[22].dataset.value, "17b");
+ checkSelected([5, 17, 30, 40, 52], [5, 17, 30]);
+ Assert.equal(getRowsHeight(), 162 * 50);
+
+ // Add more values at the start. All rows should be updated.
+
+ await addValues(0, [-3, -2]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[1].dataset.value, "-2");
+ Assert.equal(rows[2].dataset.value, "-1");
+ Assert.equal(rows[22].dataset.value, "17");
+ checkSelected([7, 19, 32, 42, 54], [7, 19, 32]);
+ Assert.equal(getRowsHeight(), 164 * 50);
+
+ Assert.equal(list.scrollTop, 0, "the list is still scrolled to the top");
+
+ // Remove values in the order we added them.
+
+ await removeValues(160, 1, [150]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[22].dataset.value, "17");
+ checkSelected([7, 19, 32, 42, 54], [7, 19, 32]);
+ Assert.equal(getRowsHeight(), 163 * 50);
+
+ await removeValues(160, 3, [151, 152, 153]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[22].dataset.value, "17");
+ checkSelected([7, 19, 32, 42, 54], [7, 19, 32]);
+ Assert.equal(getRowsHeight(), 160 * 50);
+
+ await removeValues(48, 2, ["39a", "39b"]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[22].dataset.value, "17");
+ checkSelected([7, 19, 32, 42, 52], [7, 19, 32]);
+ Assert.equal(getRowsHeight(), 158 * 50);
+
+ await removeValues(23, 3, ["17a", "17b", "17c"]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[22].dataset.value, "17");
+ checkSelected([7, 19, 29, 39, 49], [7, 19, 29]);
+ Assert.equal(getRowsHeight(), 155 * 50);
+
+ await removeValues(11, 2, ["7a", "7b"]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[10].dataset.value, "7");
+ Assert.equal(rows[11].dataset.value, "8");
+ Assert.equal(rows[22].dataset.value, "19");
+ checkSelected([7, 17, 27, 37, 47], [7, 17, 27, 37]);
+ Assert.equal(getRowsHeight(), 153 * 50);
+
+ await removeValues(2, 1, [-1]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "-3");
+ Assert.equal(rows[1].dataset.value, "-2");
+ Assert.equal(rows[2].dataset.value, "0");
+ Assert.equal(rows[22].dataset.value, "20");
+ checkSelected([6, 16, 26, 36, 46], [6, 16, 26, 36]);
+ Assert.equal(getRowsHeight(), 152 * 50);
+
+ await removeValues(0, 2, [-3, -2]);
+ checkRows(0, 38);
+ Assert.equal(rows[0].dataset.value, "0");
+ Assert.equal(rows[1].dataset.value, "1");
+ Assert.equal(rows[22].dataset.value, "22");
+ checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]);
+ Assert.equal(getRowsHeight(), 150 * 50);
+
+ Assert.equal(list.scrollTop, 0, "the list is still scrolled to the top");
+
+ // Now scroll to the middle and repeat.
+
+ await scrollListToPosition(1735);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[65].dataset.value, "73");
+ checkSelected([4, 14, 24, 34, 44], [14, 24, 34, 44]);
+
+ await addValues(150, [150]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[65].dataset.value, "73");
+ checkSelected([4, 14, 24, 34, 44], [14, 24, 34, 44]);
+
+ await addValues(38, ["37a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[29].dataset.value, "37");
+ Assert.equal(rows[30].dataset.value, "37a");
+ Assert.equal(rows[31].dataset.value, "38");
+ Assert.equal(rows[65].dataset.value, "72");
+ checkSelected([4, 14, 24, 34, 45], [14, 24, 34, 45]);
+
+ await addValues(25, ["24a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[16].dataset.value, "24");
+ Assert.equal(rows[17].dataset.value, "24a");
+ Assert.equal(rows[18].dataset.value, "25");
+ Assert.equal(rows[65].dataset.value, "71");
+ checkSelected([4, 14, 24, 35, 46], [14, 24, 35, 46]);
+
+ await addValues(11, ["10a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[2].dataset.value, "10");
+ Assert.equal(rows[3].dataset.value, "10a");
+ Assert.equal(rows[4].dataset.value, "11");
+ Assert.equal(rows[65].dataset.value, "70");
+ checkSelected([4, 15, 25, 36, 47], [15, 25, 36, 47]);
+
+ await addValues(0, ["-1"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "7");
+ Assert.equal(rows[65].dataset.value, "69");
+ checkSelected([5, 16, 26, 37, 48], [16, 26, 37, 48]);
+
+ Assert.equal(
+ list.scrollTop,
+ 1735,
+ "the list is still scrolled to the middle"
+ );
+
+ await removeValues(154, 1, [150]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "7");
+ Assert.equal(rows[65].dataset.value, "69");
+ checkSelected([5, 16, 26, 37, 48], [16, 26, 37, 48]);
+
+ await removeValues(41, 1, ["37a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "7");
+ Assert.equal(rows[65].dataset.value, "70");
+ checkSelected([5, 16, 26, 37, 47], [16, 26, 37, 47]);
+
+ await removeValues(27, 1, ["24a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "7");
+ Assert.equal(rows[65].dataset.value, "71");
+ checkSelected([5, 16, 26, 36, 46], [16, 26, 36, 46]);
+
+ await removeValues(12, 1, ["10a"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "7");
+ Assert.equal(rows[65].dataset.value, "72");
+ checkSelected([5, 15, 25, 35, 45], [15, 25, 35, 45]);
+
+ await removeValues(0, 1, ["-1"]);
+ checkRows(8, 73);
+ Assert.equal(rows[0].dataset.value, "8");
+ Assert.equal(rows[65].dataset.value, "73");
+ checkSelected([4, 14, 24, 34, 44], [14, 24, 34, 44]);
+
+ Assert.equal(
+ list.scrollTop,
+ 1735,
+ "the list is still scrolled to the middle"
+ );
+
+ // Now scroll to the bottom and repeat.
+
+ await scrollListToPosition(6870);
+ checkRows(111, 149);
+ Assert.equal(rows[0].dataset.value, "111");
+ Assert.equal(rows[38].dataset.value, "149");
+ checkSelected([4, 14, 24, 34, 44], []);
+
+ await addValues(50, [50]);
+ checkRows(111, 150);
+ Assert.equal(rows[0].dataset.value, "110");
+ Assert.equal(rows[39].dataset.value, "149");
+ checkSelected([4, 14, 24, 34, 44], []);
+
+ await addValues(49, ["48a"]);
+ checkRows(111, 151);
+ Assert.equal(rows[0].dataset.value, "109");
+ Assert.equal(rows[40].dataset.value, "149");
+ checkSelected([4, 14, 24, 34, 44], []);
+
+ await addValues(30, ["29a"]);
+ checkRows(111, 152);
+ Assert.equal(rows[0].dataset.value, "108");
+ Assert.equal(rows[41].dataset.value, "149");
+ checkSelected([4, 14, 24, 35, 45], []);
+
+ await addValues(0, ["-1"]);
+ checkRows(111, 153);
+ Assert.equal(rows[0].dataset.value, "107");
+ Assert.equal(rows[42].dataset.value, "149");
+ checkSelected([5, 15, 25, 36, 46], []);
+
+ Assert.equal(
+ list.scrollTop,
+ 6870,
+ "the list is still scrolled to the bottom"
+ );
+
+ await removeValues(53, 1, [50]);
+ checkRows(111, 152);
+ Assert.equal(rows[0].dataset.value, "108");
+ Assert.equal(rows[41].dataset.value, "149");
+ checkSelected([5, 15, 25, 36, 46], []);
+
+ await removeValues(51, 1, ["48a"]);
+ checkRows(111, 151);
+ Assert.equal(rows[0].dataset.value, "109");
+ Assert.equal(rows[40].dataset.value, "149");
+ checkSelected([5, 15, 25, 36, 46], []);
+
+ await removeValues(31, 1, ["29a"]);
+ checkRows(111, 150);
+ Assert.equal(rows[0].dataset.value, "110");
+ Assert.equal(rows[39].dataset.value, "149");
+ checkSelected([5, 15, 25, 35, 45], []);
+
+ await removeValues(0, 1, ["-1"]);
+ checkRows(111, 149);
+ Assert.equal(rows[0].dataset.value, "111");
+ Assert.equal(rows[38].dataset.value, "149");
+ checkSelected([4, 14, 24, 34, 44], []);
+
+ Assert.equal(
+ list.scrollTop,
+ 6870,
+ "the list is still scrolled to the bottom"
+ );
+
+ // Remove a selected row and check the selection changes.
+
+ await scrollListToPosition(0);
+
+ checkSelected([4, 14, 24, 34, 44], [4, 14, 24, 34]);
+
+ await removeValues(3, 3, [3, 4, 5]); // 4 is selected.
+ checkSelected([11, 21, 31, 41], [11, 21, 31]);
+
+ await addValues(3, [3, 4, 5]);
+ checkSelected([14, 24, 34, 44], [14, 24, 34]);
+
+ // Remove some consecutive selected rows.
+
+ list.selectedIndices = [6, 7, 8, 9];
+ checkSelected([6, 7, 8, 9], [6, 7, 8, 9]);
+
+ await removeValues(7, 1, [7]);
+ checkSelected([6, 7, 8], [6, 7, 8]);
+
+ await removeValues(7, 1, [8]);
+ checkSelected([6, 7], [6, 7]);
+
+ await removeValues(7, 1, [9]);
+ checkSelected([6], [6]);
+
+ // Reset the list.
+
+ await addValues(7, [7, 8, 9]);
+ list.selectedIndex = -1;
+}
+
+/**
+ * Checks that expanding and collapsing works. Twisties in the test file are
+ * styled as coloured squares: red for collapsed, green for expanded.
+ *
+ * @note This is practically the same test as in browser_treeListbox.js, but
+ * for TreeView instead of TreeListbox. If you make changes here you
+ * may want to make changes there too.
+ */
+add_task(async function testExpandCollapse() {
+ await runTestInSandbox(subtestExpandCollapse, "levels");
+});
+
+async function subtestExpandCollapse() {
+ let doc = content.document;
+ let list = doc.getElementById("testTree");
+ let allIds = [
+ "row-1",
+ "row-2",
+ "row-2-1",
+ "row-2-2",
+ "row-3",
+ "row-3-1",
+ "row-3-1-1",
+ "row-3-1-2",
+ ];
+ let idsWithoutChildren = [
+ "row-1",
+ "row-2-1",
+ "row-2-2",
+ "row-3-1-1",
+ "row-3-1-2",
+ ];
+
+ let listener = {
+ reset() {
+ this.collapsedIndex = null;
+ this.expandedIndex = null;
+ },
+ handleEvent(event) {
+ if (event.type == "collapsed") {
+ this.collapsedIndex = event.detail;
+ } else if (event.type == "expanded") {
+ this.expandedIndex = event.detail;
+ }
+ },
+ };
+ list.addEventListener("collapsed", listener);
+ list.addEventListener("expanded", listener);
+
+ let selectHandler = {
+ seenEvent: null,
+ selectedAtEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ this.selectedAtEvent = null;
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.selectedAtEvent = list.selectedIndex;
+ },
+ };
+
+ Assert.equal(
+ list.querySelectorAll("collapsed").length,
+ 0,
+ "no rows are collapsed"
+ );
+ Assert.equal(list.view.rowCount, 8, "row count");
+ Assert.deepEqual(
+ Array.from(list.table.body.children, r => r.id),
+ [
+ "row-1",
+ "row-2",
+ "row-2-1",
+ "row-2-2",
+ "row-3",
+ "row-3-1",
+ "row-3-1-1",
+ "row-3-1-2",
+ ],
+ "rows property"
+ );
+
+ function checkCurrent(expectedIndex) {
+ Assert.equal(list.currentIndex, expectedIndex, "currentIndex is correct");
+ const current = list.querySelectorAll(".current");
+ if (expectedIndex == -1) {
+ Assert.equal(current.length, 0, "no rows have the 'current' class");
+ } else {
+ Assert.equal(current.length, 1, "only one row has the 'current' class");
+ Assert.equal(
+ current[0].index,
+ expectedIndex,
+ "correct row has the 'current' class"
+ );
+ }
+ }
+
+ function checkMultiSelect(...expectedIds) {
+ let selected = [...list.querySelectorAll(".selected")].map(row => row.id);
+ Assert.deepEqual(selected, expectedIds, "selection should be correct");
+ }
+
+ function checkSelectedAndCurrent(expectedIndex, expectedId) {
+ Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct");
+ let selected = [...list.querySelectorAll(".selected")].map(row => row.id);
+ Assert.deepEqual(
+ selected,
+ [expectedId],
+ "correct rows have the 'selected' class"
+ );
+ checkCurrent(expectedIndex);
+ }
+
+ list.selectedIndex = 0;
+ checkSelectedAndCurrent(0, "row-1");
+
+ // Click the twisties of rows without children.
+
+ function performChange(id, expectedChange, changeCallback) {
+ listener.reset();
+ let row = doc.getElementById(id);
+ let before = row.classList.contains("collapsed");
+
+ changeCallback(row);
+
+ row = doc.getElementById(id);
+ if (expectedChange == "collapsed") {
+ Assert.ok(!before, `${id} was expanded`);
+ Assert.ok(row.classList.contains("collapsed"), `${id} collapsed`);
+ Assert.notEqual(
+ listener.collapsedIndex,
+ null,
+ `${id} fired 'collapse' event`
+ );
+ Assert.ok(!listener.expandedIndex, `${id} did not fire 'expand' event`);
+ } else if (expectedChange == "expanded") {
+ Assert.ok(before, `${id} was collapsed`);
+ Assert.ok(!row.classList.contains("collapsed"), `${id} expanded`);
+ Assert.ok(
+ !listener.collapsedIndex,
+ `${id} did not fire 'collapse' event`
+ );
+ Assert.notEqual(
+ listener.expandedIndex,
+ null,
+ `${id} fired 'expand' event`
+ );
+ } else {
+ Assert.equal(
+ row.classList.contains("collapsed"),
+ before,
+ `${id} state did not change`
+ );
+ }
+ }
+
+ function clickTwisty(id, expectedChange) {
+ info(`clicking the twisty on ${id}`);
+ performChange(id, expectedChange, row =>
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".twisty"),
+ {},
+ content
+ )
+ );
+ }
+
+ function clickThread(id, expectedChange) {
+ info(`clicking the thread on ${id}`);
+ performChange(id, expectedChange, row => {
+ EventUtils.synthesizeMouseAtCenter(
+ row.querySelector(".tree-button-thread"),
+ {},
+ content
+ );
+ });
+ }
+
+ for (let id of idsWithoutChildren) {
+ clickTwisty(id, null);
+ Assert.equal(list.querySelector(".selected").id, id);
+ }
+
+ checkSelectedAndCurrent(7, "row-3-1-2");
+
+ // Click the twisties of rows with children.
+
+ function checkRowsAreHidden(...hiddenIds) {
+ let remainingIds = allIds.slice();
+
+ for (let id of allIds) {
+ if (hiddenIds.includes(id)) {
+ Assert.ok(!doc.getElementById(id), `${id} is hidden`);
+ remainingIds.splice(remainingIds.indexOf(id), 1);
+ } else {
+ Assert.greater(
+ doc.getElementById(id).clientHeight,
+ 0,
+ `${id} is visible`
+ );
+ }
+ }
+
+ Assert.equal(list.view.rowCount, 8 - hiddenIds.length, "row count");
+ Assert.deepEqual(
+ Array.from(list.table.body.children, r => r.id),
+ remainingIds,
+ "rows property"
+ );
+ }
+
+ // Collapse row 2.
+
+ clickTwisty("row-2", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelectedAndCurrent(5, "row-3-1-2");
+
+ // Collapse row 3.
+
+ clickTwisty("row-3", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(2, "row-3");
+
+ // Expand row 2.
+
+ clickTwisty("row-2", "expanded");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Expand row 3.
+
+ clickTwisty("row-3", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Collapse row 3-1.
+
+ clickTwisty("row-3-1", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Collapse row 3.
+
+ clickTwisty("row-3", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Expand row 3.
+
+ clickTwisty("row-3", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Expand row 3-1.
+
+ clickTwisty("row-3-1", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Test key presses.
+
+ function pressKey(id, key, expectedChange) {
+ info(`pressing ${key}`);
+ performChange(id, expectedChange, row => {
+ EventUtils.synthesizeKey(key, {}, content);
+ });
+ }
+
+ // Row 0 has no children or parent, nothing should happen.
+
+ list.selectedIndex = 0;
+ pressKey("row-1", "VK_LEFT");
+ checkSelectedAndCurrent(0, "row-1");
+ pressKey("row-1", "VK_RIGHT");
+ checkSelectedAndCurrent(0, "row-1");
+
+ // Collapse row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ pressKey("row-2", "VK_LEFT");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ // Collapse row 3.
+
+ list.selectedIndex = 2;
+ pressKey("row-3", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(2, "row-3");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(2, "row-3");
+
+ // Expand row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_RIGHT", "expanded");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ // Expand row 3.
+
+ list.selectedIndex = 4;
+ pressKey("row-3", "VK_RIGHT", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Go down the tree to row 3-1-1.
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ // Collapse row 3-1.
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ // Collapse row 3.
+
+ pressKey("row-3-1", "VK_LEFT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ pressKey("row-3", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Expand row 3.
+
+ pressKey("row-3", "VK_RIGHT", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ // Expand row 3-1.
+
+ pressKey("row-3-1", "VK_RIGHT", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ // Same again, with a RTL tree.
+
+ info("switching to RTL");
+ doc.documentElement.dir = "rtl";
+
+ // Row 0 has no children or parent, nothing should happen.
+
+ list.selectedIndex = 0;
+ pressKey("row-1", "VK_RIGHT");
+ checkSelectedAndCurrent(0, "row-1");
+ pressKey("row-1", "VK_LEFT");
+ checkSelectedAndCurrent(0, "row-1");
+
+ // Collapse row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ pressKey("row-2", "VK_RIGHT");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ // Collapse row 3.
+
+ list.selectedIndex = 2;
+ pressKey("row-3", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(2, "row-3");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden("row-2-1", "row-2-2", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(2, "row-3");
+
+ // Expand row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_LEFT", "expanded");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(1, "row-2");
+
+ // Expand row 3.
+
+ list.selectedIndex = 4;
+ pressKey("row-3", "VK_LEFT", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Go down the tree to row 3-1-1.
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ // Collapse row 3-1.
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ // Collapse row 3.
+
+ pressKey("row-3-1", "VK_RIGHT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ pressKey("row-3", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ // Expand row 3.
+
+ pressKey("row-3", "VK_LEFT", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ // Expand row 3-1.
+
+ pressKey("row-3-1", "VK_LEFT", "expanded");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(6, "row-3-1-1");
+
+ // Use the class methods for expanding and collapsing.
+
+ selectHandler.reset();
+ list.addEventListener("select", selectHandler);
+ listener.reset();
+
+ list.collapseRowAtIndex(6); // No children, no effect.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.ok(!listener.collapsedIndex, "'collapsed' event did not fire");
+
+ list.expandRowAtIndex(6); // No children, no effect.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.ok(!listener.expandedIndex, "'expanded' event did not fire");
+
+ list.collapseRowAtIndex(1); // Item with children that aren't selected.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(listener.collapsedIndex, 1, "row-2 fired 'collapsed' event");
+ listener.reset();
+
+ list.expandRowAtIndex(1); // Item with children that aren't selected.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(listener.expandedIndex, 1, "row-2 fired 'expanded' event");
+ listener.reset();
+
+ list.collapseRowAtIndex(5); // Item with children that are selected.
+ Assert.ok(selectHandler.seenEvent, "'select' event fired");
+ Assert.equal(
+ selectHandler.selectedAtEvent,
+ 5,
+ "selectedIndex was correct when 'select' event fired"
+ );
+ Assert.equal(listener.collapsedIndex, 5, "row-3-1 fired 'collapsed' event");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(5, "row-3-1");
+ selectHandler.reset();
+ listener.reset();
+
+ list.expandRowAtIndex(5); // Selected item with children.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(listener.expandedIndex, 5, "row-3-1 fired 'expanded' event");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(5, "row-3-1");
+ listener.reset();
+
+ list.selectedIndex = 7;
+ selectHandler.reset();
+
+ list.collapseRowAtIndex(4); // Item with grandchildren that are selected.
+ Assert.ok(selectHandler.seenEvent, "'select' event fired");
+ Assert.equal(
+ selectHandler.selectedAtEvent,
+ 4,
+ "selectedIndex was correct when 'select' event fired"
+ );
+ Assert.equal(listener.collapsedIndex, 4, "row-3 fired 'collapsed' event");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelectedAndCurrent(4, "row-3");
+ selectHandler.reset();
+ listener.reset();
+
+ list.expandRowAtIndex(4); // Selected item with grandchildren.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.equal(listener.expandedIndex, 4, "row-3 fired 'expanded' event");
+ checkRowsAreHidden();
+ checkSelectedAndCurrent(4, "row-3");
+ listener.reset();
+
+ // Click thread for already expanded thread. Should select all in thread.
+ selectHandler.reset();
+ clickThread("row-3"); // Item with grandchildren.
+ Assert.ok(selectHandler.seenEvent, "'select' event fired");
+ Assert.equal(
+ selectHandler.selectedAtEvent,
+ 4,
+ "selectedIndex was correct when 'select' event fired"
+ );
+ checkRowsAreHidden();
+ checkMultiSelect("row-3", "row-3-1", "row-3-1-1", "row-3-1-2");
+ checkCurrent(4);
+
+ // Click thread for collapsed thread. Should expand the thread and select all
+ // children.
+ list.collapseRowAtIndex(1); // Item with children that aren't selected.
+ Assert.equal(listener.collapsedIndex, 1, "row-2 fired 'collapsed' event");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ clickThread("row-2", "expanded");
+ Assert.equal(listener.expandedIndex, 1, "row-2 fired 'expanded' event");
+ checkMultiSelect("row-2", "row-2-1", "row-2-2");
+ checkCurrent(1);
+
+ // Select multiple messages in an expanded thread by keyboard, ending with a
+ // child message, then collapse the thread. After that, currentIndex should
+ // be the root message.
+ selectHandler.reset();
+ list.selectedIndex = 1;
+ checkSelectedAndCurrent(1, "row-2");
+ info(`pressing VK_DOWN with shift key twice`);
+ EventUtils.synthesizeKey("VK_DOWN", { shiftKey: true }, content);
+ EventUtils.synthesizeKey("VK_DOWN", { shiftKey: true }, content);
+ checkMultiSelect("row-2", "row-2-1", "row-2-2");
+ checkCurrent(3);
+ clickTwisty("row-2", "collapsed");
+ checkSelectedAndCurrent(1, "row-2");
+
+ list.removeEventListener("collapsed", listener);
+ list.removeEventListener("expanded", listener);
+ list.removeEventListener("select", selectHandler);
+ doc.documentElement.dir = null;
+}
+
+/**
+ * Checks that the row widget can be changed, redrawing the rows and
+ * maintaining the selection.
+ */
+add_task(async function testRowClassChange() {
+ for (const variant of TEST_VARIANTS) {
+ info(`Running row class change test for ${variant}`);
+ await runTestInSandbox(subtestRowClassChange, variant);
+ }
+});
+
+async function subtestRowClassChange() {
+ let doc = content.document;
+ let list = doc.getElementById("testTree");
+ let indices = (list.selectedIndices = [1, 2, 3, 5, 8, 13, 21, 34]);
+ list.currentIndex = 5;
+
+ for (let row of list.table.body.children) {
+ Assert.equal(row.getAttribute("is"), "test-row");
+ Assert.equal(row.clientHeight, 50);
+ Assert.equal(
+ row.classList.contains("selected"),
+ indices.includes(row.index)
+ );
+ Assert.equal(row.classList.contains("current"), row.index == 5);
+ }
+
+ info("switching row class to AlternativeCardRow");
+ list.setAttribute("rows", "alternative-row");
+ Assert.deepEqual(list.selectedIndices, indices);
+ Assert.equal(list.currentIndex, 5);
+
+ for (let row of list.table.body.children) {
+ Assert.equal(row.getAttribute("is"), "alternative-row");
+ Assert.equal(row.clientHeight, 80);
+ Assert.equal(
+ row.classList.contains("selected"),
+ indices.includes(row.index)
+ );
+ Assert.equal(row.classList.contains("current"), row.index == 5);
+ }
+
+ list.selectedIndex = -1;
+ Assert.deepEqual(list.selectedIndices, []);
+ Assert.equal(list.currentIndex, -1);
+
+ info("switching row class to TestCardRow");
+ list.setAttribute("rows", "test-row");
+ Assert.deepEqual(list.selectedIndices, []);
+ Assert.equal(list.currentIndex, -1);
+
+ for (let row of list.table.body.children) {
+ Assert.equal(row.getAttribute("is"), "test-row");
+ Assert.equal(row.clientHeight, 50);
+ Assert.ok(!row.classList.contains("selected"));
+ Assert.ok(!row.classList.contains("current"));
+ }
+}
+
+/**
+ * Checks that resizing the widget automatically adds more rows if necessary.
+ */
+add_task(async function testResize() {
+ for (const variant of TEST_VARIANTS) {
+ info(`Running resize test for ${variant}`);
+ await runTestInSandbox(subtestResize, variant);
+ }
+});
+
+async function subtestResize() {
+ let doc = content.document;
+
+ let list = doc.getElementById("testTree");
+ Assert.ok(!!list, "the list exists");
+
+ async function doListActionAndWaitForRowBuffer(actionFn) {
+ // Filling the row buffer is fiddly timing, so provide an event to indicate
+ // that actions which may trigger changes in the row buffer have finished.
+ const eventName = "_treerowbufferfill";
+ list._rowBufferReadyEvent = new content.CustomEvent(eventName);
+
+ const promise = new Promise(resolve =>
+ list.addEventListener(eventName, resolve, { once: true })
+ );
+
+ await actionFn();
+ await promise;
+
+ list._rowBufferReadyEvent = null;
+ }
+
+ async function scrollVerticallyBy(scrollDistance) {
+ await doListActionAndWaitForRowBuffer(() => {
+ list.scrollBy(0, scrollDistance);
+ });
+ }
+
+ async function changeHeightTo(newHeight) {
+ await doListActionAndWaitForRowBuffer(() => {
+ list.style.height = `${newHeight}px`;
+ });
+ }
+
+ let rowCount = function () {
+ return list.querySelectorAll(`tr[is="test-row"]`).length;
+ };
+
+ let originalHeight = list.clientHeight;
+
+ // We should already be at the top, but this will force us to have finished
+ // loading before we trigger another scroll. Otherwise, we may get back a fill
+ // event for the initial fill when we expect an event in response to a scroll.
+ await doListActionAndWaitForRowBuffer(() => {
+ list.scrollTo(0, 0);
+ });
+
+ // Start by scrolling to somewhere in the middle of the list, so that we
+ // don't have to think about buffer rows that don't exist at the ends.
+ await scrollVerticallyBy(2650);
+
+ // The list has enough space for 13 visible rows, and 26 buffer rows should
+ // exist above and below.
+ Assert.equal(
+ rowCount(),
+ 13 + 26 + 26,
+ "the list should contain the right number of rows"
+ );
+
+ // Make the list shorter by 5 rows. This should not affect the number of rows,
+ // but this is a bit flaky, so check we have at least the minimum required.
+ await changeHeightTo(originalHeight - 250);
+ Assert.equal(list._toleranceSize, 16);
+ Assert.greaterOrEqual(
+ rowCount(),
+ 8 + 26 + 26,
+ "making the list shorter should not change the number of rows"
+ );
+
+ // Scrolling the list by any amount should remove excess rows.
+ await scrollVerticallyBy(50);
+ Assert.equal(
+ rowCount(),
+ 8 + 16 + 16,
+ "scrolling the list after resize should remove the excess rows"
+ );
+
+ // Return to the original height. More buffer rows should be added. We have
+ // to wait for the ResizeObserver to be triggered.
+ await changeHeightTo(originalHeight);
+ Assert.equal(list._toleranceSize, 26);
+ Assert.equal(
+ rowCount(),
+ 13 + 26 + 26,
+ "making the list taller should change the number of rows"
+ );
+
+ // Make the list taller by 5 rows. We have to wait for the ResizeObserver
+ // to be triggered.
+ await changeHeightTo(originalHeight + 250);
+ Assert.equal(list._toleranceSize, 36);
+ Assert.equal(
+ rowCount(),
+ 18 + 36 + 36,
+ "making the list taller should change the number of rows"
+ );
+
+ // Scrolling the list should not affect the number of rows.
+ await scrollVerticallyBy(50);
+ Assert.equal(
+ rowCount(),
+ 18 + 36 + 36,
+ "scrolling the list should not change the number of rows"
+ );
+}