tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

SidebarTreeView.sys.mjs (7094B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 /**
      6 * A controller that enables selection and keyboard navigation within a "tree"
      7 * view in the sidebar. This tree represents any hierarchical structure of
      8 * URLs, such as those from synced tabs or history visits.
      9 *
     10 * The host component should have the following queries:
     11 * - `cards` for the `<moz-card>` instances of collapsible containers.
     12 *
     13 * @implements {ReactiveController}
     14 */
     15 export class SidebarTreeView {
     16  /**
     17   * All lists that currently have a row selected.
     18   *
     19   * @type {Set<SidebarTabList>}
     20   */
     21  selectedLists;
     22 
     23  constructor(host, { multiSelect = true } = {}) {
     24    this.host = host;
     25    host.addController(this);
     26 
     27    this.multiSelect = multiSelect;
     28    this.selectedLists = new Set();
     29  }
     30 
     31  get cards() {
     32    return this.host.cards;
     33  }
     34 
     35  hostConnected() {
     36    this.host.addEventListener("update-selection", this);
     37    this.host.addEventListener("clear-selection", this);
     38  }
     39 
     40  hostDisconnected() {
     41    this.host.removeEventListener("update-selection", this);
     42    this.host.removeEventListener("clear-selection", this);
     43  }
     44 
     45  /**
     46   * Handle events bubbling up from `<sidebar-tab-list>` elements.
     47   *
     48   * @param {CustomEvent} event
     49   */
     50  handleEvent(event) {
     51    switch (event.type) {
     52      case "update-selection":
     53        this.selectedLists.add(event.originalTarget);
     54        break;
     55      case "clear-selection":
     56        this.selectedLists.delete(event.originalTarget);
     57        this.clearSelection();
     58        break;
     59    }
     60  }
     61 
     62  /**
     63   * Handle keydown event originating from the card header.
     64   *
     65   * @param {KeyboardEvent} event
     66   */
     67  handleCardKeydown(event) {
     68    if (!this.#shouldHandleEvent(event)) {
     69      return;
     70    }
     71    const nextSibling = event.target.nextElementSibling;
     72    const prevSibling = event.target.previousElementSibling;
     73    let focusedRow = null;
     74    switch (event.code) {
     75      case "Tab":
     76        if (prevSibling?.localName === "moz-card") {
     77          event.preventDefault();
     78        }
     79        break;
     80      case "ArrowUp":
     81        if (prevSibling?.localName !== "moz-card") {
     82          this.#focusParentHeader(event.target);
     83          break;
     84        }
     85        if (prevSibling?.expanded) {
     86          focusedRow = this.#focusLastRow(prevSibling);
     87        } else {
     88          prevSibling?.summaryEl?.focus();
     89        }
     90        break;
     91      case "ArrowDown":
     92        if (event.target.expanded) {
     93          focusedRow = this.#focusFirstRow(event.target);
     94        } else if (nextSibling?.localName === "moz-card") {
     95          nextSibling?.summaryEl?.focus();
     96        } else if (event.target.classList.contains("last-card")) {
     97          const outerCard = event.target.parentElement;
     98          const nextOuterCard = outerCard?.nextElementSibling;
     99          nextOuterCard?.summaryEl?.focus();
    100        }
    101        break;
    102      case "ArrowLeft":
    103        if (!event.target.expanded) {
    104          this.#focusParentHeader(event.target);
    105        } else {
    106          event.target.expanded = false;
    107        }
    108        break;
    109      case "ArrowRight":
    110        if (event.target.expanded) {
    111          focusedRow = this.#focusFirstRow(event.target);
    112        } else {
    113          event.target.expanded = true;
    114        }
    115        break;
    116      case "Home":
    117        this.cards[0]?.summaryEl?.focus();
    118        break;
    119      case "End":
    120        this.#focusLastVisibleRow();
    121        break;
    122    }
    123    if (this.multiSelect) {
    124      this.updateSelection(event, focusedRow);
    125    }
    126  }
    127 
    128  /**
    129   * Check if we should handle this event, or if it should be handled by a
    130   * child element such as `<sidebar-tab-list>`.
    131   *
    132   * @param {KeyboardEvent} event
    133   * @returns {boolean}
    134   */
    135  #shouldHandleEvent(event) {
    136    if (event.keyCode === "Home" || event.keyCode === "End") {
    137      // Keys that scroll the entire tree should always be handled.
    138      return true;
    139    }
    140    const headerIsSelected = event.originalTarget === event.target.summaryEl;
    141    return headerIsSelected;
    142  }
    143 
    144  /**
    145   * Focus the first row of this card (either a URL or nested card header).
    146   *
    147   * @param {MozCard} card
    148   * @returns {SidebarTabRow}
    149   */
    150  #focusFirstRow(card) {
    151    let focusedRow = null;
    152    let innerElement = card.contentSlotEl.assignedElements()[0];
    153    if (innerElement.classList.contains("nested-card")) {
    154      // Focus the first nested card header.
    155      innerElement.summaryEl.focus();
    156    } else {
    157      // Focus the first URL.
    158      focusedRow = innerElement.rowEls[0];
    159      focusedRow?.focus();
    160    }
    161    return focusedRow;
    162  }
    163 
    164  /**
    165   * Focus the last row of this card (either a URL or nested card header).
    166   *
    167   * @param {MozCard} card
    168   * @returns {SidebarTabRow}
    169   */
    170  #focusLastRow(card) {
    171    let focusedRow = null;
    172    let innerElement = card.contentSlotEl.assignedElements()[0];
    173    if (innerElement.classList.contains("nested-card")) {
    174      // Focus the last nested card header (or URL, if nested card is expanded).
    175      const lastNestedCard = card.lastElementChild;
    176      if (lastNestedCard.expanded) {
    177        focusedRow = this.#focusLastRow(lastNestedCard);
    178      } else {
    179        lastNestedCard.summaryEl.focus();
    180      }
    181    } else {
    182      // Focus the last URL.
    183      focusedRow = innerElement.rowEls[innerElement.rowEls.length - 1];
    184      focusedRow?.focus();
    185    }
    186    return focusedRow;
    187  }
    188 
    189  /**
    190   * Focus the last visible row of the entire tree.
    191   */
    192  #focusLastVisibleRow() {
    193    const lastCard = this.cards[this.cards.length - 1];
    194    if (
    195      lastCard.classList.contains("nested-card") &&
    196      !lastCard.parentElement.expanded
    197    ) {
    198      // If this is an inner card, and the outer card is collapsed, then focus
    199      // the outer header.
    200      lastCard.parentElement.summaryEl.focus();
    201    } else if (lastCard.expanded) {
    202      this.#focusLastRow(lastCard);
    203    } else {
    204      lastCard.summaryEl.focus();
    205    }
    206  }
    207 
    208  /**
    209   * If we're currently on a nested card, focus the "outer" card's header.
    210   *
    211   * @param {MozCard} card
    212   */
    213  #focusParentHeader(card) {
    214    if (card.classList.contains("nested-card")) {
    215      card.parentElement.summaryEl.focus();
    216    }
    217  }
    218 
    219  /**
    220   * When a row is focused while the shift key is held down, add it to the
    221   * selection. If shift key was not held down, clear the selection.
    222   *
    223   * @param {KeyboardEvent} event
    224   * @param {SidebarTabRow} rowEl
    225   */
    226  updateSelection(event, rowEl) {
    227    if (event.code !== "ArrowUp" && event.code !== "ArrowDown") {
    228      return;
    229    }
    230    if (!event.shiftKey) {
    231      this.clearSelection();
    232      return;
    233    }
    234    if (rowEl != null) {
    235      const listForRow = rowEl.getRootNode().host;
    236      listForRow.selectedGuids.add(rowEl.guid);
    237      listForRow.requestVirtualListUpdate();
    238      this.selectedLists.add(listForRow);
    239    }
    240  }
    241 
    242  /**
    243   * Clear the selection from all lists.
    244   */
    245  clearSelection() {
    246    for (const list of this.selectedLists) {
    247      list.clearSelection();
    248    }
    249    this.selectedLists.clear();
    250  }
    251 }