chromium/ash/webui/common/resources/navigation_view_panel.js

// Copyright 2021 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/ash/common/cr_elements/cr_drawer/cr_drawer.js';
import 'chrome://resources/ash/common/cr_elements/icons.html.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import 'chrome://resources/polymer/v3_0/iron-media-query/iron-media-query.js';
import './navigation_shared_vars.css.js';
import './page_toolbar.js';

import {assert} from 'chrome://resources/ash/common/assert.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {SelectorItem} from './navigation_selector.js';
import {getTemplate} from './navigation_view_panel.html.js';

const navigationPageChanged = 'onNavigationPageChanged';

/**
 * @fileoverview
 * 'navigation-view-panel' manages the wiring between a display page and
 * <navigation-selector>.
 *
 * Child pages that are interested in navigation page change events will need to
 * implement a public function "onNavigationPageChanged()" to be notified of the
 * event.
 *
 * To send events between pages, the component that has <navigation-view-panel>
 * must call on "notifyEvent(functionName, params)". |params| is an optional
 * parameter.
 *
 * To provide page components with initial data, include a "initialData" object
 * as part of the "addSelector()" function. Page components will then have an
 * implicit property, details, with the object provided.
 */
export class NavigationViewPanelElement extends PolymerElement {
  static get is() {
    return 'navigation-view-panel';
  }

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

  static get properties() {
    return {
      /**
       * @type {?SelectorItem}
       * Notifies parent elements of the the selected item.
       */
      selectedItem: {
        type: Object,
        observer: 'selectedItemChanged_',
        value: null,
      },

      /**
       * @type {!Array<!SelectorItem>}
       * @private
       */
      selectorItems_: {
        type: Array,
        value: () => [],
      },

      /**
       * This title only appears if |showToolBar| is True. Is otherwise a
       * no-opt if title is set and |showToolbar| is False.
       */
      title: {
        type: String,
        value: '',
      },

      /**
       * If |hasSearch| is True, the toolbar internal widths will be adjusted
       * to fit the search bar when |showNav| is False.
       */
      hasSearch: {
        type: Boolean,
      },

      /**
       * Can only be set to True if specified from the parent element by
       * adding show-banner as an attribute to <navigation-view-panel>. If
       * True, a banner will appear above the 2 column view (sidebar +
       * page). If False, banner grid-area will not show and regular grid
       * layout will be used based on show-tool-bar property.
       * @type {boolean}
       */
      showBanner: {
        type: Boolean,
        value: false,
        reflectToAttribute: true,
      },

      /**
       * Can only be set to True if specified from the parent element by
       * adding show-tool-bar as an attribute to <navigation-view-panel>. If
       * True, a toolbar will appear at the top of the navigation view panel
       * with a 2 column view below it (sidebar + page). If False,
       * navigation view panel will only be a 2 column view (sidebar +
       * page).
       */
      showToolBar: {
        type: Boolean,
        value: false,
      },

      /** @protected {boolean} */
      showNav: {
        type: Boolean,
      },
    };
  }

  /** @override */
  constructor() {
    super();
    window.addEventListener('menu-tap', () => this.onMenuButtonTap_());
    window.addEventListener(
        'navigation-selected', () => this.onNavigationSelected_());

    /**
     * Event callback for 'scroll'.
     * @private {?Function}
     */
    this.scrollClassHandler_ = () => {
      this.onScroll_();
    };
  }

  /** @override */
  connectedCallback() {
    super.connectedCallback();
    window.addEventListener('scroll', this.scrollClassHandler_);
  }

  /**
   * @param {string} name
   * @param {string} pageIs
   * @param {string} icon
   * @param {?string} id
   * @param {?Object} initialData
   * @return {!SelectorItem}
   */
  createSelectorItem(
      name, pageIs, icon = '', id = null, initialData = null) {
    id = id || pageIs;
    return {name, pageIs, icon, id, initialData};
  }

  /**
   * Set the initially active page (defaults to the first selector item),
   * Callers can override this default behavior by providing a
   * query param including the id of a specific page.
   * @protected
   */
  setDefaultPage_() {
    assert(!this.selectedItem);
    const params = new URLSearchParams(window.location.search);

    for (const item of this.selectorItems_) {
      if (params.has(item.id)) {
        this.selectedItem = item;
        return;
      }
    }

    // Default to first entry if query param isn't provided.
    this.selectedItem = this.selectorItems_[0];
  }

  /**
   * @param {!Array<!SelectorItem>} pages
   */
  addSelectors(pages) {
    this.set('selectorItems_', pages);
    this.setDefaultPage_();
  }

