diff options
Diffstat (limited to 'toolkit/components/satchel/megalist/content')
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> |