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 };