  /**
   * Adds a new section to the top level navigation. The name and icon will
   * be displayed in the side navigation. The content panel will create an
   * instance of pageIs when navigated to. If id is null it will default to
   * being equal to pageIs. In the case of adding multiple pages of the same
   * type, id must be specified to distinguish them.
   * @param {string} name
   * @param {string} pageIs
   * @param {string} icon
   * @param {?string} id
   * @param {?Object} initialData
   */
  addSelector(name, pageIs, icon = '', id = null, initialData = null) {
    this.addSelectorItem(
        this.createSelectorItem(name, pageIs, icon, id, initialData));
  }

  /**
   * Adds a new section to the top level navigation. The name and icon will
   * be displayed in the side navigation.
   * @param {!SelectorItem} selectorItem
   */
  addSelectorItem(selectorItem) {
    this.push('selectorItems_', selectorItem);
  }

  /**
   * Removes a section from the top level navigation. If the section is
   * currently selected, the selection will be reset to the top item.
   *
   * @param {string} id The ID of the section to remove.
   */
  removeSelectorById(id) {
    const index =
        this.selectorItems_.findIndex((selector) => selector.id === id);
    if (index < 0) {
      throw new Error('Cannot find selector with ID "' + id + '" to remove.');
    }
    if (this.selectorItems_.length === 1) {
      throw new Error('Removing the last selector is not supported.');
    }
    this.splice('selectorItems_', index, 1);
    if (this.selectedItem && this.selectedItem.id === id) {
      this.selectedItem = this.selectorItems_[0];
    }
  }

  /** @protected */
  selectedItemChanged_() {
    if (!this.selectedItem) {
      return;
    }
    const pageComponent = this.getPage_(this.selectedItem);

    if (this.$.drawer.open) {
      this.$.drawer.close();
    }

    this.showPage_(pageComponent);

    this.notifyEvent(navigationPageChanged);
  }

  /**
   * @param {string} functionName
   * @param {!Object} params
   */
  notifyEvent(functionName, params={}) {
    const components = this.shadowRoot.querySelectorAll('.view-content');
    // Notify all available child pages of the event.
    Array.from(components).map((c) => {
      const functionCall = c[functionName];
      if (typeof functionCall === 'function') {
        if (functionName === navigationPageChanged) {
          const event = {isActive: this.selectedItem.id === c.id};
          functionCall.call(c, event);
        } else {
          functionCall.call(c, params);
        }
      }
    });
  }

  /**
   * Selects the page that has the given id.
   * @param {string} id
   */
  selectPageById(id) {
    const selectorItem = this.selectorItems_.find(item => item.id == id);
    if (selectorItem) {
      this.selectedItem = selectorItem;
    }
  }

  /**
   * @param {!SelectorItem} item
   * @private
   */
  getPage_(item) {
    let pageComponent = this.shadowRoot.querySelector(`#${item.id}`);

    if (pageComponent === null) {
      pageComponent = document.createElement(item.pageIs);
      assert(pageComponent);
      pageComponent.setAttribute('id', item.id);
      pageComponent.setAttribute('class', 'view-content');

      if (item.initialData) {
        pageComponent.initialData = item.initialData;
      }

      pageComponent.hidden = true;

      this.$.navigationBody.appendChild(pageComponent);
    }
    return pageComponent;
  }

  /**
   * @param {!HTMLElement} pageComponent
   * @private
   */
  showPage_(pageComponent) {
    const components = this.shadowRoot.querySelectorAll('.view-content');
    // Hide all existing pages.
    Array.from(components).map((c) => c.hidden = true);
    pageComponent.hidden = false;
  }

  onMenuButtonTap_() {
    this.$.drawer.toggle();
  }

  /** @private */
  onScroll_() {
    if (this.showToolBar) {
      const scrollTop = document.documentElement.scrollTop;
      if (scrollTop <= 0) {
        this.shadowRoot.querySelector('page-toolbar').removeAttribute('shadow');
        return;
      }
      this.shadowRoot.querySelector('page-toolbar').setAttribute('shadow', '');
    }
  }

  /**
   * @param {string} selectorId The ID of the section to search for.
   * @return {boolean}
   */
  pageExists(selectorId) {
    return !!this.selectorItems_.find(({id}) => id === selectorId);
  }

  /** @private */
  onNavigationSelected_() {
    // Don't toggle, but rather only close the drawer if it's opened.
    if (this.$.drawer.open) {
      this.$.drawer.close();
    }
  }
}

customElements.define(NavigationViewPanelElement.is,
    NavigationViewPanelElement);