diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/base/test/browser/browser_treeListbox.js | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | comm/mail/base/test/browser/browser_treeListbox.js | 1313 |
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"); +} |