/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* 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";
define(function (require, exports, module) {
const React = require("devtools/client/shared/vendor/react");
const { DOM } = React;
const { findDOMNode } = require("devtools/client/shared/vendor/react-dom");
/**
* Renders simple 'tab' widget.
*
* Based on ReactSimpleTabs component
* https://github.com/pedronauck/react-simpletabs
*
* Component markup (+CSS) example:
*
*
*
*
* The content of active panel here
*
*
*/
let Tabs = React.createClass({
displayName: "Tabs",
propTypes: {
className: React.PropTypes.oneOfType([
React.PropTypes.array,
React.PropTypes.string,
React.PropTypes.object
]),
tabActive: React.PropTypes.number,
onMount: React.PropTypes.func,
onBeforeChange: React.PropTypes.func,
onAfterChange: React.PropTypes.func,
children: React.PropTypes.oneOfType([
React.PropTypes.array,
React.PropTypes.element
]).isRequired,
showAllTabsMenu: React.PropTypes.bool,
onAllTabsMenuClick: React.PropTypes.func,
},
getDefaultProps: function () {
return {
tabActive: 0,
showAllTabsMenu: false,
};
},
getInitialState: function () {
return {
tabActive: this.props.tabActive,
// This array is used to store an information whether a tab
// at specific index has already been created (e.g. selected
// at least once).
// If yes, 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,
};
},
componentDidMount: function () {
let node = findDOMNode(this);
node.addEventListener("keydown", this.onKeyDown, false);
// 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, false);
node.addEventListener("underflow", this.onUnderflow, false);
}
let index = this.state.tabActive;
if (this.props.onMount) {
this.props.onMount(index);
}
},
componentWillReceiveProps: function (newProps) {
// Check type of 'tabActive' props to see if it's valid
// (it's 0-based index).
if (typeof newProps.tabActive == "number") {
let created = [...this.state.created];
created[newProps.tabActive] = true;
this.setState(Object.assign({}, this.state, {
tabActive: newProps.tabActive,
created: created,
}));
}
},
componentWillUnmount: function () {
let node = findDOMNode(this);
node.removeEventListener("keydown", this.onKeyDown, false);
if (this.props.showAllTabsMenu) {
node.removeEventListener("overflow", this.onOverflow, false);
node.removeEventListener("underflow", this.onUnderflow, false);
}
},
// DOM Events
onOverflow: function (event) {
if (event.target.classList.contains("tabs-menu")) {
this.setState({
overflow: true
});
}
},
onUnderflow: function (event) {
if (event.target.classList.contains("tabs-menu")) {
this.setState({
overflow: false
});
}
},
onKeyDown: function (event) {
// Bail out if the focus isn't on a tab.
if (!event.target.closest(".tabs-menu-item")) {
return;
}
let tabActive = this.state.tabActive;
let tabCount = this.props.children.length;
switch (event.code) {
case "ArrowRight":
tabActive = Math.min(tabCount - 1, tabActive + 1);
break;
case "ArrowLeft":
tabActive = Math.max(0, tabActive - 1);
break;
}
if (this.state.tabActive != tabActive) {
this.setActive(tabActive);
}
},
onClickTab: function (index, event) {
this.setActive(index);
event.preventDefault();
},
onAllTabsMenuClick: function (event) {
if (this.props.onAllTabsMenuClick) {
this.props.onAllTabsMenuClick(event);
}
},
// API
setActive: function (index) {
let onAfterChange = this.props.onAfterChange;
let onBeforeChange = this.props.onBeforeChange;
if (onBeforeChange) {
let cancel = onBeforeChange(index);
if (cancel) {
return;
}
}
let created = [...this.state.created];
created[index] = true;
let newState = Object.assign({}, this.state, {
tabActive: index,
created: created
});
this.setState(newState, () => {
// Properly set focus on selected tab.
let node = findDOMNode(this);
let selectedTab = node.querySelector(".is-active > a");
if (selectedTab) {
selectedTab.focus();
}
if (onAfterChange) {
onAfterChange(index);
}
});
},
// Rendering
renderMenuItems: function () {
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];
}
let tabs = this.props.children
.map(tab => {
return typeof tab === "function" ? tab() : tab;
}).filter(tab => {
return tab;
}).map((tab, index) => {
let ref = ("tab-menu-" + index);
let title = tab.props.title;
let tabClassName = tab.props.className;
let isTabSelected = this.state.tabActive === index;
let classes = [
"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({
ref: ref,
key: index,
id: "tab-" + index,
className: classes,
role: "presentation",
},
DOM.a({
tabIndex: this.state.tabActive === index ? 0 : -1,
"aria-controls": "panel-" + index,
"aria-selected": isTabSelected,
role: "tab",
onClick: this.onClickTab.bind(this, index),
},
title
)
)
);
});
// Display the menu only if there is not enough horizontal
// space for all tabs (and overflow happened).
let allTabsMenu = this.state.overflow ? (
DOM.div({
className: "all-tabs-menu",
onClick: this.props.onAllTabsMenuClick
})
) : null;
return (
DOM.nav({className: "tabs-navigation"},
DOM.ul({className: "tabs-menu", role: "tablist"},
tabs
),
allTabsMenu
)
);
},
renderPanels: function () {
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];
}
let selectedIndex = this.state.tabActive;
let panels = this.props.children
.map(tab => {
return typeof tab === "function" ? tab() : tab;
}).filter(tab => {
return tab;
}).map((tab, index) => {
let selected = selectedIndex == index;
// Use 'visibility:hidden' + 'width/height:0' for hiding
// content of non-selected tab. It's faster (not sure why)
// than display:none and visibility:collapse.
let style = {
visibility: selected ? "visible" : "hidden",
height: selected ? "100%" : "0",
width: selected ? "100%" : "0",
};
return (
DOM.div({
key: index,
id: "panel-" + index,
style: style,
className: "tab-panel-box",
role: "tabpanel",
"aria-labelledby": "tab-" + index,
},
(selected || this.state.created[index]) ? tab : null
)
);
});
return (
DOM.div({className: "panels"},
panels
)
);
},
render: function () {
let classNames = ["tabs", this.props.className].join(" ");
return (
DOM.div({className: classNames},
this.renderMenuItems(),
this.renderPanels()
)
);
},
});
/**
* Renders simple tab 'panel'.
*/
let Panel = React.createClass({
displayName: "Panel",
propTypes: {
title: React.PropTypes.string.isRequired,
children: React.PropTypes.oneOfType([
React.PropTypes.array,
React.PropTypes.element
]).isRequired
},
render: function () {
return DOM.div({className: "tab-panel"},
this.props.children
);
}
});
// Exports from this module
exports.TabPanel = Panel;
exports.Tabs = Tabs;
});