summaryrefslogtreecommitdiffstats
path: root/comm/mail/base/test/browser/files
diff options
context:
space:
mode:
Diffstat (limited to 'comm/mail/base/test/browser/files')
-rw-r--r--comm/mail/base/test/browser/files/formContent.html36
-rw-r--r--comm/mail/base/test/browser/files/links.html38
-rw-r--r--comm/mail/base/test/browser/files/menulist.xhtml30
-rw-r--r--comm/mail/base/test/browser/files/orderableTreeListbox.xhtml171
-rw-r--r--comm/mail/base/test/browser/files/paneSplitter.xhtml122
-rw-r--r--comm/mail/base/test/browser/files/rss.xml16
-rw-r--r--comm/mail/base/test/browser/files/sampleContent.eml160
-rw-r--r--comm/mail/base/test/browser/files/sampleContent.html16
-rw-r--r--comm/mail/base/test/browser/files/selectionWidget.js225
-rw-r--r--comm/mail/base/test/browser/files/selectionWidget.xhtml57
-rw-r--r--comm/mail/base/test/browser/files/tb-logo.pngbin0 -> 6462 bytes
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-common.js73
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-header.js64
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-header.xhtml61
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-levels.js118
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-levels.xhtml65
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-no-header.js58
-rw-r--r--comm/mail/base/test/browser/files/tree-element-test-no-header.xhtml54
-rw-r--r--comm/mail/base/test/browser/files/treeListbox.xhtml390
19 files changed, 1754 insertions, 0 deletions
diff --git a/comm/mail/base/test/browser/files/formContent.html b/comm/mail/base/test/browser/files/formContent.html
new file mode 100644
index 0000000000..6779051746
--- /dev/null
+++ b/comm/mail/base/test/browser/files/formContent.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Form Content</title>
+ </head>
+ <body>
+ <form>
+ <div>
+ <input type="date" />
+ </div>
+ <div>
+ <select>
+ <option value=""></option>
+ <option value="3.141592654">&pi;</option>
+ <option value="6.283185308">&tau;</option>
+ </select>
+ </div>
+ <div>
+ <input list="letters"/>
+ <datalist id="letters">
+ <option value="alpha"/>
+ <option value="beta"/>
+ <option value="gamma"/>
+ <option value="delta"/>
+ <option value="epsilon"/>
+ <option value="zeta"/>
+ <option value="eta"/>
+ <option value="theta"/>
+ <option value="iota"/>
+ <option value="kappa"/>
+ </datalist>
+ </div>
+ </form>
+ </body>
+</html>
diff --git a/comm/mail/base/test/browser/files/links.html b/comm/mail/base/test/browser/files/links.html
new file mode 100644
index 0000000000..f5703dc4ef
--- /dev/null
+++ b/comm/mail/base/test/browser/files/links.html
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8"/>
+ <title>Links to other places</title>
+</head>
+<body>
+ <h1>Links to things</h1>
+ <p>This page is a test of what happens when you click on links. It should be loaded from http://example.org:80.</p>
+
+ <h2>This page:</h2>
+ <ul>
+ <li><a id="this-hash" href="#hash">Anchor on this page</a></li>
+ <li><a id="this-nohash" href="links.html">This page</a></li>
+ </ul>
+
+ <h2>Pages on this domain:</h2>
+ <ul>
+ <li><a id="local-here" href="sampleContent.html">A page in the same directory</a></li>
+ <li><a id="local-elsewhere" href="/browser/comm/mail/components/extensions/test/browser/data/content.html">A page elsewhere</a></li>
+ </ul>
+
+ <h2>Pages on other places on this TLD:</h2>
+ <ul>
+ <li><a id="other-https" href="https://example.org/browser/comm/mail/base/test/browser/files/links.html">This page, but over HTTPS</a></li>
+ <li><a id="other-port" href="http://example.org:8000/browser/comm/mail/base/test/browser/files/links.html">This page, but on example.com:8000</a></li>
+ <li><a id="other-subdomain" href="http://test1.example.org/browser/comm/mail/base/test/browser/files/links.html">This page, but on test1.example.com</a></li>
+ <li><a id="other-subsubdomain" href="http://sub1.test1.example.org/browser/comm/mail/base/test/browser/files/links.html">This page, but on sub1.test1.example.com</a></li>
+ </ul>
+
+ <h2>Pages on a completely different domain:</h2>
+ <ul style="margin-bottom: 100vh;">
+ <li><a id="other-domain" href="http://mochi.test:8888/browser/comm/mail/base/test/browser/files/links.html">This page, but on mochi.test</a></li>
+ </ul>
+
+ <h2 id="hash">This is the hash target!</h2>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/menulist.xhtml b/comm/mail/base/test/browser/files/menulist.xhtml
new file mode 100644
index 0000000000..cba2bbcf86
--- /dev/null
+++ b/comm/mail/base/test/browser/files/menulist.xhtml
@@ -0,0 +1,30 @@
+<?xml version="1.0"?>
+<?xml-stylesheet type="text/css" href="chrome://global/skin/global.css"?>
+<?xml-stylesheet type="text/css" href="chrome://messenger/skin/menulist.css"?>
+
+<window align="start" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" xmlns:html="http://www.w3.org/1999/xhtml">
+ <button id="before" label="I'm just a button" onclick="alert('I\'m a button!')"/>
+
+ <menulist>
+ <menupopup>
+ <menuitem value="foo" label="foo"/>
+ <menuitem value="bar" label="bar"/>
+ </menupopup>
+ </menulist>
+
+ <menulist is="menulist-editable">
+ <menupopup>
+ <menuitem value="foo" label="foo"/>
+ <menuitem value="bar" label="bar"/>
+ </menupopup>
+ </menulist>
+
+ <menulist is="menulist-editable" editable="true" width="100">
+ <menupopup>
+ <menuitem value="foo" label="foo"/>
+ <menuitem value="bar" label="bar"/>
+ </menupopup>
+ </menulist>
+
+ <button id="after" label="I'm just a button"/>
+</window>
diff --git a/comm/mail/base/test/browser/files/orderableTreeListbox.xhtml b/comm/mail/base/test/browser/files/orderableTreeListbox.xhtml
new file mode 100644
index 0000000000..63154cbce9
--- /dev/null
+++ b/comm/mail/base/test/browser/files/orderableTreeListbox.xhtml
@@ -0,0 +1,171 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the orderable-tree-listbox custom element</title>
+ <style>
+ :focus {
+ outline: 3px blue solid;
+ }
+ html {
+ height: 100%;
+ }
+ body {
+ height: 100%;
+ display: flex;
+ margin: 0;
+ }
+ #list {
+ overflow-y: auto;
+ white-space: nowrap;
+ margin: 1em;
+ border: 1px solid black;
+ width: 400px;
+ outline: none;
+ }
+ @media not (prefers-reduced-motion) {
+ #list {
+ scroll-behavior: smooth;
+ }
+ }
+ ol, ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+ li > div {
+ display: flex;
+ align-items: center;
+ padding: 4px;
+ line-height: 24px;
+ }
+ li.selected > div {
+ color: white;
+ background-color: blue;
+ }
+ li > ul > li > div {
+ padding-inline-start: calc(1em + 8px);
+ }
+ li.collapsed > ul {
+ display: none;
+ }
+ div.twisty {
+ width: 1em;
+ height: 1em;
+ margin-inline-end: 4px;
+ }
+ li.children > div > div.twisty {
+ background-color: green;
+ }
+ li.children.collapsed > div > div.twisty {
+ background-color: red;
+ }
+
+ #list > li {
+ transition: opacity 250ms;
+ }
+ #list > li.dragging {
+ opacity: 0.75;
+ }
+ </style>
+ <!-- This script is used for the automated test. -->
+ <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script>
+ <!-- This script is used when this file is loaded in a browser. -->
+ <script defer="defer" src="../../../content/widgets/tree-listbox.js"></script>
+</head>
+<body>
+ <ol id="list" is="orderable-tree-listbox" role="tree">
+ <li id="row-1">
+ <div draggable="true">
+ <div class="twisty"></div>
+ Item 1
+ </div>
+ </li>
+ <li id="row-2">
+ <div draggable="true">
+ <div class="twisty"></div>
+ Item 2
+ </div>
+ <ul>
+ <li id="row-2-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="row-2-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="row-3">
+ <div draggable="true">
+ <div class="twisty"></div>
+ Item 3
+ </div>
+ <ul>
+ <li id="row-3-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="row-3-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ <li id="row-3-3">
+ <div>
+ <div class="twisty"></div>
+ Third child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="row-4">
+ <div draggable="true">
+ <div class="twisty"></div>
+ Item 4
+ </div>
+ </li>
+ <li id="row-5">
+ <div draggable="true">
+ <div class="twisty"></div>
+ Item 5
+ </div>
+ <ul>
+ <li id="row-5-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="row-5-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ </ul>
+ </li>
+ </ol>
+
+ <div id="marker" style="position: absolute; left: 500px; border-top: 1px red solid;"></div>
+ <script>
+ function moveMarker(event) {
+ let marker = document.getElementById("marker");
+ marker.style.top = `${event.clientY}px`;
+ marker.textContent = `${event.type} event here`;
+ }
+
+ document.addEventListener("dragstart", moveMarker);
+ document.addEventListener("dragover", moveMarker);
+ document.addEventListener("drop", moveMarker);
+ </script>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/paneSplitter.xhtml b/comm/mail/base/test/browser/files/paneSplitter.xhtml
new file mode 100644
index 0000000000..7d25e5596e
--- /dev/null
+++ b/comm/mail/base/test/browser/files/paneSplitter.xhtml
@@ -0,0 +1,122 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the pane-splitter custom element</title>
+ <style>
+ hr[is="pane-splitter"] {
+ margin: 0 -3px;
+ border: none;
+ z-index: 1;
+ cursor: ew-resize;
+ opacity: .4;
+ background-color: red;
+ }
+
+ #splitter3,
+ #splitter4 {
+ margin: -3px 0;
+ cursor: ns-resize;
+ }
+
+ #horizontal-before {
+ display: grid;
+ grid-template-columns: minmax(auto, var(--splitter1-width)) 0 auto;
+ width: 500px;
+ height: 100px;
+ --splitter1-width: 200px;
+ margin: 1em;
+ }
+
+ #horizontal-after {
+ display: grid;
+ grid-template-columns: auto 0 minmax(auto, var(--splitter2-width));
+ width: 500px;
+ height: 100px;
+ --splitter2-width: 200px;
+ margin: 1em;
+ }
+
+ #vertical-before {
+ display: inline-grid;
+ grid-template-rows: minmax(auto, var(--splitter3-height)) 0 auto;
+ width: 100px;
+ height: 500px;
+ --splitter3-height: 200px;
+ margin: 1em;
+ }
+
+ #vertical-after {
+ display: inline-grid;
+ grid-template-rows: auto 0 minmax(auto, var(--splitter4-height));
+ width: 100px;
+ height: 500px;
+ --splitter4-height: 200px;
+ margin: 1em;
+ }
+
+ .resized {
+ background-color: lightblue;
+ }
+
+ .fill {
+ background-color: lightslategrey;
+ }
+ </style>
+ <!-- This path is used for the automated test. -->
+ <script src="chrome://messenger/content/pane-splitter.js"></script>
+ <!-- This path is used when this file is loaded in a browser. -->
+ <script src="../../../content/widgets/pane-splitter.js"></script>
+ <script>
+ function moveMarker(event) {
+ let markerX = document.getElementById("markerX");
+ markerX.style.left = `${event.clientX + window.scrollX}px`;
+ markerX.textContent = `${event.type} event here`;
+
+ let markerY = document.getElementById("markerY");
+ markerY.style.top = `${event.clientY + window.scrollY}px`;
+ markerY.textContent = `${event.type} event here`;
+ }
+
+ document.addEventListener("mousedown", moveMarker);
+ document.addEventListener("mousemove", moveMarker);
+ document.addEventListener("mouseup", moveMarker);
+
+ window.addEventListener("load", () => {
+ for (let splitter of document.querySelectorAll('hr[is="pane-splitter"]')) {
+ splitter.resizeElement = splitter.parentNode.querySelector(".resized");
+ }
+ });
+ </script>
+</head>
+<body>
+ <div id="horizontal-before">
+ <div id="splitter1-before" class="resized"></div>
+ <hr is="pane-splitter" id="splitter1" resize-direction="horizontal" />
+ <div id="splitter1-after" class="fill"></div>
+ </div>
+
+ <div id="horizontal-after">
+ <div id="splitter2-before" class="fill"></div>
+ <hr is="pane-splitter" id="splitter2" resize="next" resize-direction="horizontal" />
+ <div id="splitter2-after" class="resized"></div>
+ </div>
+
+ <div style="display: flex;">
+ <div id="vertical-before">
+ <div id="splitter3-before" class="resized"></div>
+ <hr is="pane-splitter" id="splitter3" />
+ <div id="splitter3-after" class="fill"></div>
+ </div>
+
+ <div id="vertical-after">
+ <div id="splitter4-before" class="fill"></div>
+ <hr is="pane-splitter" id="splitter4" resize="next" />
+ <div id="splitter4-after" class="resized"></div>
+ </div>
+ </div>
+
+ <div id="markerX" style="position: absolute; top: 0px; border-left: 1px red solid;"></div>
+ <div id="markerY" style="position: absolute; left: 550px; border-top: 1px red solid;"></div>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/rss.xml b/comm/mail/base/test/browser/files/rss.xml
new file mode 100644
index 0000000000..8ff0540a66
--- /dev/null
+++ b/comm/mail/base/test/browser/files/rss.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+ <channel>
+ <title>Test Feed</title>
+ <link>https://example.org/</link>
+ <description></description>
+ <lastBuildDate>Thu, 21 Jan 2021 17:57:54 +0000</lastBuildDate>
+ <language>en-US</language>
+
+ <item>
+ <title>Test Article</title>
+ <link>https://example.org/browser/comm/mail/base/test/browser/files/sampleContent.html</link>
+ <pubDate>Wed, 20 Jan 2021 17:00:39 +0000</pubDate>
+ </item>
+ </channel>
+</rss>
diff --git a/comm/mail/base/test/browser/files/sampleContent.eml b/comm/mail/base/test/browser/files/sampleContent.eml
new file mode 100644
index 0000000000..f0465ad2bd
--- /dev/null
+++ b/comm/mail/base/test/browser/files/sampleContent.eml
@@ -0,0 +1,160 @@
+From andy@anway.invalid
+Content-Type: multipart/related;
+ boundary="--------------CHOPCHOP0"
+Subject: Big Meeting Today
+From: "Andy Anway" <andy@anway.invalid>
+To: "Bob Bell" <bob@bell.invalid>
+Message-Id: <0@made.up.invalid>
+Date: Tue, 01 Feb 2000 00:00:00 +1300
+
+This is a multi-part message in MIME format.
+----------------CHOPCHOP0
+Content-Type: text/html; charset=ISO-8859-1; format=flowed
+Content-Transfer-Encoding: 7bit
+
+<!DOCTYPE html>
+<html>
+ <head>
+ <link rel="icon" href="http://mochi.test:8888/browser/comm/mail/base/test/browser/files/tb-logo.png" />
+ </head>
+ <body>
+ <p>This is a page of sample content for tests.</p>
+ <p><a href="https://www.thunderbird.net/">Link to a web page</a></p>
+ <form>
+ <input type="text" />
+ </form>
+ <p><img src="cid:logo" width="304" height="84" /></p>
+ </body>
+</html>
+
+----------------CHOPCHOP0
+Content-Type: image/png; charset=ISO-8859-1; format=flowed;
+ name="tb-logo.png"
+Content-Transfer-Encoding: base64
+Content-Disposition: attachment;
+ filename="tb-logo.png"
+Content-ID: <logo>
+
+iVBORw0KGgoAAAANSUhEUgAAATAAAABUCAMAAAAyN5s5AAAC91BMVEVMaXErLDVPU1wYFx1O
+T1RPT1RNT1MTExoFBwZNTlNOTlNNT1RNTlNMTFRNTlMGBgoDBgYFBQZNTlQGBgdOTlNNTlMA
+AwMEBQVNTlNOUFRMTFUAAABNTlMEBgdNT1QFBwoEBAZOTlNNTlMuOoNNTlQACghNTlRNTlNO
+T1RNTlMTDytNT1N/qs1NTlMxPolNTlSZydyfmI8pMHosPIYaFSFNTlMdEkRNUFVNTlMrM38U
+EBczT5hNTlMtNX8UDy8+XqIqN4AkGmCTiIEwN4JHaKgRCygnLHdzmsNYdq2Fd3N2nMRvlL8g
+G00RCi0TCzAsQYsqHGJMbq5pjLsWDj9HYqV/qdh6nsxpYFljhsL////7+PL38+z59u////z8
++/f8+vT28ufz7+UoG2D+/fnx7ODu6NvRyb3g2c7k39bSzcXr5NbIvq/Z0si9sqLJw7jDuKjb
+0cCJhYHOxbbn3s/n5eGjn5mtqKPY1M+0r6rl2MS9ubVwbW54dXZgXl5HREZnZWHHxcJWVlU/
+PjevpJXazbR/fX0cJGV/d29UaaFPTka5rZx0b2P/++T77c9UVEphW01BQFtXTl6QnavN3exg
+hcBIa65bgL0rS5dOcbGKt9NKZ6dPdLZdhbk8ZKlDcbNVd7RWe7l0pcszX6c5WpyKvN5EYaI9
+aK8yWaNJdrRff7OBsdc0RZUvUppukr8xN3knRZItLXkoUqFiisUoPIlsnMVpirxKfcdekL+Z
+yusoQY4EAQktOIQOBVVWicRBaqgqMmp3msRTgr40VJ1SiLhAbrFwmc1glc5DWaEqMoBRcKtp
+kMh0ntRMfrlHdronKVek1vYVCzd/ptg7aaEYGG83UZM6X6JDc6taea1cZXwkIUJDV5kLAyW3
+6P2PwOlhp+A4QIIcDg8+TpYoIXQiFVY0SYuClLAFABlZKzUdFWgyN4zU//82MTUrIGkpHWVO
+T1SUjoosIm0eK3oSCS89RYwpGjNNQHREVIaXdlSjqrsmJCHZ+P6hTAAuJnRz9RRfAAAAWXRS
+TlMAAw0H2DaeARv9u/JCJ39bSVawUY/kECNSFBsL6jdqQS2nyiBjFnOGWS6XSv75/cP+/Y1X
+gpf+INIxbcWMRrneatP7sn305HhA1FEovMfh4/W3xtjNzNPUx47MeaUAABWdSURBVHhe7JTH
+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
+
+----------------CHOPCHOP0--
+
diff --git a/comm/mail/base/test/browser/files/sampleContent.html b/comm/mail/base/test/browser/files/sampleContent.html
new file mode 100644
index 0000000000..05528ac9f1
--- /dev/null
+++ b/comm/mail/base/test/browser/files/sampleContent.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Sample Content</title>
+ <link rel="icon" href="tb-logo.png" />
+ </head>
+ <body>
+ <p>This is a page of sample content for tests.</p>
+ <p><a href="https://www.thunderbird.net/">Link to a web page</a></p>
+ <form>
+ <input type="text" />
+ </form>
+ <p><img src="tb-logo.png" width="304" height="84" /></p>
+ </body>
+</html>
diff --git a/comm/mail/base/test/browser/files/selectionWidget.js b/comm/mail/base/test/browser/files/selectionWidget.js
new file mode 100644
index 0000000000..b1e5f98e25
--- /dev/null
+++ b/comm/mail/base/test/browser/files/selectionWidget.js
@@ -0,0 +1,225 @@
+/* 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/. */
+
+var { SelectionWidgetController } = ChromeUtils.import(
+ "resource:///modules/SelectionWidgetController.jsm"
+);
+
+/**
+ * Data for a selectable item.
+ *
+ * @typedef {object} ItemData
+ * @property {HTMLElement} element - The DOM node for the item.
+ * @property {boolean} selected - Whether the item is selected.
+ */
+
+class TestSelectionWidget extends HTMLElement {
+ /**
+ * The selectable items for this widget, in DOM ordering.
+ *
+ * @type {ItemData[]}
+ */
+ items = [];
+ #focusItem = this;
+ #controller = null;
+
+ connectedCallback() {
+ let widget = this;
+
+ widget.tabIndex = 0;
+ widget.setAttribute("role", "listbox");
+ widget.setAttribute("aria-label", "Test selection widget");
+ widget.setAttribute(
+ "aria-orientation",
+ widget.getAttribute("layout-direction")
+ );
+ let model = widget.getAttribute("selection-model");
+ widget.setAttribute("aria-multiselectable", model == "browse-multi");
+
+ this.#controller = new SelectionWidgetController(widget, model, {
+ getLayoutDirection() {
+ return widget.getAttribute("layout-direction");
+ },
+ indexFromTarget(target) {
+ for (let i = 0; i < widget.items.length; i++) {
+ if (widget.items[i].element.contains(target)) {
+ return i;
+ }
+ }
+ return null;
+ },
+ getPageSizeDetails() {
+ if (widget.hasAttribute("no-pages")) {
+ return null;
+ }
+ let itemRect = widget.items[0]?.element.getBoundingClientRect();
+ if (widget.getAttribute("layout-direction") == "vertical") {
+ return {
+ itemSize: itemRect?.height ?? null,
+ viewSize: widget.clientHeight,
+ viewOffset: widget.scrollTop,
+ };
+ }
+ return {
+ itemSize: itemRect?.width ?? null,
+ viewSize: widget.clientWidth,
+ viewOffset: Math.abs(widget.scrollLeft),
+ };
+ },
+ setFocusableItem(index, focus) {
+ widget.#focusItem.tabIndex = -1;
+ widget.#focusItem =
+ index == null ? widget : widget.items[index].element;
+ widget.#focusItem.tabIndex = 0;
+ if (focus) {
+ widget.#focusItem.focus();
+ widget.#focusItem.scrollIntoView({
+ block: "nearest",
+ inline: "nearest",
+ });
+ }
+ },
+ setItemSelectionState(index, number, selected) {
+ for (let i = index; i < index + number; i++) {
+ widget.items[i].selected = selected;
+ widget.items[i].element.classList.toggle("selected", selected);
+ widget.items[i].element.setAttribute("aria-selected", selected);
+ }
+ },
+ });
+ }
+
+ #createItemElement(text) {
+ for (let { element } of this.items) {
+ if (element.textContent == text) {
+ throw new Error(`An item with the text "${text}" already exists`);
+ }
+ }
+ let element = this.ownerDocument.createElement("span");
+ element.textContent = text;
+ element.setAttribute("role", "option");
+ element.tabIndex = -1;
+ element.draggable = this.hasAttribute("items-draggable");
+ return element;
+ }
+
+ /**
+ * Create new items and add them to the widget.
+ *
+ * @param {number} index - The starting index at which to add the items.
+ * @param {string[]} textList - The textContent for the items to add. Each
+ * entry in the array will create one item in the same order.
+ */
+ addItems(index, textList) {
+ for (let [i, text] of textList.entries()) {
+ let element = this.#createItemElement(text);
+ this.insertBefore(element, this.items[index + i]?.element ?? null);
+ this.items.splice(index + i, 0, { element });
+ }
+ this.#controller.addedSelectableItems(index, textList.length);
+ // Force re-layout. This is needed for the items to be able to enter the
+ // focus cycle immediately.
+ this.getBoundingClientRect();
+ }
+
+ /**
+ * Remove items from the widget.
+ *
+ * @param {number} index - The starting index at which to remove items.
+ * @param {number} number - How many items to remove.
+ */
+ removeItems(index, number) {
+ this.#controller.removeSelectableItems(index, number, () => {
+ for (let { element } of this.items.splice(index, number)) {
+ element.remove();
+ }
+ });
+ }
+
+ /**
+ * Move items within the widget.
+ *
+ * @param {number} from - The index at which to move items from.
+ * @param {number} to - The index at which to move items to.
+ * @param {number} number - How many items to move.
+ * @param {boolean} reCreate - Whether to recreate the item when
+ * moving it. Otherwise the existing item is used.
+ */
+ moveItems(from, to, number, reCreate) {
+ if (reCreate == undefined) {
+ throw new Error("Missing reCreate argument");
+ }
+ this.#controller.moveSelectableItems(from, to, number, () => {
+ let moving = this.items.splice(from, number);
+ for (let [i, item] of moving.entries()) {
+ item.element.remove();
+ if (reCreate) {
+ let text = item.element.textContent;
+ item = { element: this.#createItemElement(text) };
+ }
+ this.insertBefore(item.element, this.items[to + i]?.element ?? null);
+ this.items.splice(to + i, 0, item);
+ }
+ });
+ }
+
+ /**
+ * Selects a single item via the SelectionWidgetController.selectSingleItem
+ * method.
+ *
+ * @param {number} index - The index of the item to select.
+ */
+ selectSingleItem(index) {
+ this.#controller.selectSingleItem(index);
+ }
+
+ /**
+ * Changes the selection state of an item via the
+ * SelectionWidgetController.setItemSelected method.
+ *
+ * @param {number} index - The index of the item to set the selection state
+ * of.
+ * @param {boolean} select - Whether to select the item.
+ */
+ setItemSelected(index, select) {
+ this.#controller.setItemSelected(index, select);
+ }
+
+ /**
+ * Get the list of selected item's indices.
+ *
+ * @returns {number[]} - The indices for selected items.
+ */
+ selectedIndices() {
+ let indices = [];
+ for (let i = 0; i < this.items.length; i++) {
+ // Assert that the item has a defined selection state set in
+ // setItemSelectionState.
+ if (typeof this.items[i].selected != "boolean") {
+ throw new Error(`Item ${i} has an undefined selection state`);
+ }
+ // Assert that our stored selection state matches that returned by the
+ // controller API.
+ let itemIsSelected = this.#controller.itemIsSelected(i);
+ if (this.items[i].selected != itemIsSelected) {
+ throw new Error(
+ `itemIsSelected(${i}): "${itemIsSelected}" does not match stored selection state "${this.items[i].selected}"`
+ );
+ }
+ if (itemIsSelected) {
+ indices.push(i);
+ }
+ }
+ return indices;
+ }
+
+ /**
+ * Get the return of SelectionWidgetController.getSelectionRanges
+ */
+ getSelectionRanges() {
+ return this.#controller.getSelectionRanges();
+ }
+}
+
+customElements.define("test-selection-widget", TestSelectionWidget);
diff --git a/comm/mail/base/test/browser/files/selectionWidget.xhtml b/comm/mail/base/test/browser/files/selectionWidget.xhtml
new file mode 100644
index 0000000000..e5f66fc30c
--- /dev/null
+++ b/comm/mail/base/test/browser/files/selectionWidget.xhtml
@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for SelectionWidgetController</title>
+ <style>
+ test-selection-widget {
+ display: flex;
+ align-items: start;
+ border: 1px solid black;
+ width: 600px;
+ height: 600px;
+ overflow: auto;
+ }
+
+ test-selection-widget[layout-direction="vertical"] {
+ flex-direction: column;
+ }
+
+ /* Fit 20 items in the view. */
+ test-selection-widget[layout-direction="vertical"] > * {
+ height: 30px;
+ }
+ test-selection-widget[layout-direction="horizontal"] > * {
+ width: 30px;
+ writing-mode: vertical-rl;
+ }
+
+ test-selection-widget > * {
+ padding-inline: 10px;
+ box-sizing: border-box;
+ border: 1px solid grey;
+ white-space: nowrap;
+ flex: 0 0 auto;
+ }
+
+ .selected {
+ background: pink;
+ }
+
+ :focus {
+ outline: 3px dashed black;
+ outline-offset: -3px;
+ }
+
+ :focus-visible {
+ outline-color: blue;
+ }
+ </style>
+ <!-- Load the SelectionWidgetController class inline if testing in a browser.
+ <script src="../../../../modules/SelectionWidgetController.jsm"></script>
+ -->
+ <script defer="defer" src="selectionWidget.js"></script>
+</head>
+<body>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/tb-logo.png b/comm/mail/base/test/browser/files/tb-logo.png
new file mode 100644
index 0000000000..aac56e2546
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tb-logo.png
Binary files differ
diff --git a/comm/mail/base/test/browser/files/tree-element-test-common.js b/comm/mail/base/test/browser/files/tree-element-test-common.js
new file mode 100644
index 0000000000..6f22962aca
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-common.js
@@ -0,0 +1,73 @@
+/* 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/. */
+
+// FIXME: Wrap the whole method around the document load listener to prevent the
+// undefined state of the "tree-view-table-row" element. This is due to the .mjs
+// nature of the class file.
+window.addEventListener("load", () => {
+ class AlternativeCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 80;
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.cell = this.appendChild(document.createElement("td"));
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ set index(index) {
+ super.index = index;
+ this.cell.textContent = this.view.getCellText(index, {
+ id: "GeneratedName",
+ });
+ }
+ }
+ customElements.define("alternative-row", AlternativeCardRow, {
+ extends: "tr",
+ });
+
+ class TestView {
+ values = [];
+
+ constructor(start, count) {
+ for (let i = start; i < start + count; i++) {
+ this.values.push(i);
+ }
+ }
+
+ get rowCount() {
+ return this.values.length;
+ }
+
+ getCellText(index, column) {
+ return `${column.id} ${this.values[index]}`;
+ }
+
+ isContainer() {
+ return false;
+ }
+
+ isContainerOpen() {
+ return false;
+ }
+
+ selectionChanged() {}
+
+ setTree() {}
+ }
+
+ const tree = document.getElementById("testTree");
+ tree.table.setBodyID("testBody");
+ tree.addEventListener("select", () => {
+ console.log("select event, selected indices:", tree.selectedIndices);
+ });
+ tree.view = new TestView(0, 150);
+});
diff --git a/comm/mail/base/test/browser/files/tree-element-test-header.js b/comm/mail/base/test/browser/files/tree-element-test-header.js
new file mode 100644
index 0000000000..37d3b583e4
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-header.js
@@ -0,0 +1,64 @@
+/* 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/. */
+
+// FIXME: Wrap the whole method around the document load listener to prevent the
+// undefined state of the "tree-view-table-row" element. This is due to the .mjs
+// nature of the class file.
+window.addEventListener("load", () => {
+ class TestCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 50;
+
+ static COLUMNS = [
+ {
+ id: "testCol",
+ // Ensure that a table header is rendered in order to verify that the
+ // header's presence doesn't cause issues with scroll calculations.
+ l10n: {
+ header: "threadpane-column-header-subject",
+ menuitem: "threadpane-column-label-subject",
+ },
+ },
+ ];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.cell = this.appendChild(document.createElement("td"));
+ let container = this.cell.appendChild(document.createElement("div"));
+
+ this.d1 = container.appendChild(document.createElement("div"));
+ this.d1.classList.add("d1");
+
+ this.d2 = this.d1.appendChild(document.createElement("div"));
+ this.d2.classList.add("d2");
+
+ this.d3 = this.d1.appendChild(document.createElement("div"));
+ this.d3.classList.add("d3");
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ set index(index) {
+ super.index = index;
+ this.d2.textContent = this.view.getCellText(index, {
+ id: "GeneratedName",
+ });
+ this.d3.textContent = this.view.getCellText(index, {
+ id: "PrimaryEmail",
+ });
+ this.dataset.value = this.view.values[index];
+ }
+ }
+ customElements.define("test-row", TestCardRow, { extends: "tr" });
+
+ const tree = document.getElementById("testTree");
+ tree.setAttribute("rows", "test-row");
+ tree.table.setColumns(TestCardRow.COLUMNS);
+});
diff --git a/comm/mail/base/test/browser/files/tree-element-test-header.xhtml b/comm/mail/base/test/browser/files/tree-element-test-header.xhtml
new file mode 100644
index 0000000000..522e3e5c60
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-header.xhtml
@@ -0,0 +1,61 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the tree-view custom element</title>
+ <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" />
+ <!-- Localization is necessary for the table header to display text. -->
+ <link rel="localization" href="messenger/about3Pane.ftl" />
+ <style>
+ :root {
+ --color-gray-20: gray;
+ --selected-item-color: rebeccapurple;
+ --selected-item-text-color: white;
+ }
+
+ /* We want a total visible row area of 630px, but we need to account for the
+ * height of the header as well. */
+ #testTree {
+ height: calc(var(--tree-header-table-height) + 630px);
+ }
+
+ .tree-view-scrollable-container {
+ scroll-behavior: unset;
+ }
+
+ tr[is="test-row"] td > div {
+ display: flex;
+ align-items: center;
+ box-sizing: border-box;
+ }
+
+ tr[is="test-row"] td div.d1 {
+ flex: 1;
+ }
+
+ tr[is="test-row"] td div.d1 > div.d2 {
+ line-height: 1.2;
+ }
+
+ tr[is="test-row"] td div.d1 > div.d3 {
+ line-height: 1.2;
+ font-size: 13px;
+ }
+ </style>
+ <script type="module" src="chrome://messenger/content/tree-view.mjs"></script>
+ <script src="tree-element-test-header.js"></script>
+ <script src="tree-element-test-common.js"></script>
+</head>
+<!-- We force layout-table in order to ensure that table header rows are
+ displayed.-->
+<body class="layout-table">
+ <input id="before" placeholder="something to focus on" />
+ <tree-view id="testTree" data-select-delay="250"/>
+ <input id="after" placeholder="something to focus on" />
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/tree-element-test-levels.js b/comm/mail/base/test/browser/files/tree-element-test-levels.js
new file mode 100644
index 0000000000..7ea7eb8232
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-levels.js
@@ -0,0 +1,118 @@
+/* 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/. */
+
+/* globals PROTO_TREE_VIEW */
+
+// FIXME: Wrap the whole method around the document load listener to prevent the
+// undefined state of the "tree-view-table-row" element. This is due to the .mjs
+// nature of the class file.
+window.addEventListener("load", () => {
+ class TestCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 30;
+
+ static COLUMNS = [
+ {
+ id: "testCol",
+ },
+ ];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.cell = this.appendChild(document.createElement("td"));
+ let container = this.cell.appendChild(document.createElement("div"));
+
+ this.threader = container.appendChild(document.createElement("button"));
+ this.threader.textContent = "↳";
+ this.threader.classList.add("tree-button-thread");
+
+ this.twisty = container.appendChild(document.createElement("div"));
+ this.twisty.textContent = "v";
+ this.twisty.classList.add("twisty");
+
+ this.d2 = container.appendChild(document.createElement("div"));
+ this.d2.classList.add("d2");
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ set index(index) {
+ super.index = index;
+ this.id = this.view.getRowProperties(index);
+ this.classList.remove("level0", "level1", "level2");
+ this.classList.add(`level${this.view.getLevel(index)}`);
+ this.d2.textContent = this.view.getCellText(index, { id: "text" });
+ }
+ }
+ customElements.define("test-row", TestCardRow, { extends: "tr" });
+
+ class TreeItem {
+ _children = [];
+
+ constructor(id, text, open = false, level = 0) {
+ this._id = id;
+ this._text = text;
+ this._open = open;
+ this._level = level;
+ }
+
+ getText() {
+ return this._text;
+ }
+
+ get open() {
+ return this._open;
+ }
+
+ get level() {
+ return this._level;
+ }
+
+ get children() {
+ return this._children;
+ }
+
+ getProperties() {
+ return this._id;
+ }
+
+ addChild(treeItem) {
+ treeItem._parent = this;
+ treeItem._level = this._level + 1;
+ this.children.push(treeItem);
+ }
+ }
+
+ let testView = new PROTO_TREE_VIEW();
+ testView._rowMap.push(new TreeItem("row-1", "Item with no children"));
+ testView._rowMap.push(new TreeItem("row-2", "Item with children"));
+ testView._rowMap.push(new TreeItem("row-3", "Item with grandchildren"));
+ testView._rowMap[1].addChild(new TreeItem("row-2-1", "First child"));
+ testView._rowMap[1].addChild(new TreeItem("row-2-2", "Second child"));
+ testView._rowMap[2].addChild(new TreeItem("row-3-1", "First child"));
+ testView._rowMap[2].children[0].addChild(
+ new TreeItem("row-3-1-1", "First grandchild")
+ );
+ testView._rowMap[2].children[0].addChild(
+ new TreeItem("row-3-1-2", "Second grandchild")
+ );
+ testView.toggleOpenState(1);
+ testView.toggleOpenState(4);
+ testView.toggleOpenState(5);
+
+ let tree = document.getElementById("testTree");
+ tree.table.setBodyID("testBody");
+ tree.setAttribute("rows", "test-row");
+ tree.table.setColumns(TestCardRow.COLUMNS);
+ tree.addEventListener("select", () => {
+ console.log("select event, selected indices:", tree.selectedIndices);
+ });
+ tree.view = testView;
+});
diff --git a/comm/mail/base/test/browser/files/tree-element-test-levels.xhtml b/comm/mail/base/test/browser/files/tree-element-test-levels.xhtml
new file mode 100644
index 0000000000..1175887e74
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-levels.xhtml
@@ -0,0 +1,65 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the tree-view custom element</title>
+ <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" />
+ <style>
+ :root {
+ --color-gray-20: gray;
+ --selected-item-color: rebeccapurple;
+ --selected-item-text-color: white;
+ }
+
+ .tree-view-scrollable-container {
+ height: 630px;
+ scroll-behavior: unset;
+ }
+
+ tr[is="test-row"] td > div {
+ display: flex;
+ align-items: center;
+ }
+
+ button.threader {
+ width: 1em;
+ height: 1em;
+ }
+
+ div.twisty {
+ width: 1em;
+ height: 1em;
+ }
+
+ tr[is="test-row"].children button.threader {
+ display: inline-block;
+ }
+
+ tr[is="test-row"] button.threader {
+ display: hidden;
+ }
+
+ tr[is="test-row"].children div.twisty {
+ background-color: green;
+ }
+
+ tr[is="test-row"].children.collapsed div.twisty {
+ background-color: red;
+ }
+
+ tr[is="test-row"].level1 .d2 {
+ padding-inline-start: 1em;
+ }
+
+ tr[is="test-row"].level2 .d2 {
+ padding-inline-start: 2em;
+ }
+ </style>
+ <script type="module" defer="defer" src="chrome://messenger/content/tree-view.mjs"></script>
+ <script defer="defer" src="chrome://messenger/content/jsTreeView.js"></script>
+ <script defer="defer" src="tree-element-test-levels.js"></script>
+</head>
+<body>
+ <tree-view id="testTree" data-select-delay="250"/>
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/tree-element-test-no-header.js b/comm/mail/base/test/browser/files/tree-element-test-no-header.js
new file mode 100644
index 0000000000..8a515be5e2
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-no-header.js
@@ -0,0 +1,58 @@
+/* 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/. */
+
+// FIXME: Wrap the whole method around the document load listener to prevent the
+// undefined state of the "tree-view-table-row" element. This is due to the .mjs
+// nature of the class file.
+window.addEventListener("load", () => {
+ class TestCardRow extends customElements.get("tree-view-table-row") {
+ static ROW_HEIGHT = 50;
+
+ static COLUMNS = [
+ {
+ id: "testCol",
+ },
+ ];
+
+ connectedCallback() {
+ if (this.hasConnected) {
+ return;
+ }
+
+ super.connectedCallback();
+
+ this.cell = this.appendChild(document.createElement("td"));
+ let container = this.cell.appendChild(document.createElement("div"));
+
+ this.d1 = container.appendChild(document.createElement("div"));
+ this.d1.classList.add("d1");
+
+ this.d2 = this.d1.appendChild(document.createElement("div"));
+ this.d2.classList.add("d2");
+
+ this.d3 = this.d1.appendChild(document.createElement("div"));
+ this.d3.classList.add("d3");
+ }
+
+ get index() {
+ return super.index;
+ }
+
+ set index(index) {
+ super.index = index;
+ this.d2.textContent = this.view.getCellText(index, {
+ id: "GeneratedName",
+ });
+ this.d3.textContent = this.view.getCellText(index, {
+ id: "PrimaryEmail",
+ });
+ this.dataset.value = this.view.values[index];
+ }
+ }
+ customElements.define("test-row", TestCardRow, { extends: "tr" });
+
+ const tree = document.getElementById("testTree");
+ tree.setAttribute("rows", "test-row");
+ tree.table.setColumns(TestCardRow.COLUMNS);
+});
diff --git a/comm/mail/base/test/browser/files/tree-element-test-no-header.xhtml b/comm/mail/base/test/browser/files/tree-element-test-no-header.xhtml
new file mode 100644
index 0000000000..7605279ba6
--- /dev/null
+++ b/comm/mail/base/test/browser/files/tree-element-test-no-header.xhtml
@@ -0,0 +1,54 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the tree-view custom element</title>
+ <link rel="stylesheet" href="chrome://messenger/skin/shared/tree-listbox.css" />
+ <style>
+ :root {
+ --color-gray-20: gray;
+ --selected-item-color: rebeccapurple;
+ --selected-item-text-color: white;
+ }
+
+ /* We want a total visible row area of 630px. Intentionally avoid leaving
+ * room for a header. */
+ .tree-view-scrollable-container {
+ height: 630px;
+ scroll-behavior: unset;
+ }
+
+ tr[is="test-row"] td > div {
+ display: flex;
+ align-items: center;
+ box-sizing: border-box;
+ }
+
+ tr[is="test-row"] td div.d1 {
+ flex: 1;
+ }
+
+ tr[is="test-row"] td div.d1 > div.d2 {
+ line-height: 1.2;
+ }
+
+ tr[is="test-row"] td div.d1 > div.d3 {
+ line-height: 1.2;
+ font-size: 13px;
+ }
+ </style>
+ <script type="module" src="chrome://messenger/content/tree-view.mjs"></script>
+ <script src="tree-element-test-no-header.js"></script>
+ <script src="tree-element-test-common.js"></script>
+</head>
+<body>
+ <input id="before" placeholder="something to focus on" />
+ <tree-view id="testTree" data-select-delay="250"/>
+ <input id="after" placeholder="something to focus on" />
+</body>
+</html>
diff --git a/comm/mail/base/test/browser/files/treeListbox.xhtml b/comm/mail/base/test/browser/files/treeListbox.xhtml
new file mode 100644
index 0000000000..a760ca141d
--- /dev/null
+++ b/comm/mail/base/test/browser/files/treeListbox.xhtml
@@ -0,0 +1,390 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <meta charset="utf-8" />
+ <title>Test for the tree-listbox custom element</title>
+ <style>
+ :focus {
+ outline: 3px blue solid;
+ }
+ html {
+ height: 100%;
+ }
+ body {
+ height: 100%;
+ display: flex;
+ margin: 0;
+ }
+ ul[is="tree-listbox"] {
+ overflow-y: auto;
+ white-space: nowrap;
+ }
+ ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+ li > div {
+ display: flex;
+ align-items: center;
+ }
+ li.selected > div {
+ color: white;
+ background-color: blue;
+ }
+ li > ul {
+ padding-inline-start: 1em;
+ }
+ li.collapsed > ul {
+ display: none;
+ }
+ div.twisty {
+ width: 1em;
+ height: 1em;
+ }
+ li.children > div > div.twisty {
+ background-color: green;
+ }
+ li.children.collapsed > div > div.twisty {
+ background-color: red;
+ }
+ li.unselectable > div {
+ background-color: red;
+ }
+ </style>
+ <script defer="defer" src="chrome://messenger/content/tree-listbox.js"></script>
+</head>
+<body>
+ <ul is="tree-listbox" role="tree">
+ <li id="row-1">
+ <div>
+ <div class="twisty"></div>
+ Item with no children
+ </div>
+ </li>
+ <li id="row-2">
+ <div>
+ <div class="twisty"></div>
+ Item with children
+ </div>
+ <ul>
+ <li id="row-2-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="row-2-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="row-3">
+ <div>
+ <div class="twisty"></div>
+ Item with grandchildren
+ </div>
+ <ul>
+ <li id="row-3-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ <ul>
+ <li id="row-3-1-1">
+ <div>
+ <div class="twisty"></div>
+ First grandchild
+ </div>
+ </li>
+ <li id="row-3-1-2">
+ <div>
+ <div class="twisty"></div>
+ Second grandchild
+ </div>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ <template id="rowToAdd">
+ <li id="new-row">
+ <div>
+ <div class="twisty"></div>
+ New row
+ </div>
+ </li>
+ </template>
+ <template id="rowsToAdd">
+ <li id="added-row">
+ <div>
+ <div class="twisty"></div>
+ Added row
+ </div>
+ <ul>
+ <li id="added-row-1">
+ <div>
+ <div class="twisty"></div>
+ Added child
+ </div>
+ <ul>
+ <li id="added-row-1-1">
+ <div>
+ <div class="twisty"></div>
+ Added grandchild
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="added-row-2">
+ <div>
+ <div class="twisty"></div>
+ Added child
+ </div>
+ </li>
+ </ul>
+ </li>
+ </template>
+ <!-- Larger tree for deleting from -->
+ <ul>
+ <li>Before</li>
+ <li>
+ <!-- Place under a plain <li> an <ul> to make sure our selector logic
+ - doesn't break down. -->
+ <ul is="tree-listbox" id="deleteTree" role="tree">
+ <li id="dRow-1" class="collapsed">
+ <div>
+ <div class="twisty"></div>
+ Item with collapsed children
+ </div>
+ <ul>
+ <li id="dRow-1-1">
+ <div>
+ <div class="twisty"></div>
+ Hidden child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-2">
+ <div>
+ <div class="twisty"></div>
+ Item with children
+ </div>
+ <ul>
+ <li id="dRow-2-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="dRow-2-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-3">
+ <div>
+ <div class="twisty"></div>
+ Item with grandchildren
+ </div>
+ <ul>
+ <li id="dRow-3-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ <ul>
+ <li id="dRow-3-1-1" class="collapsed">
+ <div>
+ <div class="twisty"></div>
+ First grandchild
+ </div>
+ <ul>
+ <li id="dRow-3-1-1-1">
+ <div>
+ <div class="twisty"></div>
+ Hidden child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-3-1-2">
+ <div>
+ <div class="twisty"></div>
+ Second grandchild
+ </div>
+ </li>
+ <li id="dRow-3-1-3" class="collapsed">
+ <div>
+ <div class="twisty"></div>
+ Third grandchild
+ </div>
+ <ul>
+ <li id="dRow-3-1-3-1">
+ <div>
+ <div class="twisty"></div>
+ Hidden child
+ </div>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-4">
+ <div>
+ <div class="twisty"></div>
+ Fourth item
+ </div>
+ <ul>
+ <li id="dRow-4-1" class="collapsed">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ <ul>
+ <li id="dRow-4-1-1">
+ <div>
+ <div class="twisty"></div>
+ Hidden child 1
+ </div>
+ </li>
+ <li id="dRow-4-1-2">
+ <div>
+ <div class="twisty"></div>
+ Hidden child 2
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-4-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ <li id="dRow-4-3">
+ <div>
+ <div class="twisty"></div>
+ Third child
+ </div>
+ <ul>
+ <li id="dRow-4-3-1">
+ <div>
+ <div class="twisty"></div>
+ First Grand child
+ </div>
+ </li>
+ <li id="dRow-4-3-2">
+ <div>
+ <div class="twisty"></div>
+ Second Grand child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-4-4" class="collapsed">
+ <div>
+ <div class="twisty"></div>
+ Fourth child
+ </div>
+ <ul>
+ <li id="dRow-4-4-1">
+ <div>
+ <div class="twisty"></div>
+ Hidden child 1
+ </div>
+ </li>
+ <li id="dRow-4-4-2">
+ <div>
+ <div class="twisty"></div>
+ Hidden child 2
+ </div>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-5">
+ <div>
+ <div class="twisty"></div>
+ Second last item
+ </div>
+ <ul>
+ <li id="dRow-5-1">
+ <div>
+ <div class="twisty"></div>
+ Last child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="dRow-6">
+ <div>
+ <div class="twisty"></div>
+ Last item
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li>After</li>
+ </ul>
+ <!-- Tree with unselectable rows -->
+ <ul is="tree-listbox" id="unselectableTree" role="tree">
+ <li id="uRow-1" class="unselectable">
+ <div>Item with no children</div>
+ </li>
+ <li id="uRow-2" class="unselectable">
+ <div>Item with children</div>
+ <ul>
+ <li id="uRow-2-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ </li>
+ <li id="uRow-2-2">
+ <div>
+ <div class="twisty"></div>
+ Second child
+ </div>
+ </li>
+ </ul>
+ </li>
+ <li id="uRow-3" class="unselectable">
+ <div>Item with grandchildren</div>
+ <ul>
+ <li id="uRow-3-1">
+ <div>
+ <div class="twisty"></div>
+ First child
+ </div>
+ <ul>
+ <li id="uRow-3-1-1">
+ <div>
+ <div class="twisty"></div>
+ First grandchild
+ </div>
+ </li>
+ <li id="uRow-3-1-2">
+ <div>
+ <div class="twisty"></div>
+ Second grandchild
+ </div>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </li>
+ </ul>
+</body>
+</html>