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;