summaryrefslogtreecommitdiffstats
path: root/comm/mail/base/test/browser/browser_treeListbox.js
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/base/test/browser/browser_treeListbox.js')
-rw-r--r--comm/mail/base/test/browser/browser_treeListbox.js1313
1 files changed, 1313 insertions, 0 deletions
diff --git a/comm/mail/base/test/browser/browser_treeListbox.js b/comm/mail/base/test/browser/browser_treeListbox.js
new file mode 100644
index 0000000000..558bef991e
--- /dev/null
+++ b/comm/mail/base/test/browser/browser_treeListbox.js
@@ -0,0 +1,1313 @@
+/* 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]);
+});
+
+async function withTab(callback) {
+ let tab = tabmail.openTab("contentTab", {
+ url: "chrome://mochitests/content/browser/comm/mail/base/test/browser/files/treeListbox.xhtml",
+ });
+ await BrowserTestUtils.browserLoaded(tab.browser);
+
+ tab.browser.focus();
+ await SpecialPowers.spawn(tab.browser, [], callback);
+
+ tabmail.closeTab(tab);
+}
+
+add_task(async function testKeyboard() {
+ await withTab(subtestKeyboard);
+});
+add_task(async function testMutation() {
+ await withTab(subtestMutation);
+});
+add_task(async function testExpandCollapse() {
+ await withTab(subtestExpandCollapse);
+});
+add_task(async function testSelectOnRemoval1() {
+ await withTab(subtestSelectOnRemoval1);
+});
+add_task(async function testSelectOnRemoval2() {
+ await withTab(subtestSelectOnRemoval2);
+});
+add_task(async function testSelectOnRemoval3() {
+ await withTab(subtestSelectOnRemoval3);
+});
+add_task(async function testUnselectable() {
+ await withTab(subtestUnselectable);
+});
+
+/**
+ * Tests keyboard navigation up and down the list.
+ */
+async function subtestKeyboard() {
+ let doc = content.document;
+
+ let list = doc.querySelector(`ul[is="tree-listbox"]`);
+ Assert.ok(!!list, "the list exists");
+
+ let initialRowIds = [
+ "row-1",
+ "row-2",
+ "row-2-1",
+ "row-2-2",
+ "row-3",
+ "row-3-1",
+ "row-3-1-1",
+ "row-3-1-2",
+ ];
+ Assert.equal(list.rowCount, initialRowIds.length, "rowCount is correct");
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ initialRowIds,
+ "initial rows are correct"
+ );
+ Assert.equal(list.selectedIndex, 0, "selectedIndex is set to 0");
+
+ let selectHandler = {
+ seenEvent: null,
+ selectedAtEvent: null,
+
+ reset() {
+ this.seenEvent = null;
+ this.selectedAtEvent = null;
+ },
+ handleEvent(event) {
+ this.seenEvent = event;
+ this.selectedAtEvent = list.selectedIndex;
+ },
+ };
+
+ function pressKey(key, expectEvent = true) {
+ info(`pressing ${key}`);
+
+ selectHandler.reset();
+ list.addEventListener("select", selectHandler);
+ EventUtils.synthesizeKey(key, {}, content);
+ list.removeEventListener("select", selectHandler);
+ Assert.equal(
+ !!selectHandler.seenEvent,
+ expectEvent,
+ `'select' event ${expectEvent ? "fired" : "did not fire"}`
+ );
+ }
+
+ function checkSelected(expectedIndex, expectedId) {
+ Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct");
+ if (selectHandler.selectedAtEvent !== null) {
+ // Check the value was already set when the select event fired.
+ Assert.deepEqual(
+ selectHandler.selectedAtEvent,
+ expectedIndex,
+ "selectedIndex was correct at the last 'select' event"
+ );
+ }
+
+ Assert.deepEqual(
+ Array.from(list.querySelectorAll(".selected"), row => row.id),
+ [expectedId],
+ "correct rows have the 'selected' class"
+ );
+ }
+
+ // Key down the list.
+
+ list.focus();
+ for (let i = 1; i < initialRowIds.length; i++) {
+ pressKey("KEY_ArrowDown");
+ checkSelected(i, initialRowIds[i]);
+ }
+
+ pressKey("KEY_ArrowDown", false);
+ checkSelected(7, "row-3-1-2");
+
+ pressKey("KEY_PageDown", false);
+ checkSelected(7, "row-3-1-2");
+
+ pressKey("KEY_End", false);
+ checkSelected(7, "row-3-1-2");
+
+ // And up again.
+
+ for (let i = initialRowIds.length - 2; i >= 0; i--) {
+ pressKey("KEY_ArrowUp");
+ checkSelected(i, initialRowIds[i]);
+ }
+
+ pressKey("KEY_ArrowUp", false);
+ checkSelected(0, "row-1");
+
+ pressKey("KEY_PageUp", false);
+ checkSelected(0, "row-1");
+
+ pressKey("KEY_Home", false);
+ checkSelected(0, "row-1");
+
+ // Jump around.
+
+ pressKey("KEY_End");
+ checkSelected(7, "row-3-1-2");
+
+ pressKey("KEY_PageUp");
+ checkSelected(0, "row-1");
+
+ pressKey("KEY_PageDown");
+ checkSelected(7, "row-3-1-2");
+
+ pressKey("KEY_Home");
+ checkSelected(0, "row-1");
+}
+
+/**
+ * Tests that rows added to or removed from the tree cause their parent rows
+ * to gain or lose the 'children' class as appropriate. This is done with a
+ * mutation observer so the tree is not updated immediately, but at the end of
+ * the event loop.
+ */
+async function subtestMutation() {
+ let doc = content.document;
+ let list = doc.querySelector(`ul[is="tree-listbox"]`);
+ let idsWithChildren = ["row-2", "row-3", "row-3-1"];
+ let idsWithoutChildren = [
+ "row-1",
+ "row-2-1",
+ "row-2-2",
+ "row-3-1-1",
+ "row-3-1-2",
+ ];
+
+ // Check the initial state.
+
+ function createNewRow() {
+ let template = doc.getElementById("rowToAdd");
+ return template.content.cloneNode(true).firstElementChild;
+ }
+
+ function checkHasClass(id, shouldHaveClass = true) {
+ let row = doc.getElementById(id);
+ if (shouldHaveClass) {
+ Assert.ok(
+ row.classList.contains("children"),
+ `${id} should have the 'children' class`
+ );
+ } else {
+ Assert.ok(
+ !row.classList.contains("children"),
+ `${id} should NOT have the 'children' class`
+ );
+ }
+ }
+
+ for (let id of idsWithChildren) {
+ checkHasClass(id, true);
+ }
+ for (let id of idsWithoutChildren) {
+ checkHasClass(id, false);
+ }
+
+ // Add a new row without children to the end of the list.
+
+ info("adding new row to end of list");
+ let newRow = list.appendChild(createNewRow());
+ // Wait for mutation observer. It does nothing, but let's be sure.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass("new-row", false);
+ newRow.remove();
+ await new Promise(r => content.setTimeout(r));
+
+ // Add and remove a single row to rows with existing children.
+
+ for (let id of idsWithChildren) {
+ let row = doc.getElementById(id);
+
+ info(`adding new row to ${id}`);
+ newRow = row.querySelector("ul").appendChild(createNewRow());
+ // Wait for mutation observer. It does nothing, but let's be sure.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass("new-row", false);
+ checkHasClass(id, true);
+
+ info(`removing new row from ${id}`);
+ newRow.remove();
+ // Wait for mutation observer. It does nothing, but let's be sure.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass(id, true);
+
+ if (id == "row-3-1") {
+ checkHasClass("row-3", true);
+ }
+ }
+
+ // Add and remove a single row to rows without existing children.
+
+ for (let id of idsWithoutChildren) {
+ let row = doc.getElementById(id);
+ let childList = row.appendChild(doc.createElement("ul"));
+
+ info(`adding new row to ${id}`);
+ newRow = childList.appendChild(createNewRow());
+ // Wait for mutation observer.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass("new-row", false);
+ checkHasClass(id, true);
+
+ info(`removing new row from ${id}`);
+ newRow.remove();
+ // Wait for mutation observer.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass(id, false);
+
+ // This time remove the child list, not the row itself.
+
+ info(`adding new row to ${id} again`);
+ newRow = childList.appendChild(createNewRow());
+ // Wait for mutation observer.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass("new-row", false);
+ checkHasClass(id, true);
+
+ info(`removing child list from ${id}`);
+ childList.remove();
+ // Wait for mutation observer.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass(id, false);
+
+ if (["row-2-1", "row-2-2"].includes(id)) {
+ checkHasClass("row-2", true);
+ } else if (["row-3-1-1", "row-3-1-2"].includes(id)) {
+ checkHasClass("row-3-1", true);
+ checkHasClass("row-3", true);
+ }
+ }
+
+ // Add a row with children and a grandchild to the end of the list. The new
+ // row should be given the "children" class. The child with a grandchild
+ // should be given the "children" class. I think it's safe to assume this
+ // works no matter where in the tree it's added.
+
+ let template = doc.getElementById("rowsToAdd");
+ newRow = template.content.cloneNode(true).firstElementChild;
+ list.appendChild(newRow);
+ // Wait for mutation observer.
+ await new Promise(r => content.setTimeout(r));
+ checkHasClass("added-row", true);
+ checkHasClass("added-row-1", true);
+ checkHasClass("added-row-1-1", false);
+ checkHasClass("added-row-2", false);
+ newRow.remove();
+ await new Promise(r => content.setTimeout(r));
+
+ // Add a new row without children to the middle of the list. Selection should
+ // be maintained.
+
+ list.selectedIndex = 5; // row-3-1
+
+ info("adding new row to middle of list");
+ newRow = template.content.cloneNode(true).firstElementChild;
+ list.insertBefore(newRow, list.querySelector("#row-3"));
+ await new Promise(r => content.setTimeout(r));
+ Assert.equal(list.selectedIndex, 9, "row-3-1 is still selected");
+
+ newRow.remove();
+ await new Promise(r => content.setTimeout(r));
+ Assert.equal(list.selectedIndex, 5, "row-3-1 is still selected");
+
+ list.selectedIndex = 0;
+}
+
+/**
+ * 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_treeView.js,
+ * but for TreeListbox instead of TreeView. If you make changes here
+ * you may want to make changes there too.
+ */
+async function subtestExpandCollapse() {
+ let doc = content.document;
+ let list = doc.querySelector(`ul[is="tree-listbox"]`);
+ 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.collapsedRow = null;
+ this.expandedRow = null;
+ },
+ handleEvent(event) {
+ if (event.type == "collapsed") {
+ this.collapsedRow = event.target;
+ } else if (event.type == "expanded") {
+ this.expandedRow = event.target;
+ }
+ },
+ };
+ 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.rowCount, 8, "row count");
+ Assert.deepEqual(
+ list.rows.map(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 checkSelected(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"
+ );
+ }
+
+ checkSelected(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);
+
+ if (expectedChange == "collapsed") {
+ Assert.ok(!before, `${id} was expanded`);
+ Assert.ok(row.classList.contains("collapsed"), `${id} collapsed`);
+ Assert.equal(listener.collapsedRow, row, `${id} fired 'collapse' event`);
+ Assert.ok(!listener.expandedRow, `${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.collapsedRow, `${id} did not fire 'collapse' event`);
+ Assert.equal(listener.expandedRow, row, `${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 doubleClick(id, expectedChange) {
+ info(`double clicking on ${id}`);
+ performChange(id, expectedChange, row =>
+ EventUtils.synthesizeMouseAtCenter(row, { clickCount: 2 }, content)
+ );
+ }
+
+ for (let id of idsWithoutChildren) {
+ clickTwisty(id, null);
+ Assert.equal(list.querySelector(".selected").id, id);
+ }
+
+ checkSelected(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.equal(doc.getElementById(id).clientHeight, 0, `${id} is hidden`);
+ remainingIds.splice(remainingIds.indexOf(id), 1);
+ } else {
+ Assert.greater(
+ doc.getElementById(id).clientHeight,
+ 0,
+ `${id} is visible`
+ );
+ }
+ }
+
+ Assert.equal(list.rowCount, 8 - hiddenIds.length, "row count");
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ remainingIds,
+ "rows property"
+ );
+ }
+
+ // Collapse row 2.
+
+ clickTwisty("row-2", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelected(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");
+ checkSelected(2, "row-3");
+
+ // Expand row 2.
+
+ doubleClick("row-2", "expanded");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Expand row 3.
+
+ doubleClick("row-3", "expanded");
+ checkRowsAreHidden();
+ checkSelected(4, "row-3");
+
+ // Collapse row 3-1.
+
+ clickTwisty("row-3-1", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Collapse row 3.
+
+ clickTwisty("row-3", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Expand row 3.
+
+ clickTwisty("row-3", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Expand row 3-1.
+
+ clickTwisty("row-3-1", "expanded");
+ checkRowsAreHidden();
+ checkSelected(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");
+ checkSelected(0, "row-1");
+ pressKey("row-1", "VK_RIGHT");
+ checkSelected(0, "row-1");
+
+ // Collapse row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelected(1, "row-2");
+
+ pressKey("row-2", "VK_LEFT");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelected(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");
+ checkSelected(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");
+ checkSelected(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");
+ checkSelected(1, "row-2");
+
+ // Expand row 3.
+
+ list.selectedIndex = 4;
+ pressKey("row-3", "VK_RIGHT", "expanded");
+ checkRowsAreHidden();
+ checkSelected(4, "row-3");
+
+ // Go down the tree to row 3-1-1.
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ // Collapse row 3-1.
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+
+ // Collapse row 3.
+
+ pressKey("row-3-1", "VK_LEFT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ pressKey("row-3", "VK_LEFT", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Expand row 3.
+
+ pressKey("row-3", "VK_RIGHT", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ pressKey("row-3", "VK_RIGHT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+
+ // Expand row 3-1.
+
+ pressKey("row-3-1", "VK_RIGHT", "expanded");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ // Toggle expansion of row 3-1 with Enter key.
+
+ list.selectedIndex = 5;
+ pressKey("row-3-1", "KEY_Enter", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3-1", "KEY_Enter", "expanded");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-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");
+ checkSelected(0, "row-1");
+ pressKey("row-1", "VK_LEFT");
+ checkSelected(0, "row-1");
+
+ // Collapse row 2.
+
+ list.selectedIndex = 1;
+ pressKey("row-2", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelected(1, "row-2");
+
+ pressKey("row-2", "VK_RIGHT");
+ checkRowsAreHidden("row-2-1", "row-2-2");
+ checkSelected(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");
+ checkSelected(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");
+ checkSelected(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");
+ checkSelected(1, "row-2");
+
+ // Expand row 3.
+
+ list.selectedIndex = 4;
+ pressKey("row-3", "VK_LEFT", "expanded");
+ checkRowsAreHidden();
+ checkSelected(4, "row-3");
+
+ // Go down the tree to row 3-1-1.
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ // Collapse row 3-1.
+
+ pressKey("row-3-1-1", "VK_RIGHT");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+
+ // Collapse row 3.
+
+ pressKey("row-3-1", "VK_RIGHT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ pressKey("row-3", "VK_RIGHT", "collapsed");
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ // Expand row 3.
+
+ pressKey("row-3", "VK_LEFT", "expanded");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(4, "row-3");
+
+ pressKey("row-3", "VK_LEFT");
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(5, "row-3-1");
+
+ // Expand row 3-1.
+
+ pressKey("row-3-1", "VK_LEFT", "expanded");
+ checkRowsAreHidden();
+ checkSelected(5, "row-3-1");
+
+ pressKey("row-3-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(6, "row-3-1-1");
+
+ pressKey("row-3-1-1", "VK_LEFT");
+ checkRowsAreHidden();
+ checkSelected(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.collapsedRow, "'collapsed' event did not fire");
+
+ list.expandRowAtIndex(6); // No children, no effect.
+ Assert.ok(!selectHandler.seenEvent, "'select' event did not fire");
+ Assert.ok(!listener.expandedRow, "'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.collapsedRow.id,
+ "row-2",
+ "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.expandedRow.id,
+ "row-2",
+ "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.collapsedRow.id,
+ "row-3-1",
+ "row-3-1 fired 'collapsed' event"
+ );
+ checkRowsAreHidden("row-3-1-1", "row-3-1-2");
+ checkSelected(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.expandedRow.id,
+ "row-3-1",
+ "row-3-1 fired 'expanded' event"
+ );
+ checkRowsAreHidden();
+ checkSelected(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.collapsedRow.id,
+ "row-3",
+ "row-3 fired 'collapsed' event"
+ );
+ checkRowsAreHidden("row-3-1", "row-3-1-1", "row-3-1-2");
+ checkSelected(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.expandedRow.id,
+ "row-3",
+ "row-3 fired 'expanded' event"
+ );
+ checkRowsAreHidden();
+ checkSelected(4, "row-3");
+ listener.reset();
+
+ list.removeEventListener("collapsed", listener);
+ list.removeEventListener("expanded", listener);
+ list.removeEventListener("select", selectHandler);
+ doc.documentElement.dir = null;
+}
+
+/**
+ * Tests what happens to selection when a row is removed.
+ */
+async function subtestSelectOnRemoval1() {
+ let doc = content.document;
+ let list = doc.getElementById("deleteTree");
+
+ let selectPromise;
+ function promiseSelectEvent() {
+ selectPromise = new Promise(resolve =>
+ list.addEventListener(
+ "select",
+ () => resolve([list.selectedIndex, list.selectedRow?.id ?? null]),
+ {
+ once: true,
+ }
+ )
+ );
+ }
+ // dRow-1
+ // dRow-2
+ // dRow-2-1
+ // dRow-2-2
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-4-4
+ // dRow-5
+ // dRow-5-1
+ // dRow-6
+
+ // Delete a row that is selected, and not at the top level. Selection should
+ // move to the next row under the shared parent.
+
+ list.selectedIndex = 2;
+ Assert.equal(list.selectedRow.id, "dRow-2-1");
+
+ promiseSelectEvent();
+ list.querySelector("#dRow-2-1").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [2, "dRow-2-2"],
+ "selection moved to the next row"
+ );
+
+ // dRow-1
+ // dRow-2
+ // dRow-2-2
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-4-4
+ // dRow-5
+ // dRow-5-1
+ // dRow-6
+
+ // Delete a row that contains the selected, and at the top level. Selection
+ // should move to the next top-level row.
+
+ Assert.equal(list.selectedRow.id, "dRow-2-2");
+ promiseSelectEvent();
+ list.querySelector("#dRow-2").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [1, "dRow-3"],
+ "selection moved to the next row"
+ );
+
+ // dRow-1
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-4-4
+ // dRow-5
+ // dRow-5-1
+ // dRow-6
+
+ // Delete the first top-level row that is selected. Should select the first
+ // row.
+ list.selectedIndex = 0;
+ Assert.equal(list.selectedRow.id, "dRow-1");
+ promiseSelectEvent();
+ list.querySelector("#dRow-1").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [0, "dRow-3"],
+ "selection moved to the first row"
+ );
+
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-4-4
+ // dRow-5
+ // dRow-5-1
+ // dRow-6
+
+ // Delete the last top-level row that is selected. Should select the last row.
+ list.selectedIndex = 14;
+ Assert.equal(list.selectedRow.id, "dRow-6");
+ promiseSelectEvent();
+ list.querySelector("#dRow-6").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [13, "dRow-5-1"],
+ "selection moved to the new last row"
+ );
+
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-4-4
+ // dRow-5
+ // dRow-5-1
+
+ // Delete the last selected descendant should move selection to the new
+ // descendant child.
+ list.selectedIndex = 11;
+ Assert.equal(list.selectedRow.id, "dRow-4-4");
+ promiseSelectEvent();
+ list.querySelector("#dRow-4-4").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [10, "dRow-4-3-2"],
+ "selection moved to the new last row"
+ );
+
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-1
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-5
+ // dRow-5-1
+
+ // Delete the first selected child should move selection to the new first child.
+ list.selectedIndex = 6;
+ Assert.equal(list.selectedRow.id, "dRow-4-1");
+ promiseSelectEvent();
+ list.querySelector("#dRow-4-1").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [6, "dRow-4-2"],
+ "selection moved to the new first row"
+ );
+
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-2
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-5
+ // dRow-5-1
+
+ // Delete a row that isn't selected. Nothing should happen.
+
+ list.selectedIndex = 2;
+ Assert.equal(list.selectedRow.id, "dRow-3-1-1");
+
+ list.querySelector("#dRow-3-1-2").remove();
+ await new Promise(resolve => content.setTimeout(resolve));
+ Assert.equal(list.selectedIndex, 2, "selection did not change");
+ Assert.equal(list.selectedRow.id, "dRow-3-1-1", "selection did not change");
+
+ // dRow-3
+ // dRow-3-1
+ // dRow-3-1-1
+ // dRow-3-1-3
+ // dRow-4
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-5
+ // dRow-5-1
+
+ // Deleting the last row under a parent that contains the selection should
+ // select the parent.
+ list.selectedIndex = 2;
+ Assert.equal(list.selectedRow.id, "dRow-3-1-1");
+
+ promiseSelectEvent();
+ let rowToReplace = list.querySelector("#dRow-3-1");
+ rowToReplace.remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [0, "dRow-3"],
+ "selection moved to the parent row"
+ );
+
+ // dRow-3
+ // dRow-4
+ // dRow-4-2
+ // dRow-4-3
+ // dRow-4-3-1
+ // dRow-4-3-2
+ // dRow-5
+ // dRow-5-1
+
+ // Deleting several rows under a parent, should select the parent row.
+ list.selectedIndex = 4;
+ Assert.equal(list.selectedRow.id, "dRow-4-3-1");
+ promiseSelectEvent();
+ list.querySelector("#dRow-4 ul").remove();
+ Assert.deepEqual(
+ await selectPromise,
+ [1, "dRow-4"],
+ "selection moved to the parent row"
+ );
+
+ // Delete the last remaining rows. The selected index should be -1.
+
+ promiseSelectEvent();
+ list.replaceChildren();
+ Assert.deepEqual(await selectPromise, [-1, null], "selection was cleared");
+
+ // Add back a row. One of the row's children was selected, this should be
+ // removed and the selection set to the top-level row.
+
+ promiseSelectEvent();
+ list.appendChild(rowToReplace);
+ Assert.deepEqual(
+ await selectPromise,
+ [0, "dRow-3-1"],
+ "selection set to the added row"
+ );
+ Assert.ok(list.querySelector("#dRow-3-1-1"), "child of the added row exists");
+ Assert.ok(
+ !list.querySelector("#dRow-3-1-1").classList.contains("selected"),
+ "child of the added row is not selected"
+ );
+}
+
+/**
+ * Tests what happens to selection when a row is removed.
+ */
+async function subtestSelectOnRemoval2() {
+ let doc = content.document;
+ let list = doc.querySelector(`ul[is="tree-listbox"]`);
+
+ let selectPromise;
+ function promiseSelectEvent() {
+ selectPromise = new Promise(resolve =>
+ list.addEventListener("select", () => resolve(list.selectedIndex), {
+ once: true,
+ })
+ );
+ }
+
+ // Delete row-3 containing the selection.
+
+ list.selectedIndex = 7; // row-3-1-2
+
+ promiseSelectEvent();
+ list.querySelector("#row-3").remove();
+ Assert.equal(await selectPromise, 3, "selection moved to the last row");
+
+ // Delete row-2. Selection should move to the only row.
+
+ promiseSelectEvent();
+ list.querySelector("#row-2").remove();
+ Assert.equal(
+ await selectPromise,
+ 0, // row-1
+ "selection moved to the last row"
+ );
+}
+
+/**
+ * Tests what happens to selection when elements above it are removed.
+ */
+async function subtestSelectOnRemoval3() {
+ let doc = content.document;
+ let list = doc.querySelector(`ul[is="tree-listbox"]`);
+
+ // Delete a row.
+
+ list.selectedIndex = 6; // row-3-1-1
+
+ list.querySelector("#row-2-1").remove();
+ await new Promise(r => content.setTimeout(r));
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ ["row-1", "row-2", "row-2-2", "row-3", "row-3-1", "row-3-1-1", "row-3-1-2"]
+ );
+ Assert.equal(
+ list.selectedIndex,
+ 5, // row-3-1-1
+ "selection moved to the previous top-level row"
+ );
+
+ // Delete an element that isn't a row.
+
+ list.querySelector("#row-2 div").remove();
+ await new Promise(r => content.setTimeout(r));
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ ["row-1", "row-2", "row-2-2", "row-3", "row-3-1", "row-3-1-1", "row-3-1-2"]
+ );
+ Assert.equal(
+ list.selectedIndex,
+ 5, // row-3-1-1
+ "selection moved to the previous top-level row"
+ );
+
+ // Delete an element that contains a row.
+
+ list.querySelector("#row-2 ul").remove();
+ await new Promise(r => content.setTimeout(r));
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ ["row-1", "row-2", "row-3", "row-3-1", "row-3-1-1", "row-3-1-2"]
+ );
+ Assert.equal(
+ list.selectedIndex,
+ 4, // row-3-1-1
+ "selection moved to the previous top-level row"
+ );
+}
+
+/**
+ * Tests that rows marked as unselectable cannot be selected.
+ */
+async function subtestUnselectable() {
+ let doc = content.document;
+
+ let list = doc.querySelector(`ul#unselectableTree`);
+ Assert.ok(!!list, "the list exists");
+
+ let initialRowIds = [
+ "uRow-2-1",
+ "uRow-2-2",
+ "uRow-3-1",
+ "uRow-3-1-1",
+ "uRow-3-1-2",
+ ];
+ Assert.equal(list.rowCount, initialRowIds.length, "rowCount is correct");
+ Assert.deepEqual(
+ list.rows.map(r => r.id),
+ initialRowIds,
+ "initial rows are correct"
+ );
+
+ function checkSelected(expectedIndex, expectedId) {
+ Assert.equal(list.selectedIndex, expectedIndex, "selectedIndex is correct");
+ Assert.deepEqual(
+ Array.from(list.querySelectorAll(".selected"), row => row.id),
+ [expectedId],
+ "correct rows have the 'selected' class"
+ );
+ }
+
+ checkSelected(0, "uRow-2-1");
+
+ // Clicking unselectable rows should not change the selection.
+ EventUtils.synthesizeMouseAtCenter(
+ doc.querySelector("#uRow-1 > div"),
+ {},
+ content
+ );
+ checkSelected(0, "uRow-2-1");
+ EventUtils.synthesizeMouseAtCenter(
+ doc.querySelector("#uRow-2 > div"),
+ {},
+ content
+ );
+ checkSelected(0, "uRow-2-1");
+ EventUtils.synthesizeMouseAtCenter(
+ doc.querySelector("#uRow-3 > div"),
+ {},
+ content
+ );
+ checkSelected(0, "uRow-2-1");
+
+ // Unselectable rows should not be accessible by keyboard.
+ EventUtils.synthesizeKey("KEY_ArrowUp", {}, content);
+ checkSelected(0, "uRow-2-1");
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, content);
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, content);
+ checkSelected(2, "uRow-3-1");
+ EventUtils.synthesizeKey("KEY_Home", {}, content);
+ checkSelected(0, "uRow-2-1");
+ EventUtils.synthesizeKey("KEY_End", {}, content);
+ checkSelected(4, "uRow-3-1-2");
+ EventUtils.synthesizeKey("KEY_PageUp", {}, content);
+ checkSelected(0, "uRow-2-1");
+ EventUtils.synthesizeKey("KEY_PageDown", {}, content);
+ checkSelected(4, "uRow-3-1-2");
+
+ EventUtils.synthesizeKey("VK_LEFT", {}, content); // Move up to 3-1.
+ checkSelected(2, "uRow-3-1");
+ EventUtils.synthesizeKey("VK_LEFT", {}, content); // Collapse.
+ checkSelected(2, "uRow-3-1");
+ EventUtils.synthesizeKey("VK_LEFT", {}, content); // Try to move to 3.
+ checkSelected(2, "uRow-3-1");
+}