tor-browser

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

Accordion.js (6863B)


      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 "use strict";
      6 
      7 const {
      8  Component,
      9  createElement,
     10 } = require("resource://devtools/client/shared/vendor/react.mjs");
     11 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs");
     12 const {
     13  ul,
     14  li,
     15  h2,
     16  div,
     17  span,
     18  button,
     19 } = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
     20 
     21 class Accordion extends Component {
     22  static get propTypes() {
     23    return {
     24      className: PropTypes.string,
     25      // A list of all items to be rendered using an Accordion component.
     26      items: PropTypes.arrayOf(
     27        PropTypes.shape({
     28          buttons: PropTypes.arrayOf(PropTypes.object),
     29          className: PropTypes.string,
     30          component: PropTypes.oneOfType([PropTypes.object, PropTypes.func]),
     31          componentProps: PropTypes.object,
     32          contentClassName: PropTypes.string,
     33          header: PropTypes.string.isRequired,
     34          id: PropTypes.string.isRequired,
     35          onToggle: PropTypes.func,
     36          // Determines the initial open state of the accordion item
     37          opened: PropTypes.bool.isRequired,
     38          // Enables dynamically changing the open state of the accordion
     39          // on update.
     40          shouldOpen: PropTypes.func,
     41        })
     42      ).isRequired,
     43    };
     44  }
     45 
     46  constructor(props) {
     47    super(props);
     48 
     49    this.state = {
     50      opened: {},
     51    };
     52 
     53    this.onHeaderClick = this.onHeaderClick.bind(this);
     54    this.setInitialState = this.setInitialState.bind(this);
     55    this.updateCurrentState = this.updateCurrentState.bind(this);
     56  }
     57 
     58  componentDidMount() {
     59    this.setInitialState();
     60  }
     61 
     62  componentDidUpdate(prevProps) {
     63    if (prevProps.items !== this.props.items) {
     64      this.updateCurrentState();
     65    }
     66  }
     67 
     68  setInitialState() {
     69    /**
     70     * Add initial data to the `state.opened` map.
     71     * This happens only on initial mount of the accordion.
     72     */
     73    const newItems = this.props.items.filter(
     74      ({ id }) => typeof this.state.opened[id] !== "boolean"
     75    );
     76 
     77    if (newItems.length) {
     78      const everOpened = { ...this.state.everOpened };
     79      const opened = { ...this.state.opened };
     80      for (const item of newItems) {
     81        everOpened[item.id] = item.opened;
     82        opened[item.id] = item.opened;
     83      }
     84 
     85      this.setState({ everOpened, opened });
     86    }
     87  }
     88 
     89  updateCurrentState() {
     90    /**
     91     * Updates the `state.opened` map based on the
     92     * new items that have been added and those that
     93     * `item.shouldOpen()` has changed. This happens
     94     * on each update.
     95     */
     96    const updatedItems = this.props.items.filter(item => {
     97      const notExist = typeof this.state.opened[item.id] !== "boolean";
     98      if (typeof item.shouldOpen == "function") {
     99        const currentState = this.state.opened[item.id];
    100        return notExist || currentState !== item.shouldOpen(item, currentState);
    101      }
    102      return notExist;
    103    });
    104 
    105    if (updatedItems.length) {
    106      const everOpened = { ...this.state.everOpened };
    107      const opened = { ...this.state.opened };
    108      for (const item of updatedItems) {
    109        let itemOpen = item.opened;
    110        if (typeof item.shouldOpen == "function") {
    111          itemOpen = item.shouldOpen(item, itemOpen);
    112        }
    113        everOpened[item.id] = itemOpen;
    114        opened[item.id] = itemOpen;
    115      }
    116      this.setState({ everOpened, opened });
    117    }
    118  }
    119 
    120  /**
    121   * @param {Event} event Click event.
    122   * @param {object} item The item to be collapsed/expanded.
    123   */
    124  onHeaderClick(event, item) {
    125    event.preventDefault();
    126    // In the Browser Toolbox's Inspector/Layout view, handleHeaderClick is
    127    // called twice unless we call stopPropagation, making the accordion item
    128    // open-and-close or close-and-open
    129    event.stopPropagation();
    130    this.toggleItem(item);
    131  }
    132 
    133  /**
    134   * Expand or collapse an accordion list item.
    135   *
    136   * @param  {object} item The item to be collapsed or expanded.
    137   */
    138  toggleItem(item) {
    139    const opened = !this.state.opened[item.id];
    140 
    141    this.setState({
    142      everOpened: {
    143        ...this.state.everOpened,
    144        [item.id]: true,
    145      },
    146      opened: {
    147        ...this.state.opened,
    148        [item.id]: opened,
    149      },
    150    });
    151 
    152    if (typeof item.onToggle === "function") {
    153      item.onToggle(opened, item);
    154    }
    155  }
    156 
    157  renderItem(item) {
    158    const {
    159      buttons,
    160      className = "",
    161      component,
    162      componentProps = {},
    163      contentClassName = "",
    164      header,
    165      id,
    166    } = item;
    167 
    168    const headerId = `${id}-header`;
    169    const opened = this.state.opened[id];
    170    let itemContent;
    171 
    172    // Only render content if the accordion item is open or has been opened once before.
    173    // This saves us rendering complex components when users are keeping
    174    // them closed (e.g. in Inspector/Layout) or may not open them at all.
    175    if (this.state.everOpened && this.state.everOpened[id]) {
    176      if (typeof component === "function") {
    177        itemContent = createElement(component, componentProps);
    178      } else if (typeof component === "object") {
    179        itemContent = component;
    180      }
    181    }
    182 
    183    return li(
    184      {
    185        key: id,
    186        id,
    187        className: `accordion-item ${
    188          opened ? "accordion-open" : ""
    189        } ${className} `.trim(),
    190        "aria-labelledby": headerId,
    191      },
    192      h2(
    193        {
    194          id: headerId,
    195          className: "accordion-header",
    196          "aria-expanded": opened,
    197          // If the header contains buttons, make sure the heading name only
    198          // contains the "header" text and not the button text
    199          "aria-label": header,
    200        },
    201        button(
    202          {
    203            className: "accordion-toggle",
    204            onClick: event => this.onHeaderClick(event, item),
    205          },
    206          span({
    207            className: `theme-twisty${opened ? " open" : ""}`,
    208            role: "presentation",
    209          }),
    210          span(
    211            {
    212              className: "accordion-header-label",
    213            },
    214            header
    215          )
    216        ),
    217        buttons &&
    218          span(
    219            {
    220              className: "accordion-header-buttons",
    221              role: "presentation",
    222            },
    223            buttons
    224          )
    225      ),
    226      div(
    227        {
    228          className: `accordion-content ${contentClassName}`.trim(),
    229          hidden: !opened,
    230          role: "presentation",
    231        },
    232        itemContent
    233      )
    234    );
    235  }
    236 
    237  render() {
    238    return ul(
    239      {
    240        className:
    241          "accordion" +
    242          (this.props.className ? ` ${this.props.className}` : ""),
    243        tabIndex: -1,
    244      },
    245      this.props.items.map(item => this.renderItem(item))
    246    );
    247  }
    248 }
    249 
    250 module.exports = Accordion;