Source code
Revision control
Copy as Markdown
Other Tools
/* 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
"use strict";
define(function (require, exports) {
const {
Component,
createRef,
} = require("resource://devtools/client/shared/vendor/react.js");
const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
/**
* Renders simple 'tab' widget.
*
* Based on ReactSimpleTabs component
*
* Component markup (+CSS) example:
*
* <div class='tabs'>
* <nav class='tabs-navigation'>
* <ul class='tabs-menu'>
* <li class='tabs-menu-item is-active'>Tab #1</li>
* <li class='tabs-menu-item'>Tab #2</li>
* </ul>
* </nav>
* <div class='panels'>
* The content of active panel here
* </div>
* <div>
*/
class Tabs extends Component {
static get propTypes() {
return {
className: PropTypes.oneOfType([
PropTypes.array,
PropTypes.string,
PropTypes.object,
]),
activeTab: PropTypes.number,
onMount: PropTypes.func,
onBeforeChange: PropTypes.func,
onAfterChange: PropTypes.func,
children: PropTypes.oneOfType([PropTypes.array, PropTypes.element])
.isRequired,
showAllTabsMenu: PropTypes.bool,
allTabsMenuButtonTooltip: PropTypes.string,
onAllTabsMenuClick: PropTypes.func,
tall: PropTypes.bool,
// To render a sidebar toggle button before the tab menu provide a function that
// returns a React component for the button.
renderSidebarToggle: PropTypes.func,
// Set true will only render selected panel on DOM. It's complete
// opposite of the created array, and it's useful if panels content
// is unpredictable and update frequently.
renderOnlySelected: PropTypes.bool,
};
}
static get defaultProps() {
return {
activeTab: 0,
showAllTabsMenu: false,
renderOnlySelected: false,
};
}
static getDerivedStateFromProps(props, state) {
if (
props.children.length !== state.prevChildren ||
state.prevChildren.some(
(previousChild, i) => props.children[i] !== previousChild
) ||
props.activeTab !== state.prevActiveTab
) {
let { children, activeTab } = props;
const panels = children.filter(panel => panel);
let created = [...state.created];
// If the children props has changed due to an addition or removal of a tab,
// update the state's created array with the latest tab ids and whether or not
// the tab is already created.
if (state.created.length != panels.length) {
created = panels.map(panel => {
// Get whether or not the tab has already been created from the previous state.
const createdEntry = state.created.find(entry => {
return entry && entry.tabId === panel.props.id;
});
const isCreated = !!createdEntry && createdEntry.isCreated;
const tabId = panel.props.id;
return {
isCreated,
tabId,
};
});
}
const newState = {
created,
prevChildren: children,
prevActiveTab: activeTab,
};
// Check type of 'activeTab' props to see if it's valid (it's 0-based index).
if (typeof activeTab === "number") {
// Reset to index 0 if index overflows the range of panel array
activeTab =
activeTab < panels.length && activeTab >= 0 ? activeTab : 0;
created[activeTab] = Object.assign({}, created[activeTab], {
isCreated: true,
});
newState.activeTab = activeTab;
}
return newState;
}
return null;
}
constructor(props) {
super(props);
this.state = {
activeTab: props.activeTab,
// This array is used to store an object containing information on whether a tab
// at a specified index has already been created (e.g. selected at least once) and
// the tab id. An example of the object structure is the following:
// [{ isCreated: true, tabId: "ruleview" }, { isCreated: false, tabId: "foo" }].
// If the tab at the specified index has already been created, it's rendered even
// if not currently selected. This is because in some cases we don't want
// to re-create tab content when it's being unselected/selected.
// E.g. in case of an iframe being used as a tab-content we want the iframe to
// stay in the DOM.
created: [],
// True if tabs can't fit into available horizontal space.
overflow: false,
// The created and activeTab state are derived from the Props and we need to recalculate
// them when the props change. So we track the prevous values passed
prevChildren: [],
prevActiveTab: 0,
};
this.tabsEl = createRef();
this.onOverflow = this.onOverflow.bind(this);
this.onUnderflow = this.onUnderflow.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onClickTab = this.onClickTab.bind(this);
this.setActive = this.setActive.bind(this);
this.renderMenuItems = this.renderMenuItems.bind(this);
this.renderPanels = this.renderPanels.bind(this);
}
componentDidMount() {
const node = this.tabsEl.current;
node.addEventListener("keydown", this.onKeyDown);
// Register overflow listeners to manage visibility
// of all-tabs-menu. This menu is displayed when there
// is not enough h-space to render all tabs.
// It allows the user to select a tab even if it's hidden.
if (this.props.showAllTabsMenu) {
node.addEventListener("overflow", this.onOverflow);
node.addEventListener("underflow", this.onUnderflow);
}
const index = this.state.activeTab;
if (this.props.onMount) {
this.props.onMount(index);
}
}
componentWillUnmount() {
const node = this.tabsEl.current;
node.removeEventListener("keydown", this.onKeyDown);
if (this.props.showAllTabsMenu) {
node.removeEventListener("overflow", this.onOverflow);
node.removeEventListener("underflow", this.onUnderflow);
}
}
// DOM Events
onOverflow(event) {
if (event.target.classList.contains("tabs-menu")) {
this.setState({
overflow: true,
});
}
}
onUnderflow(event) {
if (event.target.classList.contains("tabs-menu")) {
this.setState({
overflow: false,
});
}
}
onKeyDown(event) {
// Bail out if the focus isn't on a tab.
if (!event.target.closest(".tabs-menu-item")) {
return;
}
let activeTab = this.state.activeTab;
const tabCount = this.props.children.length;
const ltr = event.target.ownerDocument.dir == "ltr";
const nextOrLastTab = Math.min(tabCount - 1, activeTab + 1);
const previousOrFirstTab = Math.max(0, activeTab - 1);
switch (event.code) {
case "ArrowRight":
if (ltr) {
activeTab = nextOrLastTab;
} else {
activeTab = previousOrFirstTab;
}
break;
case "ArrowLeft":
if (ltr) {
activeTab = previousOrFirstTab;
} else {
activeTab = nextOrLastTab;
}
break;
}
if (this.state.activeTab != activeTab) {
this.setActive(activeTab);
}
}
onClickTab(index, event) {
this.setActive(index);
if (event) {
event.preventDefault();
}
}
onMouseDown(event) {
// Prevents click-dragging the tab headers
if (event) {
event.preventDefault();
}
}
// API
setActive(index) {
const onAfterChange = this.props.onAfterChange;
const onBeforeChange = this.props.onBeforeChange;
if (onBeforeChange) {
const cancel = onBeforeChange(index);
if (cancel) {
return;
}
}
const created = [...this.state.created];
created[index] = Object.assign({}, created[index], {
isCreated: true,
});
const newState = Object.assign({}, this.state, {
created,
activeTab: index,
});
this.setState(newState, () => {
// Properly set focus on selected tab.
const selectedTab = this.tabsEl.current.querySelector(".is-active > a");
if (selectedTab) {
selectedTab.focus();
}
if (onAfterChange) {
onAfterChange(index);
}
});
}
// Rendering
renderMenuItems() {
if (!this.props.children) {
throw new Error("There must be at least one Tab");
}
if (!Array.isArray(this.props.children)) {
this.props.children = [this.props.children];
}
const tabs = this.props.children
.map(tab => (typeof tab === "function" ? tab() : tab))
.filter(tab => tab)
.map((tab, index) => {
const {
id,
className: tabClassName,
title,
badge,
showBadge,
} = tab.props;
const ref = "tab-menu-" + index;
const isTabSelected = this.state.activeTab === index;
const className = [
"tabs-menu-item",
tabClassName,
isTabSelected ? "is-active" : "",
].join(" ");
// Set tabindex to -1 (except the selected tab) so, it's focusable,
// but not reachable via sequential tab-key navigation.
// Changing selected tab (and so, moving focus) is done through
// left and right arrow keys.
// See also `onKeyDown()` event handler.
return dom.li(
{
className,
key: index,
ref,
role: "presentation",
},
dom.span({ className: "devtools-tab-line" }),
dom.a(
{
id: id ? id + "-tab" : "tab-" + index,
tabIndex: isTabSelected ? 0 : -1,
title,
"aria-controls": id ? id + "-panel" : "panel-" + index,
"aria-selected": isTabSelected,
role: "tab",
onClick: this.onClickTab.bind(this, index),
onMouseDown: this.onMouseDown.bind(this),
"data-tab-index": index,
},
title,
badge && !isTabSelected && showBadge()
? dom.span({ className: "tab-badge" }, badge)
: null
)
);
});
// Display the menu only if there is not enough horizontal
// space for all tabs (and overflow happened).
const allTabsMenu = this.state.overflow
? dom.button({
className: "all-tabs-menu",
title: this.props.allTabsMenuButtonTooltip,
onClick: this.props.onAllTabsMenuClick,
})
: null;
// Get the sidebar toggle button if a renderSidebarToggle function is provided.
const sidebarToggle = this.props.renderSidebarToggle
? this.props.renderSidebarToggle()
: null;
return dom.nav(
{ className: "tabs-navigation" },
sidebarToggle,
dom.ul({ className: "tabs-menu", role: "tablist" }, tabs),
allTabsMenu
);
}
renderPanels() {
let { children, renderOnlySelected } = this.props;
if (!children) {
throw new Error("There must be at least one Tab");
}
if (!Array.isArray(children)) {
children = [children];
}
const selectedIndex = this.state.activeTab;
const panels = children
.map(tab => (typeof tab === "function" ? tab() : tab))
.filter(tab => tab)
.map((tab, index) => {
const selected = selectedIndex === index;
if (renderOnlySelected && !selected) {
return null;
}
const id = tab.props.id;
const isCreated =
this.state.created[index] && this.state.created[index].isCreated;
// Use 'visibility:hidden' + 'height:0' for hiding content of non-selected
// tab. It's faster than 'display:none' because it avoids triggering frame
// destruction and reconstruction. 'width' is not changed to avoid relayout.
const style = {
visibility: selected ? "visible" : "hidden",
height: selected ? "100%" : "0",
};
// Allows lazy loading panels by creating them only if they are selected,
// then store a copy of the lazy created panel in `tab.panel`.
if (typeof tab.panel == "function" && selected) {
tab.panel = tab.panel(tab);
}
const panel = tab.panel || tab;
return dom.div(
{
id: id ? id + "-panel" : "panel-" + index,
key: id,
style,
className: selected ? "tab-panel-box" : "tab-panel-box hidden",
role: "tabpanel",
"aria-labelledby": id ? id + "-tab" : "tab-" + index,
},
selected || isCreated ? panel : null
);
});
return dom.div({ className: "panels" }, panels);
}
render() {
return dom.div(
{
className: [
"tabs",
...(this.props.tall ? ["tabs-tall"] : []),
this.props.className,
].join(" "),
ref: this.tabsEl,
},
this.renderMenuItems(),
this.renderPanels()
);
}
}
/**
* Renders simple tab 'panel'.
*/
class Panel extends Component {
static get propTypes() {
return {
id: PropTypes.string.isRequired,
className: PropTypes.string,
title: PropTypes.string.isRequired,
children: PropTypes.oneOfType([PropTypes.array, PropTypes.element])
.isRequired,
};
}
render() {
const { className } = this.props;
return dom.div(
{ className: `tab-panel ${className || ""}` },
this.props.children
);
}
}
// Exports from this module
exports.TabPanel = Panel;
exports.Tabs = Tabs;
});