summaryrefslogtreecommitdiffstats
path: root/toolkit/components/satchel/megalist/content
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/satchel/megalist/content')
-rw-r--r--toolkit/components/satchel/megalist/content/MegalistView.mjs477
-rw-r--r--toolkit/components/satchel/megalist/content/VirtualizedList.mjs136
-rw-r--r--toolkit/components/satchel/megalist/content/megalist.css208
-rw-r--r--toolkit/components/satchel/megalist/content/megalist.ftl126
-rw-r--r--toolkit/components/satchel/megalist/content/megalist.html78
-rw-r--r--toolkit/components/satchel/megalist/content/search-input.mjs36
-rw-r--r--toolkit/components/satchel/megalist/content/tests/chrome/chrome.toml3
-rw-r--r--toolkit/components/satchel/megalist/content/tests/chrome/test_virtualized_list.html125
8 files changed, 1189 insertions, 0 deletions
diff --git a/toolkit/components/satchel/megalist/content/MegalistView.mjs b/toolkit/components/satchel/megalist/content/MegalistView.mjs
new file mode 100644
index 0000000000..44a0198692
--- /dev/null
+++ b/toolkit/components/satchel/megalist/content/MegalistView.mjs
@@ -0,0 +1,477 @@
+/* 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/. */
+
+import { html } from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://global/content/megalist/VirtualizedList.mjs";
+
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://global/content/megalist/search-input.mjs";
+
+/**
+ * Map with limit on how many entries it can have.
+ * When over limit entries are added, oldest one are removed.
+ */
+class MostRecentMap {
+ constructor(maxSize) {
+ this.#maxSize = maxSize;
+ }
+
+ get(id) {
+ const data = this.#map.get(id);
+ if (data) {
+ this.#keepAlive(id, data);
+ }
+ return data;
+ }
+
+ has(id) {
+ this.#map.has(id);
+ }
+
+ set(id, data) {
+ this.#keepAlive(id, data);
+ this.#enforceLimits();
+ }
+
+ clear() {
+ this.#map.clear();
+ }
+
+ #maxSize;
+ #map = new Map();
+
+ #keepAlive(id, data) {
+ // Re-insert data to the map so it will be less likely to be evicted
+ this.#map.delete(id);
+ this.#map.set(id, data);
+ }
+
+ #enforceLimits() {
+ // Maps preserve order in which data was inserted,
+ // we use that fact to remove oldest data from it.
+ while (this.#map.size > this.#maxSize) {
+ this.#map.delete(this.#map.keys().next().value);
+ }
+ }
+}
+
+/**
+ * MegalistView presents data pushed to it by the MegalistViewModel and
+ * notify MegalistViewModel of user commands.
+ */
+export class MegalistView extends MozLitElement {
+ static keyToMessage = {
+ ArrowUp: "SelectPreviousSnapshot",
+ ArrowDown: "SelectNextSnapshot",
+ PageUp: "SelectPreviousGroup",
+ PageDown: "SelectNextGroup",
+ Escape: "UpdateFilter",
+ };
+ static LINE_HEIGHT = 64;
+
+ constructor() {
+ super();
+ this.selectedIndex = 0;
+ this.searchText = "";
+
+ window.addEventListener("MessageFromViewModel", ev =>
+ this.#onMessageFromViewModel(ev)
+ );
+ }
+
+ static get properties() {
+ return {
+ listLength: { type: Number },
+ selectedIndex: { type: Number },
+ searchText: { type: String },
+ };
+ }
+
+ /**
+ * View shows list of snapshots of lines stored in the View Model.
+ * View Model provides the first snapshot id in the list and list length.
+ * It's safe to combine firstSnapshotId+index to identify specific snapshot
+ * in the list. When the list changes, View Model will provide a new
+ * list with new first snapshot id (even if the content is the same).
+ */
+ #firstSnapshotId = 0;
+
+ /**
+ * Cache 120 most recently used lines.
+ * View lives in child and View Model in parent processes.
+ * By caching a few lines we reduce the need to send data between processes.
+ * This improves performance in nearby scrolling scenarios.
+ * 7680 is 8K vertical screen resolution.
+ * Typical line is under 1/4KB long, making around 30KB cache requirement.
+ */
+ #snapshotById = new MostRecentMap(7680 / MegalistView.LINE_HEIGHT);
+
+ #templates = {};
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.ownerDocument.addEventListener("keydown", e => this.#handleKeydown(e));
+ for (const template of this.ownerDocument.getElementsByTagName(
+ "template"
+ )) {
+ this.#templates[template.id] = template.content.firstElementChild;
+ }
+ this.#messageToViewModel("Refresh");
+ }
+
+ createLineElement(index) {
+ if (index < 0 || index >= this.listLength) {
+ return null;
+ }
+
+ const snapshotId = this.#firstSnapshotId + index;
+ const lineElement = this.#templates.lineElement.cloneNode(true);
+ lineElement.dataset.id = snapshotId;
+ lineElement.addEventListener("dblclick", e => {
+ this.#messageToViewModel("Command");
+ e.preventDefault();
+ });
+
+ const data = this.#snapshotById.get(snapshotId);
+ if (data !== "Loading") {
+ if (data) {
+ this.#applyData(snapshotId, data, lineElement);
+ } else {
+ // Put placeholder for this snapshot data to avoid requesting it again
+ this.#snapshotById.set(snapshotId, "Loading");
+
+ // Ask for snapshot data from the View Model.
+ // Note: we could have optimized it further by asking for a range of
+ // indices because any scroll in virtualized list can only add
+ // a continuous range at the top or bottom of the visible area.
+ // However, this optimization is not necessary at the moment as
+ // we typically will request under a 100 of lines at a time.
+ // If we feel like making this improvement, we need to enhance
+ // VirtualizedList to request a range of new elements instead.
+ this.#messageToViewModel("RequestSnapshot", { snapshotId });
+ }
+ }
+
+ return lineElement;
+ }
+
+ /**
+ * Find snapshot element on screen and populate it with data
+ */
+ receiveSnapshot({ snapshotId, snapshot }) {
+ this.#snapshotById.set(snapshotId, snapshot);
+
+ const lineElement = this.shadowRoot.querySelector(
+ `.line[data-id="${snapshotId}"]`
+ );
+ if (lineElement) {
+ this.#applyData(snapshotId, snapshot, lineElement);
+ }
+ }
+
+ #applyData(snapshotId, snapshotData, lineElement) {
+ let elementToFocus;
+ const template =
+ this.#templates[snapshotData.template] ?? this.#templates.lineTemplate;
+
+ const lineContent = template.cloneNode(true);
+ lineContent.querySelector(".label").textContent = snapshotData.label;
+
+ const valueElement = lineContent.querySelector(".value");
+ if (valueElement) {
+ const valueText = lineContent.querySelector("span");
+ if (valueText) {
+ valueText.textContent = snapshotData.value;
+ } else {
+ const valueInput = lineContent.querySelector("input");
+ if (valueInput) {
+ valueInput.value = snapshotData.value;
+ valueInput.addEventListener("keydown", e => {
+ switch (e.code) {
+ case "Enter":
+ this.#messageToViewModel("Command", {
+ snapshotId,
+ commandId: "Save",
+ value: valueInput.value,
+ });
+ break;
+ case "Escape":
+ this.#messageToViewModel("Command", {
+ snapshotId,
+ commandId: "Cancel",
+ });
+ break;
+ default:
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ });
+ valueInput.addEventListener("input", () => {
+ // Update local cache so we don't override editing value
+ // while user scrolls up or down a little.
+ const snapshotDataInChild = this.#snapshotById.get(snapshotId);
+ if (snapshotDataInChild) {
+ snapshotDataInChild.value = valueInput.value;
+ }
+ this.#messageToViewModel("Command", {
+ snapshotId,
+ commandId: "EditInProgress",
+ value: valueInput.value,
+ });
+ });
+ elementToFocus = valueInput;
+ } else {
+ valueElement.textContent = snapshotData.value;
+ }
+ }
+
+ if (snapshotData.valueIcon) {
+ const valueIcon = valueElement.querySelector(".icon");
+ if (valueIcon) {
+ valueIcon.src = snapshotData.valueIcon;
+ }
+ }
+
+ if (snapshotData.href) {
+ const linkElement = this.ownerDocument.createElement("a");
+ linkElement.className = valueElement.className;
+ linkElement.href = snapshotData.href;
+ linkElement.replaceChildren(...valueElement.children);
+ valueElement.replaceWith(linkElement);
+ }
+
+ if (snapshotData.stickers?.length) {
+ const stickersElement = lineContent.querySelector(".stickers");
+ for (const sticker of snapshotData.stickers) {
+ const stickerElement = this.ownerDocument.createElement("span");
+ stickerElement.textContent = sticker.label;
+ stickerElement.className = sticker.type;
+ stickersElement.appendChild(stickerElement);
+ }
+ }
+ }
+
+ lineElement.querySelector(".content").replaceWith(lineContent);
+ lineElement.classList.toggle("start", !!snapshotData.start);
+ lineElement.classList.toggle("end", !!snapshotData.end);
+ elementToFocus?.focus();
+ }
+
+ #messageToViewModel(messageName, data) {
+ window.windowGlobalChild
+ .getActor("Megalist")
+ .sendAsyncMessage(messageName, data);
+ }
+
+ #onMessageFromViewModel({ detail }) {
+ const functionName = `receive${detail.name}`;
+ if (!(functionName in this)) {
+ throw new Error(`Received unknown message "${detail.name}"`);
+ }
+ this[functionName](detail.data);
+ }
+
+ receiveUpdateSelection({ selectedIndex }) {
+ this.selectedIndex = selectedIndex;
+ }
+
+ receiveShowSnapshots({ firstSnapshotId, count }) {
+ this.#firstSnapshotId = firstSnapshotId;
+ this.listLength = count;
+
+ // Each new display list starts with the new first snapshot id
+ // so we can forget previously known data.
+ this.#snapshotById.clear();
+ this.shadowRoot.querySelector("virtualized-list").requestRefresh();
+ this.requestUpdate();
+ }
+
+ receiveMegalistUpdateFilter({ searchText }) {
+ this.searchText = searchText;
+ this.requestUpdate();
+ }
+
+ #handleInputChange(e) {
+ const searchText = e.target.value;
+ this.#messageToViewModel("UpdateFilter", { searchText });
+ }
+
+ #handleKeydown(e) {
+ const message = MegalistView.keyToMessage[e.code];
+ if (message) {
+ this.#messageToViewModel(message);
+ e.preventDefault();
+ } else if (e.code == "Enter") {
+ // Do not handle Enter at the virtualized list level when line menu is open
+ if (
+ this.shadowRoot.querySelector(
+ ".line.selected > .menuButton > .menuPopup"
+ )
+ ) {
+ return;
+ }
+
+ if (e.altKey) {
+ // Execute default command1
+ this.#messageToViewModel("Command");
+ } else {
+ // Show line level menu
+ this.shadowRoot
+ .querySelector(".line.selected > .menuButton > button")
+ ?.click();
+ }
+ e.preventDefault();
+ } else if (e.ctrlKey && e.key == "c" && !this.searchText.length) {
+ this.#messageToViewModel("Command", { commandId: "Copy" });
+ e.preventDefault();
+ }
+ }
+
+ #handleClick(e) {
+ const lineElement = e.composedTarget.closest(".line");
+ if (!lineElement) {
+ return;
+ }
+
+ const snapshotId = Number(lineElement.dataset.id);
+ const snapshotData = this.#snapshotById.get(snapshotId);
+ if (!snapshotData) {
+ return;
+ }
+
+ this.#messageToViewModel("SelectSnapshot", { snapshotId });
+ const menuButton = e.composedTarget.closest(".menuButton");
+ if (menuButton) {
+ this.#handleMenuButtonClick(menuButton, snapshotId, snapshotData);
+ }
+
+ e.preventDefault();
+ }
+
+ #handleMenuButtonClick(menuButton, snapshotId, snapshotData) {
+ if (!snapshotData.commands?.length) {
+ return;
+ }
+
+ const popup = this.ownerDocument.createElement("div");
+ popup.className = "menuPopup";
+ popup.addEventListener(
+ "keydown",
+ e => {
+ function focusInternal(next, wrapSelector) {
+ let element = e.composedTarget;
+ do {
+ element = element[next];
+ } while (element && element.tagName != "BUTTON");
+
+ // If we can't find next/prev button, focus the first/last one
+ element ??=
+ e.composedTarget.parentElement.querySelector(wrapSelector);
+ element?.focus();
+ }
+
+ function focusNext() {
+ focusInternal("nextElementSibling", "button");
+ }
+
+ function focusPrev() {
+ focusInternal("previousElementSibling", "button:last-of-type");
+ }
+
+ switch (e.code) {
+ case "Escape":
+ popup.remove();
+ break;
+ case "Tab":
+ if (e.shiftKey) {
+ focusPrev();
+ } else {
+ focusNext();
+ }
+ break;
+ case "ArrowUp":
+ focusPrev();
+ break;
+ case "ArrowDown":
+ focusNext();
+ break;
+ default:
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+ },
+ { capture: true }
+ );
+ popup.addEventListener(
+ "blur",
+ e => {
+ if (
+ e.composedTarget?.closest(".menuPopup") !=
+ e.relatedTarget?.closest(".menuPopup")
+ ) {
+ // TODO: this triggers on macOS before "click" event. Due to this,
+ // we are not receiving the command.
+ popup.remove();
+ }
+ },
+ { capture: true }
+ );
+
+ for (const command of snapshotData.commands) {
+ if (command == "-") {
+ const separator = this.ownerDocument.createElement("div");
+ separator.className = "separator";
+ popup.appendChild(separator);
+ continue;
+ }
+
+ const menuItem = this.ownerDocument.createElement("button");
+ menuItem.textContent = command.label;
+ menuItem.addEventListener("click", e => {
+ this.#messageToViewModel("Command", {
+ snapshotId,
+ commandId: command.id,
+ });
+ popup.remove();
+ e.preventDefault();
+ });
+ popup.appendChild(menuItem);
+ }
+
+ menuButton.querySelector("button").after(popup);
+ popup.querySelector("button")?.focus();
+ }
+
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://global/content/megalist/megalist.css"
+ />
+ <div class="container">
+ <search-input
+ .value=${this.searchText}
+ .change=${e => this.#handleInputChange(e)}
+ >
+ </search-input>
+ <virtualized-list
+ .lineCount=${this.listLength}
+ .lineHeight=${MegalistView.LINE_HEIGHT}
+ .selectedIndex=${this.selectedIndex}
+ .createLineElement=${index => this.createLineElement(index)}
+ @click=${e => this.#handleClick(e)}
+ >
+ </virtualized-list>
+ </div>
+ `;
+ }
+}
+
+customElements.define("megalist-view", MegalistView);
diff --git a/toolkit/components/satchel/megalist/content/VirtualizedList.mjs b/toolkit/components/satchel/megalist/content/VirtualizedList.mjs
new file mode 100644
index 0000000000..7903a189eb
--- /dev/null
+++ b/toolkit/components/satchel/megalist/content/VirtualizedList.mjs
@@ -0,0 +1,136 @@
+/* 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/. */
+
+/**
+ * Virtualized List can efficiently show billions of lines provided
+ * that all of them have the same height.
+ *
+ * Caller is responsible for setting createLineElement(index) function to
+ * create elements as they are scrolled into the view.
+ */
+class VirtualizedList extends HTMLElement {
+ lineHeight = 64;
+ #lineCount = 0;
+
+ get lineCount() {
+ return this.#lineCount;
+ }
+
+ set lineCount(value) {
+ this.#lineCount = value;
+ this.#rebuildVisibleLines();
+ }
+
+ #selectedIndex = 0;
+
+ get selectedIndex() {
+ return this.#selectedIndex;
+ }
+
+ set selectedIndex(value) {
+ this.#selectedIndex = value;
+ if (this.#container) {
+ this.updateLineSelection(true);
+ }
+ }
+
+ #container;
+
+ connectedCallback() {
+ this.#container = this.ownerDocument.createElement("ul");
+ this.#container.classList.add("lines-container");
+ this.appendChild(this.#container);
+
+ this.#rebuildVisibleLines();
+ this.addEventListener("scroll", () => this.#rebuildVisibleLines());
+ }
+
+ requestRefresh() {
+ this.#container.replaceChildren();
+ this.#rebuildVisibleLines();
+ }
+
+ updateLineSelection(scrollIntoView) {
+ const lineElements = this.#container.querySelectorAll(".line");
+ let selectedElement;
+
+ for (let lineElement of lineElements) {
+ let isSelected = Number(lineElement.dataset.index) === this.selectedIndex;
+ if (isSelected) {
+ selectedElement = lineElement;
+ }
+ lineElement.classList.toggle("selected", isSelected);
+ }
+
+ if (scrollIntoView) {
+ if (selectedElement) {
+ selectedElement.scrollIntoView({ block: "nearest" });
+ } else {
+ let selectedTop = this.selectedIndex * this.lineHeight;
+ if (this.scrollTop > selectedTop) {
+ this.scrollTop = selectedTop;
+ } else {
+ this.scrollTop = selectedTop - this.clientHeight + this.lineHeight;
+ }
+ }
+ }
+ }
+
+ #rebuildVisibleLines() {
+ if (!this.isConnected || !this.createLineElement) {
+ return;
+ }
+
+ this.#container.style.height = `${this.lineHeight * this.lineCount}px`;
+
+ let firstLineIndex = Math.floor(this.scrollTop / this.lineHeight);
+ let visibleLineCount = Math.ceil(this.clientHeight / this.lineHeight);
+ let lastLineIndex = firstLineIndex + visibleLineCount;
+ let extraLines = Math.ceil(visibleLineCount / 2); // They are present in DOM, but not visible
+
+ firstLineIndex = Math.max(0, firstLineIndex - extraLines);
+ lastLineIndex = Math.min(this.lineCount, lastLineIndex + extraLines);
+
+ let previousChild = null;
+ let visibleLines = new Map();
+
+ for (let child of Array.from(this.#container.children)) {
+ let index = Number(child.dataset.index);
+ if (index < firstLineIndex || index > lastLineIndex) {
+ child.remove();
+ } else {
+ visibleLines.set(index, child);
+ }
+ }
+
+ for (let index = firstLineIndex; index <= lastLineIndex; index++) {
+ let child = visibleLines.get(index);
+ if (!child) {
+ child = this.createLineElement(index);
+
+ if (!child) {
+ // Friday fix :-)
+ //todo: figure out what was on that Friday and how can we fix it
+ continue;
+ }
+
+ child.style.top = `${index * this.lineHeight}px`;
+ child.dataset.index = index;
+
+ if (previousChild) {
+ previousChild.after(child);
+ } else if (this.#container.firstElementChild?.offsetTop > top) {
+ this.#container.firstElementChild.before(child);
+ } else {
+ this.#container.appendChild(child);
+ }
+ }
+ previousChild = child;
+ }
+
+ this.updateLineSelection(false);
+ }
+}
+
+customElements.define("virtualized-list", VirtualizedList);
diff --git a/toolkit/components/satchel/megalist/content/megalist.css b/toolkit/components/satchel/megalist/content/megalist.css
new file mode 100644
index 0000000000..b442a7b60d
--- /dev/null
+++ b/toolkit/components/satchel/megalist/content/megalist.css
@@ -0,0 +1,208 @@
+/* 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/. */
+
+/* Bug 1869845 - Styles in this file are still experimental! */
+
+.container {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ max-height: 100vh;
+
+ > search-input {
+ margin: 20px;
+ }
+}
+
+virtualized-list {
+ position: relative;
+ overflow: auto;
+ margin: 20px;
+
+ .lines-container {
+ padding-inline-start: unset;
+ }
+}
+
+.line {
+ display: flex;
+ align-items: stretch;
+ position: absolute;
+ width: 100%;
+ user-select: none;
+ box-sizing: border-box;
+ height: 64px;
+
+ background-color: var(--in-content-box-background-odd);
+ border-inline: 1px solid var(--in-content-border-color);
+
+ color: var(--in-content-text-color);
+
+ &.start {
+ border-block-start: 1px solid var(--in-content-border-color);
+ border-start-start-radius: 8px;
+ border-start-end-radius: 8px;
+ }
+
+ &.end {
+ border-block-end: 1px solid var(--in-content-border-color);
+ border-end-start-radius: 8px;
+ border-end-end-radius: 8px;
+ height: 54px;
+ }
+
+ > .menuButton {
+ position: relative;
+ visibility: hidden;
+
+ > button {
+ border: none;
+ margin-inline-start: 2px;
+ padding: 2px;
+ background-color: transparent;
+ /* Fix: too lazy to load the svg */
+ width: 32px;
+ color: unset;
+ }
+
+ > .menuPopup {
+ position: absolute;
+ inset-inline-end: 0;
+ box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2);
+ z-index: 1;
+ background-color: var(--in-content-table-background);
+ padding: 4px;
+
+ > .separator {
+ border-block-start: 1px solid var(--in-content-border-color);
+ margin: 4px 0;
+ }
+
+ > button {
+ text-align: start;
+ border-style: none;
+ padding: 12px;
+ margin-block-end: 2px;
+ width: 100%;
+ text-wrap: nowrap;
+ }
+ }
+ }
+
+ > .content {
+ flex-grow: 1;
+
+ > div {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ padding-inline-start: 10px;
+
+ &:last-child {
+ padding-block-end: 10px;
+ }
+ }
+
+ > .icon {
+ margin-inline-start: 4px;
+ width: 16px;
+ height: 16px;
+ -moz-context-properties: fill;
+ fill: currentColor;
+ }
+
+ > .label {
+ color: var(--text-color-deemphasized);
+ padding-block: 2px 4px;
+ }
+
+ > .value {
+ user-select: text;
+
+ > .icon {
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: auto;
+ height: 16px;
+ margin-inline: 4px;
+ vertical-align: text-bottom;
+ }
+
+ > .icon:not([src]) {
+ display: none;
+ }
+
+ &:is(a) {
+ color: currentColor;
+ }
+ }
+
+ > .stickers {
+ text-align: end;
+ margin-block-start: 2px;
+
+ > span {
+ padding: 2px;
+ margin-inline-end: 2px;
+ }
+
+ /* Hard-coded colors will be addressed in FXCM-1013 */
+ > span.risk {
+ background-color: slateblue;
+ border: 1px solid darkslateblue;
+ color: whitesmoke;
+ }
+
+ > span.warning {
+ background-color: firebrick;
+ border: 1px solid maroon;
+ color: whitesmoke;
+ }
+ }
+
+ &.section {
+ font-size: larger;
+
+ > .label {
+ display: inline-block;
+ margin: 0;
+ color: unset;
+ }
+
+ > .value {
+ margin-inline-end: 8px;
+ text-align: end;
+ font-size: smaller;
+ color: var(--text-color-deemphasized);
+ user-select: unset;
+ }
+ }
+ }
+
+ &.selected {
+ color: var(--in-content-item-selected-text);
+ background-color: var(--in-content-item-selected);
+
+ > .menuButton {
+ visibility: inherit;
+ }
+ }
+
+ &:hover {
+ color: var(--in-content-item-hover-text);
+ background-color: var(--in-content-item-hover);
+
+ > .menuButton {
+ visibility: visible;
+ }
+ }
+}
+
+.search {
+ padding: 8px;
+ border-radius: 4px;
+ border: 1px solid var(--in-content-border-color);
+ box-sizing: border-box;
+ width: 100%;
+}
diff --git a/toolkit/components/satchel/megalist/content/megalist.ftl b/toolkit/components/satchel/megalist/content/megalist.ftl
new file mode 100644
index 0000000000..69d085a7c5
--- /dev/null
+++ b/toolkit/components/satchel/megalist/content/megalist.ftl
@@ -0,0 +1,126 @@
+# 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/.
+
+filter-placeholder =
+ .placeholder = Search Your Data
+ .key = F
+
+## Commands
+
+command-copy = Copy
+command-reveal = Reveal
+command-conceal = Conceal
+command-toggle = Toggle
+command-open = Open
+command-delete = Remove record
+command-edit = Edit
+command-save = Save
+command-cancel = Cancel
+
+## Passwords
+
+passwords-section-label = Passwords
+passwords-disabled = Passwords are disabled
+
+passwords-command-create = Add Password
+passwords-command-import = Import from a File…
+passwords-command-export = Export Passwords…
+passwords-command-remove-all = Remove All Passwords…
+passwords-command-settings = Settings
+passwords-command-help = Help
+
+passwords-import-file-picker-title = Import Passwords
+passwords-import-file-picker-import-button = Import
+
+# A description for the .csv file format that may be shown as the file type
+# filter by the operating system.
+passwords-import-file-picker-csv-filter-title =
+ { PLATFORM() ->
+ [macos] CSV Document
+ *[other] CSV File
+ }
+# A description for the .tsv file format that may be shown as the file type
+# filter by the operating system. TSV is short for 'tab separated values'.
+passwords-import-file-picker-tsv-filter-title =
+ { PLATFORM() ->
+ [macos] TSV Document
+ *[other] TSV File
+ }
+
+# Variables
+# $count (number) - Number of passwords
+passwords-count =
+ { $count ->
+ [one] { $count } password
+ *[other] { $count } passwords
+ }
+
+# Variables
+# $count (number) - Number of filtered passwords
+# $total (number) - Total number of passwords
+passwords-filtered-count =
+ { $total ->
+ [one] { $count } of { $total } password
+ *[other] { $count } of { $total } passwords
+ }
+
+passwords-origin-label = Website address
+passwords-username-label = Username
+passwords-password-label = Password
+
+## Payments
+
+payments-command-create = Add Payment Method
+
+payments-section-label = Payment methods
+payments-disabled = Payments methods are disabled
+
+# Variables
+# $count (number) - Number of payment methods
+payments-count =
+ { $count ->
+ [one] { $count } payment method
+ *[other] { $count } payment methods
+ }
+
+# Variables
+# $count (number) - Number of filtered payment methods
+# $total (number) - Total number of payment methods
+payments-filtered-count =
+ { $total ->
+ [one] { $count } of { $total } payment method
+ *[other] { $count } of { $total } payment methods
+ }
+
+card-number-label = Card Number
+card-expiration-label = Expires on
+card-holder-label = Name on Card
+
+## Addresses
+
+addresses-command-create = Add Address
+
+addresses-section-label = Addresses
+addresses-disabled = Addresses are disabled
+
+# Variables
+# $count (number) - Number of addresses
+addresses-count =
+ { $count ->
+ [one] { $count } address
+ *[other] { $count } addresses
+ }
+
+# Variables
+# $count (number) - Number of filtered addresses
+# $total (number) - Total number of addresses
+addresses-filtered-count =
+ { $total ->
+ [one] { $count } of { $total } address
+ *[other] { $count } of { $total } addresses
+ }
+
+address-name-label = Name
+address-phone-label = Phone
+address-email-label = Email
diff --git a/toolkit/components/satchel/megalist/content/megalist.html b/toolkit/components/satchel/megalist/content/megalist.html
new file mode 100644
index 0000000000..6ff3f089fc
--- /dev/null
+++ b/toolkit/components/satchel/megalist/content/megalist.html
@@ -0,0 +1,78 @@
+<!-- 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 lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta
+ name="viewport"
+ content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
+ />
+ <script
+ type="module"
+ src="chrome://global/content/megalist/MegalistView.mjs"
+ ></script>
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+ <link rel="localization" href="preview/megalist.ftl" />
+ </head>
+
+ <body>
+ <megalist-view></megalist-view>
+
+ <template id="lineElement">
+ <li class="line">
+ <div class="content"></div>
+ <div class="menuButton">
+ <button>…</button>
+ </div>
+ </li>
+ </template>
+
+ <template id="collapsedSectionTemplate">
+ <div class="content section">
+ <img
+ class="icon collapsed"
+ draggable="false"
+ src="chrome://global/skin/icons/arrow-down.svg"
+ />
+ <h4 class="label"></h4>
+ </div>
+ </template>
+
+ <template id="expandedSectionTemplate">
+ <div class="content section">
+ <img
+ class="icon expanded"
+ draggable="false"
+ src="chrome://global/skin/icons/arrow-up.svg"
+ />
+ <h4 class="label"></h4>
+ <div class="value"></div>
+ </div>
+ </template>
+
+ <template id="lineTemplate">
+ <div class="content">
+ <div class="label"></div>
+ <div class="value">
+ <img class="icon" />
+ <span></span>
+ </div>
+ <div class="stickers"></div>
+ </div>
+ </template>
+
+ <template id="editingLineTemplate">
+ <div class="content">
+ <div class="label"></div>
+ <div class="value">
+ <img class="icon" />
+ <input type="text" />
+ </div>
+ <div class="stickers"></div>
+ </div>
+ </template>
+ </body>
+</html>
diff --git a/toolkit/components/satchel/megalist/content/search-input.mjs b/toolkit/components/satchel/megalist/content/search-input.mjs
new file mode 100644
index 0000000000..e30d13ef2a
--- /dev/null
+++ b/toolkit/components/satchel/megalist/content/search-input.mjs
@@ -0,0 +1,36 @@
+/* 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/. */
+
+import { html } from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+export default class SearchInput extends MozLitElement {
+ static get properties() {
+ return {
+ items: { type: Array },
+ change: { type: Function },
+ value: { type: String },
+ };
+ }
+
+ render() {
+ return html` <link
+ rel="stylesheet"
+ href="chrome://global/content/megalist/megalist.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://global/skin/in-content/common.css"
+ />
+ <input
+ class="search"
+ type="search"
+ data-l10n-id="filter-placeholder"
+ @input=${this.change}
+ .value=${this.value}
+ />`;
+ }
+}
+
+customElements.define("search-input", SearchInput);
diff --git a/toolkit/components/satchel/megalist/content/tests/chrome/chrome.toml b/toolkit/components/satchel/megalist/content/tests/chrome/chrome.toml
new file mode 100644
index 0000000000..2d7fd6bccd
--- /dev/null
+++ b/toolkit/components/satchel/megalist/content/tests/chrome/chrome.toml
@@ -0,0 +1,3 @@
+[DEFAULT]
+
+["test_virtualized_list.html"]
diff --git a/toolkit/components/satchel/megalist/content/tests/chrome/test_virtualized_list.html b/toolkit/components/satchel/megalist/content/tests/chrome/test_virtualized_list.html
new file mode 100644
index 0000000000..65ddbcc40b
--- /dev/null
+++ b/toolkit/components/satchel/megalist/content/tests/chrome/test_virtualized_list.html
@@ -0,0 +1,125 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>VirtualizedList Tests</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
+ <link rel="stylesheet" href="chrome://global/content/megalist/megalist.css">
+ <script type="module" src="chrome://global/content/megalist/VirtualizedList.mjs"></script>
+</head>
+<body>
+ <style>
+ </style>
+<p id="display"></p>
+<div id="content">
+ <virtualized-list></virtualized-list>
+</div>
+<pre id="test">
+<script class="testbody" type="application/javascript">
+ const virtualizedList = document.querySelector("virtualized-list");
+
+ function dispatchScrollEvent(target, scrollY) {
+ target.scrollTop = scrollY;
+ virtualizedList.dispatchEvent(new Event('scroll'));
+ }
+
+ function updateVisibleItemBoundaries(visibleItemCount, value) {
+ if (value > visibleItemCount.max) {
+ visibleItemCount.max = value;
+ }
+ }
+
+ // Setup
+ virtualizedList.lineHeight = 64;
+ virtualizedList.lineCount = 1000;
+ virtualizedList.selectedIndex = 0;
+ virtualizedList.createLineElement = index => {
+ const lineElement = document.createElement("div");
+ lineElement.classList.add("line");
+ lineElement.textContent = `Row ${index}`;
+ return lineElement;
+ }
+
+ virtualizedList.style.display = "block";
+ virtualizedList.style.height = "300px";
+ virtualizedList.style.width = "500px";
+
+ /**
+ * Tests that the virtualized list renders expected number of items
+ */
+
+ add_task(async function test_rebuildVisibleLines() {
+ let container = virtualizedList.querySelector(".lines-container");
+ let initialLines = container.querySelectorAll(".line");
+ // Get boundaries of visible item count as they are rendered.
+ let visibleItemsCount = {
+ min: initialLines.length,
+ max: initialLines.length,
+ };
+
+ is(
+ container.style.height,
+ `${virtualizedList.lineHeight * virtualizedList.lineCount}px`,
+ "VirtualizedList is correct height."
+ );
+
+ // Scroll down 800px
+ dispatchScrollEvent(virtualizedList, 800);
+ let newRenderedLines = container.querySelectorAll(".line");
+ updateVisibleItemBoundaries(visibleItemsCount, newRenderedLines.length);
+ let firstRow = container.querySelector(".line[data-index='0']");
+ ok(!firstRow, "The first row should be removed.");
+
+ // Scroll down another 800px
+ dispatchScrollEvent(virtualizedList, 800);
+ newRenderedLines = container.querySelectorAll(".line");
+ updateVisibleItemBoundaries(visibleItemsCount, newRenderedLines.length);
+ let thirdRow = container.querySelector(".line[data-index='2']");
+ ok(!thirdRow, "The third row should be removed.");
+
+ // Check that amount of visible lines is within boundaries. This is to
+ // ensure the list is keeping a range of rendered items and
+ // not increasing the element count in the DOM.
+ ok(
+ newRenderedLines.length >= visibleItemsCount.min &&
+ newRenderedLines.length <= visibleItemsCount.max,
+ "Virtual list is removing and adding lines as needed."
+ );
+
+ // Scroll back to top
+ dispatchScrollEvent(virtualizedList, 0);
+ newRenderedLines = container.querySelectorAll(".line");
+ updateVisibleItemBoundaries(visibleItemsCount, newRenderedLines.length);
+ firstRow = container.querySelector(".line[data-index='0']");
+ thirdRow = container.querySelector(".line[data-index='2']");
+ ok(firstRow, "The first row should be rendered again.");
+ ok(firstRow, "The third row should be rendered again.");
+ });
+
+ /**
+ * Tests that item selection is preserved when list is rebuilt
+ */
+ add_task(async function test_updateLineSelection() {
+ let container = virtualizedList.querySelector(".lines-container");
+ let selectedLine = container.querySelector(".selected");
+ is(selectedLine.dataset.index, "0", "The correct line is selected");
+
+ // Scroll down 800px
+ dispatchScrollEvent(virtualizedList, 800);
+ selectedLine = container.querySelector(".selected");
+ ok(!selectedLine, "Selected line is not rendered because it's out of view");
+ is(virtualizedList.selectedIndex, 0, "Selected line is still preserved in list.");
+
+ // Scroll back to top
+ dispatchScrollEvent(virtualizedList, 0);
+ selectedLine = container.querySelector(".selected");
+ is(selectedLine.dataset.index, "0", "The same selected line is rendered.");
+ });
+
+</script>
+</pre>
+</body>
+</html>