diff options
Diffstat (limited to 'devtools/client/shared/components/Accordion.js')
-rw-r--r-- | devtools/client/shared/components/Accordion.js | 257 |
1 files changed, 257 insertions, 0 deletions
diff --git a/devtools/client/shared/components/Accordion.js b/devtools/client/shared/components/Accordion.js new file mode 100644 index 0000000000..c3f1afa418 --- /dev/null +++ b/devtools/client/shared/components/Accordion.js @@ -0,0 +1,257 @@ +/* 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 <http://mozilla.org/MPL/2.0/>. */ + +"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; |