chromium/chrome/browser/resources/bookmarks/list.ts

// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'chrome://resources/cr_elements/cr_shared_vars.css.js';
import 'chrome://resources/polymer/v3_0/iron-list/iron-list.js';
import './shared_style.css.js';
import './strings.m.js';
import './item.js';

import {getInstance as getAnnouncerInstance} from 'chrome://resources/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js';
import {ListPropertyUpdateMixin} from 'chrome://resources/cr_elements/list_property_update_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {isMac} from 'chrome://resources/js/platform.js';
import {PluralStringProxyImpl} from 'chrome://resources/js/plural_string_proxy.js';
import {getDeepActiveElement} from 'chrome://resources/js/util.js';
import type {IronListElement} from 'chrome://resources/polymer/v3_0/iron-list/iron-list.js';
import {microTask, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {deselectItems, selectAll, selectItem, updateAnchor} from './actions.js';
import {BookmarksCommandManagerElement} from './command_manager.js';
import {MenuSource} from './constants.js';
import type {BookmarksItemElement} from './item.js';
import {getTemplate} from './list.html.js';
import {StoreClientMixin} from './store_client_mixin.js';
import type {OpenCommandMenuDetail} from './types.js';
import {canReorderChildren, getDisplayedList} from './util.js';

const BookmarksListElementBase =
    StoreClientMixin(ListPropertyUpdateMixin(PolymerElement));

export interface BookmarksListElement {
  $: {
    list: IronListElement,
    message: HTMLDivElement,
  };
}

export class BookmarksListElement extends BookmarksListElementBase {
  static get is() {
    return 'bookmarks-list';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      /**
       * A list of item ids wrapped in an Object. This is necessary because
       * iron-list is unable to distinguish focusing index 6 from focusing id
       * '6' so the item we supply to iron-list needs to be non-index-like.
       */
      displayedList_: {
        type: Array,
        value() {
          // Use an empty list during initialization so that the databinding to
          // hide #list takes effect.
          return [];
        },
      },

      displayedIds_: {
        type: Array,
        observer: 'onDisplayedIdsChanged_',
      },

      searchTerm_: {
        type: String,
        observer: 'onDisplayedListSourceChange_',
      },

      selectedFolder_: {
        type: String,
        observer: 'onDisplayedListSourceChange_',
      },

      selectedItems_: Object,
    };
  }

  private displayedList_: Array<{id: string}>;
  private displayedIds_: string[];
  private eventTracker_: EventTracker = new EventTracker();
  private searchTerm_: string;
  private selectedFolder_: string;
  private selectedItems_: Set<string>;
  private boundOnHighlightItems_: (p1: CustomEvent) => void;

  override ready() {
    super.ready();
    this.addEventListener('click', () => this.deselectItems_());
    this.addEventListener('contextmenu',
                          e => this.onContextMenu_(e as MouseEvent));
    this.addEventListener(
        'open-command-menu',
        e => this.onOpenCommandMenu_(e as CustomEvent<OpenCommandMenuDetail>));
  }

  override connectedCallback() {
    super.connectedCallback();

    const list = this.$.list;
    list.scrollTarget = this;

    this.watch('displayedIds_', function(state) {
      return getDisplayedList(state);
    });
    this.watch('searchTerm_', state => state.search.term);
    this.watch('selectedFolder_', state => state.selectedFolder);
    this.watch('selectedItems_', state => state.selection.items);
    this.updateFromStore();

    this.$.list.addEventListener(
        'keydown', this.onItemKeydown_.bind(this), true);

    this.eventTracker_.add(
        document, 'highlight-items',
        (e: Event) => this.onHighlightItems_(e as CustomEvent<string[]>));
    this.eventTracker_.add(
        document, 'import-began', () => this.onImportBegan_());
    this.eventTracker_.add(
        document, 'import-ended', () => this.onImportEnded_());
  }

  override disconnectedCallback() {
    super.disconnectedCallback();

    this.eventTracker_.remove(document, 'highlight-items');
  }

  getDropTarget(): HTMLElement {
    return this.$.message;
  }

  /**
   * Updates `displayedList_` using splices to be equivalent to `newValue`. This
   * allows the iron-list to delete sublists of items which preserves scroll and
   * focus on incremental update.
   */
  private async onDisplayedIdsChanged_(
      newValue: string[], _oldValue: string[]) {
    const updatedList = newValue.map(id => ({id: id}));
    let skipFocus = false;
    let selectIndex = -1;
    if (this.matches(':focus-within')) {
      if (this.selectedItems_.size > 0) {
        const selectedId = Array.from(this.selectedItems_)[0];
        skipFocus = newValue.some(id => id === selectedId);
        selectIndex =
            this.displayedList_.findIndex(({id}) => selectedId === id);
      }
      if (selectIndex === -1 && updatedList.length > 0) {
        selectIndex = 0;
      } else {
        selectIndex = Math.min(selectIndex, updatedList.length - 1);
      }
    }
    this.updateList(
        'displayedList_', item => (item as {id: string}).id, updatedList);
    // Trigger a layout of the iron list. Otherwise some elements may render
    // as blank entries. See https://crbug.com/848683
    this.$.list.dispatchEvent(
        new CustomEvent('iron-resize', {bubbles: true, composed: true}));
    const label = await PluralStringProxyImpl.getInstance().getPluralString(
        'listChanged', this.displayedList_.length);
    getAnnouncerInstance().announce(label);

    if (!skipFocus && selectIndex > -1) {
      setTimeout(() => {
        this.$.list.focusItem(selectIndex);
        // Focus menu button so 'Undo' is only one tab stop away on delete.
        const item = getDeepActiveElement();
        if (item) {
          (item as BookmarksItemElement).focusMenuButton();
        }
      });
    }
  }

  private onDisplayedListSourceChange_() {
    this.scrollTop = 0;
  }

  /**
   * Scroll the list so that |itemId| is visible, if it is not already.
   */
  private scrollToId_(itemId: string) {
    const index = this.displayedIds_.indexOf(itemId);
    const list = this.$.list;
    if (index >= 0 && index < list.firstVisibleIndex ||
        index > list.lastVisibleIndex) {
      list.scrollToIndex(index);
    }
  }

  private emptyListMessage_(): string {
    let emptyListMessage = 'noSearchResults';
    if (!this.searchTerm_) {
      emptyListMessage =
          canReorderChildren(this.getState(), this.getState().selectedFolder) ?
          'emptyList' :
          'emptyUnmodifiableList';
    }
    return loadTimeData.getString(emptyListMessage);
  }

  private isEmptyList_(): boolean {
    return this.displayedList_.length === 0;
  }

  private deselectItems_() {
    this.dispatch(deselectItems());
  }

  private getIndexForItemElement_(el: HTMLElement): number {
    return (this.$.list.modelForElement(el) as unknown as {index: number})
        .index;
  }

  private onOpenCommandMenu_(e: CustomEvent<{source: MenuSource}>) {
    // If the item is not visible, scroll to it before rendering the menu.
    if (e.detail.source === MenuSource.ITEM) {
      this.scrollToId_((e.composedPath()[0] as BookmarksItemElement).itemId);
    }
  }

  /**
   * Highlight a list of items by selecting them, scrolling them into view and
   * focusing the first item.
   */
  private onHighlightItems_(e: CustomEvent<string[]>) {
    // Ensure that we only select items which are actually being displayed.
    // This should only matter if an unrelated update to the bookmark model
    // happens with the perfect timing to end up in a tracked batch update.
    const toHighlight =
        e.detail.filter((item) => this.displayedIds_.indexOf(item) !== -1);

    if (toHighlight.length <= 0) {
      return;
    }

    const leadId = toHighlight[0]!;
    this.dispatch(selectAll(toHighlight, this.getState(), leadId));

    // Allow iron-list time to render additions to the list.
    microTask.run(() => {
      this.scrollToId_(leadId);
      const leadIndex = this.displayedIds_.indexOf(leadId);
      assert(leadIndex !== -1);
      this.$.list.focusItem(leadIndex);
    });
  }

  private onImportBegan_() {
    getAnnouncerInstance().announce(loadTimeData.getString('importBegan'));
  }

  private onImportEnded_() {
    getAnnouncerInstance().announce(loadTimeData.getString('importEnded'));
  }

  private onItemKeydown_(e: KeyboardEvent) {
    let handled = true;
    const list = this.$.list;
    let focusMoved = false;
    let focusedIndex = this.getIndexForItemElement_(e.target as HTMLElement);
    const oldFocusedIndex = focusedIndex;
    const cursorModifier = isMac ? e.metaKey : e.ctrlKey;
    if (e.key === 'ArrowUp') {
      focusedIndex--;
      focusMoved = true;
    } else if (e.key === 'ArrowDown') {
      focusedIndex++;
      focusMoved = true;
      e.preventDefault();
    } else if (e.key === 'Home') {
      focusedIndex = 0;
      focusMoved = true;
    } else if (e.key === 'End') {
      focusedIndex = list.items!.length - 1;
      focusMoved = true;
    } else if (e.key === ' ' && cursorModifier) {
      this.dispatch(
          selectItem(this.displayedIds_[focusedIndex]!, this.getState(), {
            clear: false,
            range: false,
            toggle: true,
          }));
    } else {
      handled = false;
    }

    if (focusMoved) {
      focusedIndex =
          Math.min(list.items!.length - 1, Math.max(0, focusedIndex));
      list.focusItem(focusedIndex);

      if (cursorModifier && !e.shiftKey) {
        this.dispatch(updateAnchor(this.displayedIds_[focusedIndex]!));
      } else {
        // If shift-selecting with no anchor, use the old focus index.
        if (e.shiftKey && this.getState().selection.anchor === null) {
          this.dispatch(updateAnchor(this.displayedIds_[oldFocusedIndex]!));
        }

        // If the focus moved from something other than a Ctrl + move event,
        // update the selection.
        const config = {
          clear: !cursorModifier,
          range: e.shiftKey,
          toggle: false,
        };

        this.dispatch(selectItem(
            this.displayedIds_[focusedIndex]!, this.getState(), config));
      }
    }

    // Prevent the iron-list from changing focus on enter.
    if (e.key === 'Enter') {
      if ((e.composedPath()[0] as HTMLElement).tagName === 'CR-ICON-BUTTON') {
        return;
      }
      if (e.composedPath()[0] instanceof HTMLButtonElement) {
        handled = true;
      }
    }

    if (!handled) {
      handled = BookmarksCommandManagerElement.getInstance().handleKeyEvent(
          e, this.getState().selection.items);
    }

    if (handled) {
      e.stopPropagation();
    }
  }

  private onContextMenu_(e: MouseEvent) {
    e.preventDefault();
    this.deselectItems_();

    this.dispatchEvent(new CustomEvent('open-command-menu', {
      bubbles: true,
      composed: true,
      detail: {
        x: e.clientX,
        y: e.clientY,
        source: MenuSource.LIST,
      },
    }));
  }

  private getAriaRowindex_(index: number): number {
    return index + 1;
  }

  private getAriaSelected_(id: string): boolean {
    return this.selectedItems_.has(id);
  }

  setDisplayedIdsForTesting(ids: string[]) {
    this.displayedIds_ = ids;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'bookmarks-list': BookmarksListElement;
  }
}

customElements.define(BookmarksListElement.is, BookmarksListElement);