tor-browser

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

Tabs.mjs (14232B)


      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 import React from "resource://devtools/client/shared/vendor/react.mjs";
      6 import * as dom from "resource://devtools/client/shared/vendor/react-dom-factories.mjs";
      7 import PropTypes from "resource://devtools/client/shared/vendor/react-prop-types.mjs";
      8 
      9 const { Component, createRef } = React;
     10 
     11 /**
     12 * Renders simple 'tab' widget.
     13 *
     14 * Based on ReactSimpleTabs component
     15 * https://github.com/pedronauck/react-simpletabs
     16 *
     17 * Component markup (+CSS) example:
     18 *
     19 * <div class='tabs'>
     20 *  <nav class='tabs-navigation'>
     21 *    <ul class='tabs-menu'>
     22 *      <li class='tabs-menu-item is-active'>Tab #1</li>
     23 *      <li class='tabs-menu-item'>Tab #2</li>
     24 *    </ul>
     25 *  </nav>
     26 *  <div class='panels'>
     27 *    The content of active panel here
     28 *  </div>
     29 * <div>
     30 */
     31 class Tabs extends Component {
     32  static get propTypes() {
     33    return {
     34      className: PropTypes.oneOfType([
     35        PropTypes.array,
     36        PropTypes.string,
     37        PropTypes.object,
     38      ]),
     39      activeTab: PropTypes.number,
     40      onMount: PropTypes.func,
     41      onBeforeChange: PropTypes.func,
     42      onAfterChange: PropTypes.func,
     43      children: PropTypes.oneOfType([PropTypes.array, PropTypes.element])
     44        .isRequired,
     45      showAllTabsMenu: PropTypes.bool,
     46      allTabsMenuButtonTooltip: PropTypes.string,
     47      onAllTabsMenuClick: PropTypes.func,
     48      tall: PropTypes.bool,
     49 
     50      // To render a sidebar toggle button before the tab menu provide a function that
     51      // returns a React component for the button.
     52      renderSidebarToggle: PropTypes.func,
     53      // To render a toolbar button after the tab menu provide a function that
     54      // returns a React component for the button.
     55      renderToolbarButton: PropTypes.func,
     56      // Set true will only render selected panel on DOM. It's complete
     57      // opposite of the created array, and it's useful if panels content
     58      // is unpredictable and update frequently.
     59      renderOnlySelected: PropTypes.bool,
     60    };
     61  }
     62 
     63  static get defaultProps() {
     64    return {
     65      activeTab: 0,
     66      showAllTabsMenu: false,
     67      renderOnlySelected: false,
     68    };
     69  }
     70 
     71  constructor(props) {
     72    super(props);
     73 
     74    this.state = {
     75      activeTab: props.activeTab,
     76 
     77      // This array is used to store an object containing information on whether a tab
     78      // at a specified index has already been created (e.g. selected at least once) and
     79      // the tab id. An example of the object structure is the following:
     80      // [{ isCreated: true, tabId: "ruleview" }, { isCreated: false, tabId: "foo" }].
     81      // If the tab at the specified index has already been created, it's rendered even
     82      // if not currently selected. This is because in some cases we don't want
     83      // to re-create tab content when it's being unselected/selected.
     84      // E.g. in case of an iframe being used as a tab-content we want the iframe to
     85      // stay in the DOM.
     86      created: [],
     87 
     88      // True if tabs can't fit into available horizontal space.
     89      overflow: false,
     90    };
     91 
     92    this.tabsEl = createRef();
     93 
     94    this.onOverflow = this.onOverflow.bind(this);
     95    this.onUnderflow = this.onUnderflow.bind(this);
     96    this.onKeyDown = this.onKeyDown.bind(this);
     97    this.onClickTab = this.onClickTab.bind(this);
     98    this.setActive = this.setActive.bind(this);
     99    this.renderMenuItems = this.renderMenuItems.bind(this);
    100    this.renderPanels = this.renderPanels.bind(this);
    101  }
    102 
    103  componentDidMount() {
    104    const node = this.tabsEl.current;
    105    node.addEventListener("keydown", this.onKeyDown);
    106 
    107    // Register overflow listeners to manage visibility
    108    // of all-tabs-menu. This menu is displayed when there
    109    // is not enough h-space to render all tabs.
    110    // It allows the user to select a tab even if it's hidden.
    111    if (this.props.showAllTabsMenu) {
    112      node.addEventListener("overflow", this.onOverflow);
    113      node.addEventListener("underflow", this.onUnderflow);
    114    }
    115 
    116    const index = this.state.activeTab;
    117    if (this.props.onMount) {
    118      this.props.onMount(index);
    119    }
    120  }
    121 
    122  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
    123  UNSAFE_componentWillReceiveProps(nextProps) {
    124    let { children, activeTab } = nextProps;
    125    const panels = children.filter(panel => panel);
    126    let created = [...this.state.created];
    127 
    128    // If the children props has changed due to an addition or removal of a tab,
    129    // update the state's created array with the latest tab ids and whether or not
    130    // the tab is already created.
    131    if (this.state.created.length != panels.length) {
    132      created = panels.map(panel => {
    133        // Get whether or not the tab has already been created from the previous state.
    134        const createdEntry = this.state.created.find(entry => {
    135          return entry && entry.tabId === panel.props.id;
    136        });
    137        const isCreated = !!createdEntry && createdEntry.isCreated;
    138        const tabId = panel.props.id;
    139 
    140        return {
    141          isCreated,
    142          tabId,
    143        };
    144      });
    145    }
    146 
    147    // Check type of 'activeTab' props to see if it's valid (it's 0-based index).
    148    if (typeof activeTab === "number") {
    149      // Reset to index 0 if index overflows the range of panel array
    150      activeTab = activeTab < panels.length && activeTab >= 0 ? activeTab : 0;
    151 
    152      created[activeTab] = Object.assign({}, created[activeTab], {
    153        isCreated: true,
    154      });
    155 
    156      this.setState({
    157        activeTab,
    158      });
    159    }
    160 
    161    this.setState({
    162      created,
    163    });
    164  }
    165 
    166  componentWillUnmount() {
    167    const node = this.tabsEl.current;
    168    node.removeEventListener("keydown", this.onKeyDown);
    169 
    170    if (this.props.showAllTabsMenu) {
    171      node.removeEventListener("overflow", this.onOverflow);
    172      node.removeEventListener("underflow", this.onUnderflow);
    173    }
    174  }
    175 
    176  // DOM Events
    177 
    178  onOverflow(event) {
    179    if (event.target.classList.contains("tabs-menu")) {
    180      this.setState({
    181        overflow: true,
    182      });
    183    }
    184  }
    185 
    186  onUnderflow(event) {
    187    if (event.target.classList.contains("tabs-menu")) {
    188      this.setState({
    189        overflow: false,
    190      });
    191    }
    192  }
    193 
    194  onKeyDown(event) {
    195    // Bail out if the focus isn't on a tab.
    196    if (!event.target.closest(".tabs-menu-item")) {
    197      return;
    198    }
    199 
    200    let activeTab = this.state.activeTab;
    201    const tabCount = this.props.children.length;
    202 
    203    const ltr = event.target.ownerDocument.dir == "ltr";
    204    const nextOrLastTab = Math.min(tabCount - 1, activeTab + 1);
    205    const previousOrFirstTab = Math.max(0, activeTab - 1);
    206 
    207    switch (event.code) {
    208      case "ArrowRight":
    209        if (ltr) {
    210          activeTab = nextOrLastTab;
    211        } else {
    212          activeTab = previousOrFirstTab;
    213        }
    214        break;
    215      case "ArrowLeft":
    216        if (ltr) {
    217          activeTab = previousOrFirstTab;
    218        } else {
    219          activeTab = nextOrLastTab;
    220        }
    221        break;
    222    }
    223 
    224    if (this.state.activeTab != activeTab) {
    225      this.setActive(activeTab);
    226    }
    227  }
    228 
    229  onClickTab(index, event) {
    230    this.setActive(index, { fromMouseEvent: true });
    231 
    232    if (event) {
    233      event.preventDefault();
    234    }
    235  }
    236 
    237  onMouseDown(event) {
    238    // Prevents click-dragging the tab headers
    239    if (event) {
    240      event.preventDefault();
    241    }
    242  }
    243 
    244  // API
    245 
    246  /**
    247   * Set the active tab from its index
    248   *
    249   * @param {Integer} index
    250   *        Index of the tab that we want to set as the active one
    251   * @param {object} options
    252   * @param {boolean} options.fromMouseEvent
    253   *        Set to true if this is called from a click on the tab
    254   */
    255  setActive(index, options = {}) {
    256    const onAfterChange = this.props.onAfterChange;
    257    const onBeforeChange = this.props.onBeforeChange;
    258 
    259    if (onBeforeChange) {
    260      const cancel = onBeforeChange(index);
    261      if (cancel) {
    262        return;
    263      }
    264    }
    265 
    266    const created = [...this.state.created];
    267    created[index] = Object.assign({}, created[index], {
    268      isCreated: true,
    269    });
    270 
    271    const newState = Object.assign({}, this.state, {
    272      created,
    273      activeTab: index,
    274    });
    275 
    276    this.setState(newState, () => {
    277      // Properly set focus on selected tab.
    278      const selectedTab = this.tabsEl.current.querySelector(
    279        `a[data-tab-index="${index}"]`
    280      );
    281      selectedTab.focus({
    282        // When focus is coming from a mouse event,
    283        // prevent :focus-visible to be applied to the element
    284        focusVisible: !options.fromMouseEvent,
    285      });
    286 
    287      if (onAfterChange) {
    288        onAfterChange(index);
    289      }
    290    });
    291  }
    292 
    293  // Rendering
    294 
    295  renderMenuItems() {
    296    if (!this.props.children) {
    297      throw new Error("There must be at least one Tab");
    298    }
    299 
    300    if (!Array.isArray(this.props.children)) {
    301      this.props.children = [this.props.children];
    302    }
    303 
    304    const tabs = this.props.children
    305      .map(tab => (typeof tab === "function" ? tab() : tab))
    306      .filter(tab => tab)
    307      .map((tab, index) => {
    308        const {
    309          id,
    310          className: tabClassName,
    311          title,
    312          tooltip,
    313          badge,
    314          showBadge,
    315        } = tab.props;
    316 
    317        const ref = "tab-menu-" + index;
    318        const isTabSelected = this.state.activeTab === index;
    319 
    320        const className = [
    321          "tabs-menu-item",
    322          tabClassName,
    323          isTabSelected ? "is-active" : "",
    324        ].join(" ");
    325 
    326        // Set tabindex to -1 (except the selected tab) so, it's focusable,
    327        // but not reachable via sequential tab-key navigation.
    328        // Changing selected tab (and so, moving focus) is done through
    329        // left and right arrow keys.
    330        // See also `onKeyDown()` event handler.
    331        return dom.li(
    332          {
    333            className,
    334            key: index,
    335            ref,
    336            role: "presentation",
    337          },
    338          dom.span({ className: "devtools-tab-line" }),
    339          dom.a(
    340            {
    341              id: id ? id + "-tab" : "tab-" + index,
    342              tabIndex: isTabSelected ? 0 : -1,
    343              title: tooltip || title,
    344              "aria-controls": id ? id + "-panel" : "panel-" + index,
    345              "aria-selected": isTabSelected,
    346              role: "tab",
    347              onClick: this.onClickTab.bind(this, index),
    348              onMouseDown: this.onMouseDown.bind(this),
    349              "data-tab-index": index,
    350            },
    351            title,
    352            badge && !isTabSelected && showBadge()
    353              ? dom.span({ className: "tab-badge" }, badge)
    354              : null
    355          )
    356        );
    357      });
    358 
    359    // Display the menu only if there is not enough horizontal
    360    // space for all tabs (and overflow happened).
    361    const allTabsMenu = this.state.overflow
    362      ? dom.button({
    363          className: "all-tabs-menu",
    364          title: this.props.allTabsMenuButtonTooltip,
    365          onClick: this.props.onAllTabsMenuClick,
    366        })
    367      : null;
    368 
    369    // Get the sidebar toggle button if a renderSidebarToggle function is provided.
    370    const sidebarToggle = this.props.renderSidebarToggle
    371      ? this.props.renderSidebarToggle()
    372      : null;
    373 
    374    // Get the toolbar button if a renderToolbarButton function is provided.
    375    const toolbarButton = this.props.renderToolbarButton
    376      ? this.props.renderToolbarButton()
    377      : null;
    378 
    379    return dom.nav(
    380      { className: "tabs-navigation" },
    381      sidebarToggle,
    382      dom.ul({ className: "tabs-menu", role: "tablist" }, tabs),
    383      allTabsMenu,
    384      toolbarButton
    385    );
    386  }
    387 
    388  renderPanels() {
    389    let { children, renderOnlySelected } = this.props;
    390 
    391    if (!children) {
    392      throw new Error("There must be at least one Tab");
    393    }
    394 
    395    if (!Array.isArray(children)) {
    396      children = [children];
    397    }
    398 
    399    const selectedIndex = this.state.activeTab;
    400 
    401    const panels = children
    402      .map(tab => (typeof tab === "function" ? tab() : tab))
    403      .filter(tab => tab)
    404      .map((tab, index) => {
    405        const selected = selectedIndex === index;
    406        if (renderOnlySelected && !selected) {
    407          return null;
    408        }
    409 
    410        const id = tab.props.id;
    411        const isCreated =
    412          this.state.created[index] && this.state.created[index].isCreated;
    413 
    414        // Use 'visibility:hidden' + 'height:0' for hiding content of non-selected
    415        // tab. It's faster than 'display:none' because it avoids triggering frame
    416        // destruction and reconstruction. 'width' is not changed to avoid relayout.
    417        const style = {
    418          visibility: selected ? "visible" : "hidden",
    419          height: selected ? "100%" : "0",
    420        };
    421 
    422        // Allows lazy loading panels by creating them only if they are selected,
    423        // then store a copy of the lazy created panel in `tab.panel`.
    424        if (typeof tab.panel == "function" && selected) {
    425          tab.panel = tab.panel(tab);
    426        }
    427        const panel = tab.panel || tab;
    428 
    429        return dom.div(
    430          {
    431            id: id ? id + "-panel" : "panel-" + index,
    432            key: id,
    433            style,
    434            className: selected ? "tab-panel-box" : "tab-panel-box hidden",
    435            role: "tabpanel",
    436            "aria-labelledby": id ? id + "-tab" : "tab-" + index,
    437          },
    438          selected || isCreated ? panel : null
    439        );
    440      });
    441 
    442    return dom.div({ className: "panels" }, panels);
    443  }
    444 
    445  render() {
    446    return dom.div(
    447      {
    448        className: [
    449          "tabs",
    450          ...(this.props.tall ? ["tabs-tall"] : []),
    451          this.props.className,
    452        ].join(" "),
    453        ref: this.tabsEl,
    454      },
    455      this.renderMenuItems(),
    456      this.renderPanels()
    457    );
    458  }
    459 }
    460 
    461 /**
    462 * Renders simple tab 'panel'.
    463 */
    464 class TabPanel extends Component {
    465  static get propTypes() {
    466    return {
    467      id: PropTypes.string.isRequired,
    468      className: PropTypes.string,
    469      title: PropTypes.string.isRequired,
    470      children: PropTypes.oneOfType([PropTypes.array, PropTypes.element])
    471        .isRequired,
    472    };
    473  }
    474 
    475  render() {
    476    const { className } = this.props;
    477    return dom.div(
    478      { className: `tab-panel ${className || ""}` },
    479      this.props.children
    480    );
    481  }
    482 }
    483 
    484 // Exports from this module
    485 export { TabPanel, Tabs };