Generalizes the logic of `getFocusableItemsInList()` from the bookmarks panel. Adds a `getNodesInOrder()` to `SidebarPage`, which allows all pages connected to SidebarTreeView to report their list of focusable items and enable the navigation logic there. Centralizes keyboard dispatch, replacing the parallel `handleCardKeydown()` and per-list `handleFocusElementInRow()` handlers with a single `SidebarTreeView.handleKeydown()`. Differential Revision: https://phabricator.services.mozilla.com/D300295
553 lines
15 KiB
JavaScript
553 lines
15 KiB
JavaScript
/* 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/. */
|
|
|
|
/**
|
|
* Tree view node which can be focused via keyboard navigation.
|
|
*
|
|
* @typedef {object} TreeViewNode
|
|
*
|
|
* @property {"card-summary" | "folder" | "row" | "separator" | "empty-folder"} type
|
|
* @property {Element} [list] - The parent <tab-list>.
|
|
* @property {Element} [card] - The parent <moz-card>.
|
|
* @property {object} [item] - The object from `tabItems`.
|
|
* @property {Element} domNode - The rendered DOM element, or null if not rendered.
|
|
*/
|
|
|
|
/**
|
|
* A controller that enables selection and keyboard navigation within a "tree"
|
|
* view in the sidebar. This tree represents any hierarchical structure of
|
|
* URLs, such as those from synced tabs, history visits, or bookmarks.
|
|
*
|
|
* Selection is keyed on `(list, guid)` because guids are not globally unique
|
|
* across the tree. (The same URL appears in multiple cards, and each card has
|
|
* its own list.) Storing per-list keeps a click in a card from also marking
|
|
* the same URL "selected" in other cards.
|
|
*
|
|
* @implements {ReactiveController}
|
|
*/
|
|
export class SidebarTreeView {
|
|
/**
|
|
* Selected guids per list.
|
|
*
|
|
* @type {Map<SidebarTabList, Set<string>>}
|
|
*/
|
|
selectedRows;
|
|
|
|
/**
|
|
* The anchor row for shift-click range selection. Holds the list and GUID of
|
|
* the last row selected without Shift, defining one end of the range when the
|
|
* user shift-clicks another row.
|
|
*
|
|
* @type {{ list: SidebarTabList, guid: string }}
|
|
*/
|
|
#selectionAnchor = { list: null, guid: null };
|
|
|
|
constructor(host, { multiSelect = true } = {}) {
|
|
this.host = host;
|
|
host.addController(this);
|
|
|
|
this.multiSelect = multiSelect;
|
|
this.selectedRows = new Map();
|
|
}
|
|
|
|
hostConnected() {
|
|
this.host.addEventListener("clear-selection", this);
|
|
this.host.addEventListener("set-anchor", this);
|
|
this.host.addEventListener("shift-select", this);
|
|
this.host.addEventListener("focus-row", this);
|
|
}
|
|
|
|
hostDisconnected() {
|
|
this.host.removeEventListener("clear-selection", this);
|
|
this.host.removeEventListener("set-anchor", this);
|
|
this.host.removeEventListener("shift-select", this);
|
|
this.host.removeEventListener("focus-row", this);
|
|
}
|
|
|
|
/**
|
|
* Visually ordered list of tree view nodes.
|
|
*
|
|
* @returns {TreeViewNode[]}
|
|
*/
|
|
get treeNodes() {
|
|
if (!this._treeNodes) {
|
|
this._treeNodes = this.host.getNodesInOrder();
|
|
}
|
|
return this._treeNodes;
|
|
}
|
|
|
|
hostUpdated() {
|
|
delete this._treeNodes;
|
|
}
|
|
|
|
/**
|
|
* Handle events bubbling up from `<sidebar-tab-list>` elements.
|
|
*
|
|
* @param {CustomEvent} event
|
|
*/
|
|
handleEvent(event) {
|
|
switch (event.type) {
|
|
case "clear-selection":
|
|
this.#clearSelection();
|
|
break;
|
|
case "set-anchor":
|
|
this.#setAnchor(event.originalTarget, event.detail.guid);
|
|
break;
|
|
case "shift-select":
|
|
this.#extendSelection(event.originalTarget, event.detail.row.guid);
|
|
break;
|
|
case "focus-row":
|
|
this.#handleFocusRow(event);
|
|
break;
|
|
}
|
|
}
|
|
|
|
#setAnchor(list, guid) {
|
|
this.#selectionAnchor = { list, guid };
|
|
}
|
|
|
|
#resetAnchor() {
|
|
this.#setAnchor(null, null);
|
|
}
|
|
|
|
isSelected(list, guid) {
|
|
const selection = this.selectedRows.get(list);
|
|
return selection?.has(guid);
|
|
}
|
|
|
|
toggleSelection(list, guid) {
|
|
const selection = this.#getSelectedGuids(list);
|
|
if (selection.has(guid)) {
|
|
selection.delete(guid);
|
|
if (!selection.size) {
|
|
this.selectedRows.delete(list);
|
|
}
|
|
} else {
|
|
selection.add(guid);
|
|
}
|
|
list.requestVirtualListUpdate();
|
|
}
|
|
|
|
selectAllInList(list) {
|
|
const selection = this.#getSelectedGuids(list);
|
|
for (const { guid } of list.tabItems) {
|
|
selection.add(guid);
|
|
}
|
|
list.requestVirtualListUpdate();
|
|
}
|
|
|
|
#getSelectedGuids(list) {
|
|
let selection = this.selectedRows.get(list);
|
|
if (!selection) {
|
|
selection = new Set();
|
|
this.selectedRows.set(list, selection);
|
|
}
|
|
return selection;
|
|
}
|
|
|
|
/**
|
|
* Centralized keyboard handler for the tree view.
|
|
*
|
|
* @param {KeyboardEvent} event
|
|
*/
|
|
handleKeydown(event) {
|
|
const accel = event.getModifierState("Accel");
|
|
if (accel && event.key.toUpperCase() === this.selectAllShortcut) {
|
|
this.#selectAll(event);
|
|
return;
|
|
}
|
|
|
|
const from = event.originalTarget;
|
|
const navigateOpts = {
|
|
shift: event.shiftKey && this.multiSelect,
|
|
keepSelection: accel,
|
|
from,
|
|
};
|
|
switch (event.code) {
|
|
case "ArrowUp":
|
|
event.preventDefault();
|
|
this.#navigate({
|
|
direction: "up",
|
|
...navigateOpts,
|
|
});
|
|
break;
|
|
case "ArrowDown":
|
|
event.preventDefault();
|
|
this.#navigate({
|
|
direction: "down",
|
|
...navigateOpts,
|
|
});
|
|
break;
|
|
case "ArrowLeft":
|
|
event.preventDefault();
|
|
this.#collapseOrMoveUpToHeader(from);
|
|
break;
|
|
case "ArrowRight":
|
|
event.preventDefault();
|
|
if (from.localName === "summary") {
|
|
this.#expandOrMoveDownFromHeader(from);
|
|
}
|
|
break;
|
|
case "Home":
|
|
event.preventDefault();
|
|
this.#navigate({
|
|
direction: "home",
|
|
...navigateOpts,
|
|
});
|
|
break;
|
|
case "End":
|
|
event.preventDefault();
|
|
this.#navigate({
|
|
direction: "end",
|
|
...navigateOpts,
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
|
|
get selectAllShortcut() {
|
|
if (!this._selectAllShortcut) {
|
|
const localization = new Localization(
|
|
["toolkit/global/textActions.ftl"],
|
|
true
|
|
);
|
|
const [message] = localization.formatMessagesSync([
|
|
"text-action-select-all-shortcut",
|
|
]);
|
|
this._selectAllShortcut = message.attributes[0].value;
|
|
}
|
|
return this._selectAllShortcut;
|
|
}
|
|
|
|
/**
|
|
* Select all items from the active list.
|
|
*
|
|
* @param {KeyboardEvent} event
|
|
* Keyboard shortcut which invoked the Select All command.
|
|
*/
|
|
#selectAll(event) {
|
|
if (!this.multiSelect) {
|
|
return;
|
|
}
|
|
const list = event.originalTarget.getRootNode().host;
|
|
if (list?.tabItems) {
|
|
event.preventDefault();
|
|
this.selectAllInList(list);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* If the focused element is expanded, collapse it. Otherwise, focus the
|
|
* nearest containing folder summary or card header.
|
|
*
|
|
* @param {Element} from
|
|
*/
|
|
#collapseOrMoveUpToHeader(from) {
|
|
const node = this.#findNode(this.treeNodes, from);
|
|
const expandStateChanged = node && this.host.setExpanded(node, false);
|
|
if (expandStateChanged) {
|
|
delete this._treeNodes;
|
|
return;
|
|
}
|
|
|
|
const container = from.getRootNode().host;
|
|
|
|
// If we're on a nested card header, move up one level.
|
|
if (container.localName === "moz-card") {
|
|
if (container.classList.contains("nested-card")) {
|
|
this.#focusElement(container.parentElement.summaryEl);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// If we're in a tab list, move up to the closest header.
|
|
const parentDetails = container.closest("details");
|
|
if (parentDetails) {
|
|
this.#focusElement(parentDetails.querySelector("summary"));
|
|
return;
|
|
}
|
|
const parentCard = container.closest("moz-card");
|
|
if (parentCard?.summaryEl) {
|
|
this.#focusElement(parentCard.summaryEl);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* From the card header, expand the card. If is already expanded, focus the
|
|
* first item in the list.
|
|
*
|
|
* @param {Element} header
|
|
*/
|
|
#expandOrMoveDownFromHeader(header) {
|
|
const node = this.#findNode(this.treeNodes, header);
|
|
const expandStateChanged = node && this.host.setExpanded(node, true);
|
|
if (expandStateChanged) {
|
|
delete this._treeNodes;
|
|
return;
|
|
}
|
|
this.#navigate({ direction: "down", keepSelection: true, from: header });
|
|
}
|
|
|
|
/**
|
|
* Focus the DOM element corresponding to a tree view node.
|
|
*
|
|
* @param {TreeViewNode} node
|
|
*/
|
|
#focusNode(node) {
|
|
const el = node.domNode;
|
|
if (el) {
|
|
this.#focusElement(el);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Move focus to an element without scrolling the page, then nudge it into
|
|
* view if the element is offscreen.
|
|
*
|
|
* @param {Element} element
|
|
*/
|
|
#focusElement(element) {
|
|
element.focus({ preventScroll: true });
|
|
element.scrollIntoView({ block: "nearest" });
|
|
}
|
|
|
|
/**
|
|
* Find the tree view node that corresponds to the given DOM element.
|
|
*
|
|
* @param {TreeViewNode[]} nodes
|
|
* @param {Element} element
|
|
* @returns {TreeViewNode}
|
|
*/
|
|
#findNode(nodes, element) {
|
|
const index = this.#findNodeIndex(nodes, element);
|
|
return nodes[index];
|
|
}
|
|
|
|
/**
|
|
* Find the index of the tree view node corresponding to the given DOM
|
|
* element. Returns -1 if no match is found.
|
|
*
|
|
* @param {TreeViewNode[]} nodes
|
|
* @param {Element} element
|
|
* @returns {number}
|
|
*/
|
|
#findNodeIndex(nodes, element) {
|
|
const elementHost = element.getRootNode().host;
|
|
const elementGuid = element.dataset.guid;
|
|
return nodes.findIndex(node => {
|
|
if (node.type === "card-summary") {
|
|
return node.card?.summaryEl === element;
|
|
}
|
|
return node.list === elementHost && node.item.guid === elementGuid;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Add a single guid to a list's selection.
|
|
*
|
|
* @param {SidebarTabList} list
|
|
* @param {string} guid
|
|
*/
|
|
selectRowInList(list, guid) {
|
|
this.#getSelectedGuids(list).add(guid);
|
|
list.requestVirtualListUpdate();
|
|
}
|
|
|
|
/**
|
|
* Select all items between current anchor and target row, in visual order.
|
|
*
|
|
* If no anchor has been set, fall back to selecting just the target row and
|
|
* making it the new anchor.
|
|
*
|
|
* @param {SidebarTabList} targetList
|
|
* @param {string} targetGuid
|
|
*/
|
|
#extendSelection(targetList, targetGuid) {
|
|
const { list: anchorList, guid: anchorGuid } = this.#selectionAnchor;
|
|
|
|
if (!anchorList || !anchorGuid) {
|
|
this.#setAnchor(targetList, targetGuid);
|
|
this.selectRowInList(targetList, targetGuid);
|
|
return;
|
|
}
|
|
|
|
const rows = this.treeNodes.filter(({ type }) => type === "row");
|
|
const anchorIndex = rows.findIndex(
|
|
row => row.list === anchorList && row.item.guid === anchorGuid
|
|
);
|
|
const targetIndex = rows.findIndex(
|
|
row => row.list === targetList && row.item.guid === targetGuid
|
|
);
|
|
|
|
if (anchorIndex === -1 || targetIndex === -1) {
|
|
// Anchor or target isn't reachable in visual order (e.g. anchor's list
|
|
// was destroyed, or the sublist isn't currently rendered). Reset and
|
|
// treat the target as a fresh anchor.
|
|
this.#clearSelection();
|
|
this.#setAnchor(targetList, targetGuid);
|
|
this.selectRowInList(targetList, targetGuid);
|
|
return;
|
|
}
|
|
|
|
const selectedLists = [...this.selectedRows.keys()];
|
|
const listsToUpdate = new Set();
|
|
this.selectedRows.clear();
|
|
|
|
const start = Math.min(anchorIndex, targetIndex);
|
|
const end = Math.max(anchorIndex, targetIndex);
|
|
for (let i = start; i <= end; i++) {
|
|
const { list, item } = rows[i];
|
|
this.#getSelectedGuids(list).add(item.guid);
|
|
listsToUpdate.add(list);
|
|
}
|
|
for (const list of selectedLists) {
|
|
listsToUpdate.add(list);
|
|
}
|
|
for (const list of listsToUpdate) {
|
|
list.requestVirtualListUpdate();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Move keyboard focus to the next or previous node in visual order, and
|
|
* update selection state if the destination is a row.
|
|
*
|
|
* @param {object} options
|
|
* @param {"up" | "down" | "home" | "end"} options.direction
|
|
* @param {boolean} [options.shift]
|
|
* @param {boolean} [options.keepSelection]
|
|
* When true, move focus without touching selection or anchor.
|
|
* @param {Element} options.from
|
|
* Element that received the originating event.
|
|
*/
|
|
async #navigate({ direction, shift = false, keepSelection = false, from }) {
|
|
const nodes = this.treeNodes;
|
|
if (!nodes.length) {
|
|
return;
|
|
}
|
|
const prevSelectionIndex = this.#findNodeIndex(nodes, from);
|
|
const prevSelection = nodes[prevSelectionIndex];
|
|
if (!prevSelection) {
|
|
return;
|
|
}
|
|
|
|
let newIndex;
|
|
let shouldFlushBeforeFocus = false;
|
|
switch (direction) {
|
|
case "home": {
|
|
const scrollContainer = this.host.shadowRoot.querySelector(
|
|
".sidebar-panel-scrollable-content"
|
|
);
|
|
if (scrollContainer) {
|
|
scrollContainer.scrollTop = 0;
|
|
shouldFlushBeforeFocus = true;
|
|
}
|
|
newIndex = 0;
|
|
break;
|
|
}
|
|
case "end": {
|
|
const scrollContainer = this.host.shadowRoot.querySelector(
|
|
".sidebar-panel-scrollable-content"
|
|
);
|
|
if (scrollContainer) {
|
|
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
|
shouldFlushBeforeFocus = true;
|
|
}
|
|
newIndex = nodes.length - 1;
|
|
break;
|
|
}
|
|
case "up":
|
|
newIndex = prevSelectionIndex - 1;
|
|
break;
|
|
case "down":
|
|
newIndex = prevSelectionIndex + 1;
|
|
break;
|
|
}
|
|
|
|
const newSelection = nodes[newIndex];
|
|
if (!newSelection) {
|
|
return;
|
|
}
|
|
// TODO: Double RAF usually gives enough time to re-render before focus,
|
|
// but it's not guaranteed.
|
|
// @see Bug 2037918 - Add virtual list function which scrolls to and focuses the first or last item
|
|
if (shouldFlushBeforeFocus) {
|
|
await new Promise(resolve => {
|
|
const { requestAnimationFrame } = this.host.documentGlobal;
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
|
});
|
|
}
|
|
this.#focusNode(newSelection);
|
|
|
|
if (keepSelection) {
|
|
return;
|
|
}
|
|
|
|
const newSelectionIsRow = newSelection.type === "row";
|
|
if (shift) {
|
|
const boundary = newSelectionIsRow ? newSelection : prevSelection;
|
|
if (boundary.type === "row") {
|
|
this.#extendSelection(boundary.list, boundary.item.guid);
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.#clearSelection();
|
|
if (newSelectionIsRow) {
|
|
this.#setAnchor(newSelection.list, newSelection.item.guid);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the anchor to the focused row if no anchor is currently set.
|
|
*
|
|
* @param {CustomEvent} event
|
|
*/
|
|
#handleFocusRow(event) {
|
|
if (this.#selectionAnchor.guid) {
|
|
return;
|
|
}
|
|
this.#setAnchor(event.originalTarget, event.detail.guid);
|
|
}
|
|
|
|
/**
|
|
* Get all selected tab items across all lists.
|
|
*
|
|
* @returns {object[]}
|
|
*/
|
|
getSelectedTabItems() {
|
|
const items = [];
|
|
for (const [list, guids] of this.selectedRows) {
|
|
for (const item of list.tabItems) {
|
|
if (guids.has(item.guid)) {
|
|
items.push(item);
|
|
}
|
|
}
|
|
}
|
|
return items;
|
|
}
|
|
|
|
clearSelectionForList(list) {
|
|
if (this.#selectionAnchor.list === list) {
|
|
this.#resetAnchor();
|
|
}
|
|
if (this.selectedRows.delete(list)) {
|
|
list.requestVirtualListUpdate();
|
|
}
|
|
}
|
|
|
|
#clearSelection() {
|
|
const listsToUpdate = [...this.selectedRows.keys()];
|
|
this.selectedRows.clear();
|
|
for (const list of listsToUpdate) {
|
|
list.requestVirtualListUpdate();
|
|
}
|
|
}
|
|
|
|
resetSelection() {
|
|
this.#clearSelection();
|
|
this.#resetAnchor();
|
|
}
|
|
}
|