476 lines
14 KiB
JavaScript
476 lines
14 KiB
JavaScript
/* 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/. */
|
|
|
|
import React from "resource://devtools/client/shared/vendor/react.mjs";
|
|
import * as dom from "resource://devtools/client/shared/vendor/react-dom-factories.mjs";
|
|
import PropTypes from "resource://devtools/client/shared/vendor/react-prop-types.mjs";
|
|
|
|
const { Component, createRef } = React;
|
|
|
|
/**
|
|
* Renders simple 'tab' widget.
|
|
*
|
|
* Based on ReactSimpleTabs component
|
|
* https://github.com/pedronauck/react-simpletabs
|
|
*
|
|
* 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,
|
|
};
|
|
}
|
|
|
|
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,
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
|
|
UNSAFE_componentWillReceiveProps(nextProps) {
|
|
let { children, activeTab } = nextProps;
|
|
const panels = children.filter(panel => panel);
|
|
let created = [...this.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 (this.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 = this.state.created.find(entry => {
|
|
return entry && entry.tabId === panel.props.id;
|
|
});
|
|
const isCreated = !!createdEntry && createdEntry.isCreated;
|
|
const tabId = panel.props.id;
|
|
|
|
return {
|
|
isCreated,
|
|
tabId,
|
|
};
|
|
});
|
|
}
|
|
|
|
// 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,
|
|
});
|
|
|
|
this.setState({
|
|
activeTab,
|
|
});
|
|
}
|
|
|
|
this.setState({
|
|
created,
|
|
});
|
|
}
|
|
|
|
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, { fromMouseEvent: true });
|
|
|
|
if (event) {
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
onMouseDown(event) {
|
|
// Prevents click-dragging the tab headers
|
|
if (event) {
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
// API
|
|
|
|
/**
|
|
* Set the active tab from its index
|
|
*
|
|
* @param {Integer} index
|
|
* Index of the tab that we want to set as the active one
|
|
* @param {Object} options
|
|
* @param {Boolean} options.fromMouseEvent
|
|
* Set to true if this is called from a click on the tab
|
|
*/
|
|
setActive(index, options = {}) {
|
|
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(
|
|
`a[data-tab-index="${index}"]`
|
|
);
|
|
selectedTab.focus({
|
|
// When focus is coming from a mouse event,
|
|
// prevent :focus-visible to be applied to the element
|
|
focusVisible: !options.fromMouseEvent,
|
|
});
|
|
|
|
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,
|
|
tooltip,
|
|
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: tooltip || 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 TabPanel 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
|
|
export { TabPanel, Tabs };
|