summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/components/tabs
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/components/tabs')
-rw-r--r--devtools/client/shared/components/tabs/TabBar.js378
-rw-r--r--devtools/client/shared/components/tabs/Tabs.css127
-rw-r--r--devtools/client/shared/components/tabs/Tabs.js468
-rw-r--r--devtools/client/shared/components/tabs/moz.build10
4 files changed, 983 insertions, 0 deletions
diff --git a/devtools/client/shared/components/tabs/TabBar.js b/devtools/client/shared/components/tabs/TabBar.js
new file mode 100644
index 0000000000..730e8c7802
--- /dev/null
+++ b/devtools/client/shared/components/tabs/TabBar.js
@@ -0,0 +1,378 @@
+/* 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/. */
+
+/* eslint-env browser */
+
+"use strict";
+
+const {
+ Component,
+ createFactory,
+ createRef,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+
+const Sidebar = createFactory(
+ require("resource://devtools/client/shared/components/Sidebar.js")
+);
+
+loader.lazyRequireGetter(
+ this,
+ "Menu",
+ "resource://devtools/client/framework/menu.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "MenuItem",
+ "resource://devtools/client/framework/menu-item.js"
+);
+
+// Shortcuts
+const { div } = dom;
+
+/**
+ * Renders Tabbar component.
+ */
+class Tabbar extends Component {
+ static get propTypes() {
+ return {
+ children: PropTypes.array,
+ menuDocument: PropTypes.object,
+ onSelect: PropTypes.func,
+ showAllTabsMenu: PropTypes.bool,
+ allTabsMenuButtonTooltip: PropTypes.string,
+ activeTabId: PropTypes.string,
+ renderOnlySelected: PropTypes.bool,
+ sidebarToggleButton: PropTypes.shape({
+ // Set to true if collapsed.
+ collapsed: PropTypes.bool.isRequired,
+ // Tooltip text used when the button indicates expanded state.
+ collapsePaneTitle: PropTypes.string.isRequired,
+ // Tooltip text used when the button indicates collapsed state.
+ expandPaneTitle: PropTypes.string.isRequired,
+ // Click callback
+ onClick: PropTypes.func.isRequired,
+ // align toggle button to right
+ alignRight: PropTypes.bool,
+ // if set to true toggle-button rotate 90
+ canVerticalSplit: PropTypes.bool,
+ }),
+ };
+ }
+
+ static get defaultProps() {
+ return {
+ menuDocument: window.parent.document,
+ showAllTabsMenu: false,
+ };
+ }
+
+ constructor(props, context) {
+ super(props, context);
+ const { activeTabId, children = [] } = props;
+ const tabs = this.createTabs(children);
+ const activeTab = tabs.findIndex((tab, index) => tab.id === activeTabId);
+
+ this.state = {
+ activeTab: activeTab === -1 ? 0 : activeTab,
+ tabs,
+ };
+
+ // Array of queued tabs to add to the Tabbar.
+ this.queuedTabs = [];
+
+ this.createTabs = this.createTabs.bind(this);
+ this.addTab = this.addTab.bind(this);
+ this.addAllQueuedTabs = this.addAllQueuedTabs.bind(this);
+ this.queueTab = this.queueTab.bind(this);
+ this.toggleTab = this.toggleTab.bind(this);
+ this.removeTab = this.removeTab.bind(this);
+ this.select = this.select.bind(this);
+ this.getTabIndex = this.getTabIndex.bind(this);
+ this.getTabId = this.getTabId.bind(this);
+ this.getCurrentTabId = this.getCurrentTabId.bind(this);
+ this.onTabChanged = this.onTabChanged.bind(this);
+ this.onAllTabsMenuClick = this.onAllTabsMenuClick.bind(this);
+ this.renderTab = this.renderTab.bind(this);
+ this.tabbarRef = createRef();
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { activeTabId, children = [] } = nextProps;
+ const tabs = this.createTabs(children);
+ const activeTab = tabs.findIndex((tab, index) => tab.id === activeTabId);
+
+ if (
+ activeTab !== this.state.activeTab ||
+ children !== this.props.children
+ ) {
+ this.setState({
+ activeTab: activeTab === -1 ? 0 : activeTab,
+ tabs,
+ });
+ }
+ }
+
+ createTabs(children) {
+ return children
+ .filter(panel => panel)
+ .map((panel, index) =>
+ Object.assign({}, children[index], {
+ id: panel.props.id || index,
+ panel,
+ title: panel.props.title,
+ })
+ );
+ }
+
+ // Public API
+
+ addTab(id, title, selected = false, panel, url, index = -1) {
+ const tabs = this.state.tabs.slice();
+
+ if (index >= 0) {
+ tabs.splice(index, 0, { id, title, panel, url });
+ } else {
+ tabs.push({ id, title, panel, url });
+ }
+
+ const newState = Object.assign({}, this.state, {
+ tabs,
+ });
+
+ if (selected) {
+ newState.activeTab = index >= 0 ? index : tabs.length - 1;
+ }
+
+ this.setState(newState, () => {
+ if (this.props.onSelect && selected) {
+ this.props.onSelect(id);
+ }
+ });
+ }
+
+ addAllQueuedTabs() {
+ if (!this.queuedTabs.length) {
+ return;
+ }
+
+ const tabs = this.state.tabs.slice();
+
+ // Preselect the first sidebar tab if none was explicitly selected.
+ let activeTab = 0;
+ let activeId = this.queuedTabs[0].id;
+
+ for (const { id, index, panel, selected, title, url } of this.queuedTabs) {
+ if (index >= 0) {
+ tabs.splice(index, 0, { id, title, panel, url });
+ } else {
+ tabs.push({ id, title, panel, url });
+ }
+
+ if (selected) {
+ activeId = id;
+ activeTab = index >= 0 ? index : tabs.length - 1;
+ }
+ }
+
+ const newState = Object.assign({}, this.state, {
+ activeTab,
+ tabs,
+ });
+
+ this.setState(newState, () => {
+ if (this.props.onSelect) {
+ this.props.onSelect(activeId);
+ }
+ });
+
+ this.queuedTabs = [];
+ }
+
+ /**
+ * Queues a tab to be added. This is more performant than calling addTab for every
+ * single tab to be added since we will limit the number of renders happening when
+ * a new state is set. Once all the tabs to be added have been queued, call
+ * addAllQueuedTabs() to populate the TabBar with all the queued tabs.
+ */
+ queueTab(id, title, selected = false, panel, url, index = -1) {
+ this.queuedTabs.push({
+ id,
+ index,
+ panel,
+ selected,
+ title,
+ url,
+ });
+ }
+
+ toggleTab(tabId, isVisible) {
+ const index = this.getTabIndex(tabId);
+ if (index < 0) {
+ return;
+ }
+
+ const tabs = this.state.tabs.slice();
+ tabs[index] = Object.assign({}, tabs[index], {
+ isVisible,
+ });
+
+ this.setState(
+ Object.assign({}, this.state, {
+ tabs,
+ })
+ );
+ }
+
+ removeTab(tabId) {
+ const index = this.getTabIndex(tabId);
+ if (index < 0) {
+ return;
+ }
+
+ const tabs = this.state.tabs.slice();
+ tabs.splice(index, 1);
+
+ let activeTab = this.state.activeTab - 1;
+ activeTab = activeTab === -1 ? 0 : activeTab;
+
+ this.setState(
+ Object.assign({}, this.state, {
+ activeTab,
+ tabs,
+ }),
+ () => {
+ // Select the next active tab and force the select event handler to initialize
+ // the panel if needed.
+ if (tabs.length && this.props.onSelect) {
+ this.props.onSelect(this.getTabId(activeTab));
+ }
+ }
+ );
+ }
+
+ select(tabId) {
+ const docRef = this.tabbarRef.current.ownerDocument;
+
+ const index = this.getTabIndex(tabId);
+ if (index < 0) {
+ return;
+ }
+
+ const newState = Object.assign({}, this.state, {
+ activeTab: index,
+ });
+
+ const tabDomElement = docRef.querySelector(`[data-tab-index="${index}"]`);
+
+ if (tabDomElement) {
+ tabDomElement.scrollIntoView();
+ }
+
+ this.setState(newState, () => {
+ if (this.props.onSelect) {
+ this.props.onSelect(tabId);
+ }
+ });
+ }
+
+ // Helpers
+
+ getTabIndex(tabId) {
+ let tabIndex = -1;
+ this.state.tabs.forEach((tab, index) => {
+ if (tab.id === tabId) {
+ tabIndex = index;
+ }
+ });
+ return tabIndex;
+ }
+
+ getTabId(index) {
+ return this.state.tabs[index].id;
+ }
+
+ getCurrentTabId() {
+ return this.state.tabs[this.state.activeTab].id;
+ }
+
+ // Event Handlers
+
+ onTabChanged(index) {
+ this.setState(
+ {
+ activeTab: index,
+ },
+ () => {
+ if (this.props.onSelect) {
+ this.props.onSelect(this.state.tabs[index].id);
+ }
+ }
+ );
+ }
+
+ onAllTabsMenuClick(event) {
+ const menu = new Menu();
+ const target = event.target;
+
+ // Generate list of menu items from the list of tabs.
+ this.state.tabs.forEach(tab => {
+ menu.append(
+ new MenuItem({
+ label: tab.title,
+ type: "checkbox",
+ checked: this.getCurrentTabId() === tab.id,
+ click: () => this.select(tab.id),
+ })
+ );
+ });
+
+ // Show a drop down menu with frames.
+ menu.popupAtTarget(target);
+
+ return menu;
+ }
+
+ // Rendering
+
+ renderTab(tab) {
+ if (typeof tab.panel === "function") {
+ return tab.panel({
+ key: tab.id,
+ title: tab.title,
+ id: tab.id,
+ url: tab.url,
+ });
+ }
+
+ return tab.panel;
+ }
+
+ render() {
+ const tabs = this.state.tabs.map(tab => this.renderTab(tab));
+
+ return div(
+ {
+ className: "devtools-sidebar-tabs",
+ ref: this.tabbarRef,
+ },
+ Sidebar(
+ {
+ onAllTabsMenuClick: this.onAllTabsMenuClick,
+ renderOnlySelected: this.props.renderOnlySelected,
+ showAllTabsMenu: this.props.showAllTabsMenu,
+ allTabsMenuButtonTooltip: this.props.allTabsMenuButtonTooltip,
+ sidebarToggleButton: this.props.sidebarToggleButton,
+ activeTab: this.state.activeTab,
+ onAfterChange: this.onTabChanged,
+ },
+ tabs
+ )
+ );
+ }
+}
+
+module.exports = Tabbar;
diff --git a/devtools/client/shared/components/tabs/Tabs.css b/devtools/client/shared/components/tabs/Tabs.css
new file mode 100644
index 0000000000..876088b667
--- /dev/null
+++ b/devtools/client/shared/components/tabs/Tabs.css
@@ -0,0 +1,127 @@
+/* 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/. */
+
+/* Tabs General Styles */
+
+.tabs {
+ --tab-height: var(--theme-toolbar-height);
+ height: 100%;
+ background: var(--theme-sidebar-background);
+ display: flex;
+ flex-direction: column;
+}
+
+.tabs.tabs-tall {
+ --tab-height: var(--theme-toolbar-tall-height);
+}
+
+/* Hides the tab strip in the TabBar */
+div[hidetabs=true] .tabs .tabs-navigation {
+ display: none;
+}
+
+.tabs .tabs-navigation {
+ box-sizing: border-box;
+ display: flex;
+ /* Reserve 1px for the border */
+ height: calc(var(--tab-height) + 1px);
+ position: relative;
+ border-bottom: 1px solid var(--theme-splitter-color);
+ background: var(--theme-tab-toolbar-background);
+}
+
+.tabs .tabs-menu {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ margin-inline-end: 15px;
+ flex-grow: 1;
+}
+
+/* The tab takes entire horizontal space and individual tabs
+ should stretch accordingly. Use flexbox for the behavior.
+ Use also `overflow: hidden` so, 'overflow' and 'underflow'
+ events are fired (it's utilized by the all-tabs-menu). */
+.tabs .tabs-navigation .tabs-menu {
+ overflow: hidden;
+ display: flex;
+ overflow-x: scroll;
+ scrollbar-width: none;
+}
+
+.tabs .tabs-menu-item {
+ display: inline-block;
+ position: relative;
+ margin: 0;
+ padding: 0;
+ color: var(--theme-toolbar-color);
+}
+
+.tabs .tabs-menu-item.is-active {
+ color: var(--theme-toolbar-selected-color);
+}
+
+.tabs .tabs-menu-item:hover {
+ background-color: var(--theme-toolbar-hover);
+}
+
+.tabs .tabs-menu-item:hover:active:not(.is-active) {
+ background-color: var(--theme-toolbar-hover-active);
+}
+
+.tabs .tabs-menu-item a {
+ --text-height: 16px;
+ --devtools-tab-border-width: 1px;
+ display: flex;
+ justify-content: center;
+ /* Vertically center text, calculate space remaining by taking the full height and removing
+ the block borders and text. Divide by 2 to distribute above and below. */
+ padding: calc((var(--tab-height) - var(--text-height) - (var(--devtools-tab-border-width) * 2)) / 2) 10px;
+ border: var(--devtools-tab-border-width) solid transparent;
+ font-size: 12px;
+ line-height: var(--text-height);
+ text-decoration: none;
+ white-space: nowrap;
+ cursor: default;
+ user-select: none;
+ text-align: center;
+}
+
+/* Remove the outline focusring from tabs-menu-item. */
+.tabs .tabs-navigation .tabs-menu-item > a:-moz-focusring {
+ outline: none;
+}
+
+.tabs .tabs-menu-item .tab-badge {
+ color: var(--theme-highlight-blue);
+ font-size: 80%;
+ font-style: italic;
+ /* Tabs have a 15px padding start/end, so we set the margins here in order to center the
+ badge after the tab title, with a 5px effective margin. */
+ margin-inline-start: 5px;
+ margin-inline-end: -10px;
+}
+
+.tabs .tabs-menu-item.is-active .tab-badge {
+ /* Use the same color as the tab-item when active */
+ color: inherit;
+}
+
+/* To avoid "select all" commands from selecting content in hidden tabs */
+.tabs .hidden,
+.tabs .hidden * {
+ user-select: none !important;
+}
+
+/* Make sure panel content takes entire vertical space. */
+.tabs .panels {
+ flex: 1;
+ overflow: hidden;
+}
+
+.tabs .tab-panel {
+ height: 100%;
+ overflow-x: hidden;
+ overflow-y: auto;
+}
diff --git a/devtools/client/shared/components/tabs/Tabs.js b/devtools/client/shared/components/tabs/Tabs.js
new file mode 100644
index 0000000000..a265032f9e
--- /dev/null
+++ b/devtools/client/shared/components/tabs/Tabs.js
@@ -0,0 +1,468 @@
+/* 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 {
+ Component,
+ createRef,
+ } = require("devtools/client/shared/vendor/react");
+ const dom = require("devtools/client/shared/vendor/react-dom-factories");
+ const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+
+ /**
+ * 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);
+
+ 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;
+});
diff --git a/devtools/client/shared/components/tabs/moz.build b/devtools/client/shared/components/tabs/moz.build
new file mode 100644
index 0000000000..15ede75b9d
--- /dev/null
+++ b/devtools/client/shared/components/tabs/moz.build
@@ -0,0 +1,10 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ "TabBar.js",
+ "Tabs.js",
+)