/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at . */ "use strict"; const { Component, createElement, } = require("resource://devtools/client/shared/vendor/react.js"); const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); const { ul, li, h2, div, span, } = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); class Accordion extends Component { static get propTypes() { return { className: PropTypes.string, // A list of all items to be rendered using an Accordion component. items: PropTypes.arrayOf( PropTypes.shape({ buttons: PropTypes.arrayOf(PropTypes.object), className: PropTypes.string, component: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), componentProps: PropTypes.object, contentClassName: PropTypes.string, header: PropTypes.string.isRequired, id: PropTypes.string.isRequired, onToggle: PropTypes.func, // Determines the initial open state of the accordion item opened: PropTypes.bool.isRequired, // Enables dynamically changing the open state of the accordion // on update. shouldOpen: PropTypes.func, }) ).isRequired, }; } constructor(props) { super(props); this.state = { opened: {}, }; this.onHeaderClick = this.onHeaderClick.bind(this); this.onHeaderKeyDown = this.onHeaderKeyDown.bind(this); this.setInitialState = this.setInitialState.bind(this); this.updateCurrentState = this.updateCurrentState.bind(this); } componentDidMount() { this.setInitialState(); } componentDidUpdate(prevProps) { if (prevProps.items !== this.props.items) { this.updateCurrentState(); } } setInitialState() { /** * Add initial data to the `state.opened` map. * This happens only on initial mount of the accordion. */ const newItems = this.props.items.filter( ({ id }) => typeof this.state.opened[id] !== "boolean" ); if (newItems.length) { const everOpened = { ...this.state.everOpened }; const opened = { ...this.state.opened }; for (const item of newItems) { everOpened[item.id] = item.opened; opened[item.id] = item.opened; } this.setState({ everOpened, opened }); } } updateCurrentState() { /** * Updates the `state.opened` map based on the * new items that have been added and those that * `item.shouldOpen()` has changed. This happens * on each update. */ const updatedItems = this.props.items.filter(item => { const notExist = typeof this.state.opened[item.id] !== "boolean"; if (typeof item.shouldOpen == "function") { const currentState = this.state.opened[item.id]; return notExist || currentState !== item.shouldOpen(item, currentState); } return notExist; }); if (updatedItems.length) { const everOpened = { ...this.state.everOpened }; const opened = { ...this.state.opened }; for (const item of updatedItems) { let itemOpen = item.opened; if (typeof item.shouldOpen == "function") { itemOpen = item.shouldOpen(item, itemOpen); } everOpened[item.id] = itemOpen; opened[item.id] = itemOpen; } this.setState({ everOpened, opened }); } } /** * @param {Event} event Click event. * @param {Object} item The item to be collapsed/expanded. */ onHeaderClick(event, item) { event.preventDefault(); // In the Browser Toolbox's Inspector/Layout view, handleHeaderClick is // called twice unless we call stopPropagation, making the accordion item // open-and-close or close-and-open event.stopPropagation(); this.toggleItem(item); } /** * @param {Event} event Keyboard event. * @param {Object} item The item to be collapsed/expanded. */ onHeaderKeyDown(event, item) { if (event.key === " " || event.key === "Enter") { event.preventDefault(); this.toggleItem(item); } } /** * Expand or collapse an accordion list item. * @param {Object} item The item to be collapsed or expanded. */ toggleItem(item) { const opened = !this.state.opened[item.id]; this.setState({ everOpened: { ...this.state.everOpened, [item.id]: true, }, opened: { ...this.state.opened, [item.id]: opened, }, }); if (typeof item.onToggle === "function") { item.onToggle(opened, item); } } renderItem(item) { const { buttons, className = "", component, componentProps = {}, contentClassName = "", header, id, } = item; const headerId = `${id}-header`; const opened = this.state.opened[id]; let itemContent; // Only render content if the accordion item is open or has been opened once before. // This saves us rendering complex components when users are keeping // them closed (e.g. in Inspector/Layout) or may not open them at all. if (this.state.everOpened && this.state.everOpened[id]) { if (typeof component === "function") { itemContent = createElement(component, componentProps); } else if (typeof component === "object") { itemContent = component; } } return li( { key: id, id, className: `accordion-item ${ opened ? "accordion-open" : "" } ${className} `.trim(), "aria-labelledby": headerId, }, h2( { id: headerId, className: "accordion-header", tabIndex: 0, "aria-expanded": opened, // If the header contains buttons, make sure the heading name only // contains the "header" text and not the button text "aria-label": header, onKeyDown: event => this.onHeaderKeyDown(event, item), onClick: event => this.onHeaderClick(event, item), }, span({ className: `theme-twisty${opened ? " open" : ""}`, role: "presentation", }), span( { className: "accordion-header-label", }, header ), buttons && span( { className: "accordion-header-buttons", role: "presentation", }, buttons ) ), div( { className: `accordion-content ${contentClassName}`.trim(), hidden: !opened, role: "presentation", }, itemContent ) ); } render() { return ul( { className: "accordion" + (this.props.className ? ` ${this.props.className}` : ""), tabIndex: -1, }, this.props.items.map(item => this.renderItem(item)) ); } } module.exports = Accordion;