diff options
Diffstat (limited to 'devtools/client/shared/components/tabs')
-rw-r--r-- | devtools/client/shared/components/tabs/moz.build | 12 | ||||
-rw-r--r-- | devtools/client/shared/components/tabs/tabbar.css | 53 | ||||
-rw-r--r-- | devtools/client/shared/components/tabs/tabbar.js | 204 | ||||
-rw-r--r-- | devtools/client/shared/components/tabs/tabs.css | 183 | ||||
-rw-r--r-- | devtools/client/shared/components/tabs/tabs.js | 369 |
5 files changed, 821 insertions, 0 deletions
diff --git a/devtools/client/shared/components/tabs/moz.build b/devtools/client/shared/components/tabs/moz.build new file mode 100644 index 000000000..d4d5dc35d --- /dev/null +++ b/devtools/client/shared/components/tabs/moz.build @@ -0,0 +1,12 @@ +# -*- 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.css', + 'tabbar.js', + 'tabs.css', + 'tabs.js', +) diff --git a/devtools/client/shared/components/tabs/tabbar.css b/devtools/client/shared/components/tabs/tabbar.css new file mode 100644 index 000000000..72445e43e --- /dev/null +++ b/devtools/client/shared/components/tabs/tabbar.css @@ -0,0 +1,53 @@ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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 .tabs-navigation { + line-height: 15px; +} + +.tabs .tabs-navigation { + height: 24px; +} + +.tabs .tabs-menu-item:first-child { + border-inline-start-width: 0; +} + +.tabs .tabs-navigation .tabs-menu-item:focus { + outline: var(--theme-focus-outline); + outline-offset: -2px; +} + +.tabs .tabs-menu-item.is-active { + height: 23px; +} + +/* Firebug theme is using slightly different height. */ +.theme-firebug .tabs .tabs-navigation { + height: 24px; +} + +/* 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; +} + +.tabs .tabs-navigation .tabs-menu-item { + flex-grow: 1; +} + +.tabs .tabs-navigation .tabs-menu-item a { + text-align: center; +} + +/* Firebug theme doesn't stretch the tabs. */ +.theme-firebug .tabs .tabs-navigation .tabs-menu-item { + flex-grow: 0; +} + diff --git a/devtools/client/shared/components/tabs/tabbar.js b/devtools/client/shared/components/tabs/tabbar.js new file mode 100644 index 000000000..1e3aa4617 --- /dev/null +++ b/devtools/client/shared/components/tabs/tabbar.js @@ -0,0 +1,204 @@ +/* -*- 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"; + +const { DOM, createClass, PropTypes, createFactory } = require("devtools/client/shared/vendor/react"); +const Tabs = createFactory(require("devtools/client/shared/components/tabs/tabs").Tabs); + +const Menu = require("devtools/client/framework/menu"); +const MenuItem = require("devtools/client/framework/menu-item"); + +// Shortcuts +const { div } = DOM; + +/** + * Renders Tabbar component. + */ +let Tabbar = createClass({ + displayName: "Tabbar", + + propTypes: { + onSelect: PropTypes.func, + showAllTabsMenu: PropTypes.bool, + toolbox: PropTypes.object, + }, + + getDefaultProps: function () { + return { + showAllTabsMenu: false, + }; + }, + + getInitialState: function () { + return { + tabs: [], + activeTab: 0 + }; + }, + + // Public API + + addTab: function (id, title, selected = false, panel, url) { + let tabs = this.state.tabs.slice(); + tabs.push({id, title, panel, url}); + + let newState = Object.assign({}, this.state, { + tabs: tabs, + }); + + if (selected) { + newState.activeTab = tabs.length - 1; + } + + this.setState(newState, () => { + if (this.props.onSelect && selected) { + this.props.onSelect(id); + } + }); + }, + + toggleTab: function (tabId, isVisible) { + let index = this.getTabIndex(tabId); + if (index < 0) { + return; + } + + let tabs = this.state.tabs.slice(); + tabs[index] = Object.assign({}, tabs[index], { + isVisible: isVisible + }); + + this.setState(Object.assign({}, this.state, { + tabs: tabs, + })); + }, + + removeTab: function (tabId) { + let index = this.getTabIndex(tabId); + if (index < 0) { + return; + } + + let tabs = this.state.tabs.slice(); + tabs.splice(index, 1); + + this.setState(Object.assign({}, this.state, { + tabs: tabs, + })); + }, + + select: function (tabId) { + let index = this.getTabIndex(tabId); + if (index < 0) { + return; + } + + let newState = Object.assign({}, this.state, { + activeTab: index, + }); + + this.setState(newState, () => { + if (this.props.onSelect) { + this.props.onSelect(tabId); + } + }); + }, + + // Helpers + + getTabIndex: function (tabId) { + let tabIndex = -1; + this.state.tabs.forEach((tab, index) => { + if (tab.id == tabId) { + tabIndex = index; + } + }); + return tabIndex; + }, + + getTabId: function (index) { + return this.state.tabs[index].id; + }, + + getCurrentTabId: function () { + return this.state.tabs[this.state.activeTab].id; + }, + + // Event Handlers + + onTabChanged: function (index) { + this.setState({ + activeTab: index + }); + + if (this.props.onSelect) { + this.props.onSelect(this.state.tabs[index].id); + } + }, + + onAllTabsMenuClick: function (event) { + let menu = new Menu(); + let 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. + // XXX Missing menu API for specifying target (anchor) + // and relative position to it. See also: + // https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/Method/openPopup + // https://bugzilla.mozilla.org/show_bug.cgi?id=1274551 + let rect = target.getBoundingClientRect(); + let screenX = target.ownerDocument.defaultView.mozInnerScreenX; + let screenY = target.ownerDocument.defaultView.mozInnerScreenY; + menu.popup(rect.left + screenX, rect.bottom + screenY, this.props.toolbox); + + return menu; + }, + + // Rendering + + renderTab: function (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: function () { + let tabs = this.state.tabs.map(tab => { + return this.renderTab(tab); + }); + + return ( + div({className: "devtools-sidebar-tabs"}, + Tabs({ + onAllTabsMenuClick: this.onAllTabsMenuClick, + showAllTabsMenu: this.props.showAllTabsMenu, + tabActive: 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 000000000..0e70549c5 --- /dev/null +++ b/devtools/client/shared/components/tabs/tabs.css @@ -0,0 +1,183 @@ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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 { + height: 100%; +} + +.tabs .tabs-menu { + display: table; + list-style: none; + padding: 0; + margin: 0; +} + +.tabs .tabs-menu-item { + display: inline-block; +} + +.tabs .tabs-menu-item a { + display: block; + color: #A9A9A9; + padding: 4px 8px; + border: 1px solid transparent; + text-decoration: none; + white-space: nowrap; +} + +.tabs .tabs-menu-item a { + cursor: default; +} + +/* Make sure panel content takes entire vertical space. + (minus the height of the tab bar) */ +.tabs .panels { + height: calc(100% - 24px); +} + +.tabs .tab-panel { + height: 100%; +} + +.tabs .all-tabs-menu { + position: absolute; + top: 0; + offset-inline-end: 0; + width: 15px; + height: 100%; + border-inline-start: 1px solid var(--theme-splitter-color); + background: url("chrome://devtools/skin/images/dropmarker.svg"); + background-repeat: no-repeat; + background-position: center; + background-color: var(--theme-tab-toolbar-background); +} + +/* Light Theme */ + +.theme-dark .tabs, +.theme-light .tabs { + background: var(--theme-body-background); +} + +.theme-dark .tabs .tabs-navigation, +.theme-light .tabs .tabs-navigation { + position: relative; + border-bottom: 1px solid var(--theme-splitter-color); + background: var(--theme-tab-toolbar-background); +} + +.theme-dark .tabs .tabs-menu-item, +.theme-light .tabs .tabs-menu-item { + margin: 0; + padding: 0; + border-style: solid; + border-width: 0; + border-inline-start-width: 1px; + border-color: var(--theme-splitter-color); +} + +.theme-dark .tabs .tabs-menu-item:last-child, +.theme-light:not(.theme-firebug) .tabs .tabs-menu-item:last-child { + border-inline-end-width: 1px; +} + +.theme-dark .tabs .tabs-menu-item a, +.theme-light .tabs .tabs-menu-item a { + color: var(--theme-content-color1); + padding: 3px 15px; +} + +.theme-dark .tabs .tabs-menu-item:hover:not(.is-active), +.theme-light .tabs .tabs-menu-item:hover:not(.is-active) { + background-color: var(--toolbar-tab-hover); +} + +.theme-dark .tabs .tabs-menu-item:hover:active:not(.is-active), +.theme-light .tabs .tabs-menu-item:hover:active:not(.is-active) { + background-color: var(--toolbar-tab-hover-active); +} + +.theme-dark .tabs .tabs-menu-item.is-active, +.theme-light .tabs .tabs-menu-item.is-active { + background-color: var(--theme-selection-background); +} + +.theme-dark .tabs .tabs-menu-item.is-active a, +.theme-light .tabs .tabs-menu-item.is-active a { + color: var(--theme-selection-color); +} + +/* Dark Theme */ + +.theme-dark .tabs .tabs-menu-item a { + color: var(--theme-body-color-alt); +} + +.theme-dark .tabs .tabs-menu-item:hover:not(.is-active) a { + color: #CED3D9; +} + +.theme-dark .tabs .tabs-menu-item:hover:active a { + color: var(--theme-selection-color); +} + +/* Firebug Theme */ + +.theme-firebug .tabs .tabs-navigation { + background-image: linear-gradient(rgba(253, 253, 253, 0.2), rgba(253, 253, 253, 0)); + padding-top: 3px; + padding-left: 3px; + border-bottom: 1px solid rgb(170, 188, 207); +} + +.theme-firebug .tabs .tabs-menu { + margin-bottom: -1px; +} + +.theme-firebug .tabs .tabs-menu-item.is-active, +.theme-firebug .tabs .tabs-menu-item.is-active:hover { + background-color: transparent; +} + +.theme-firebug .tabs .tabs-menu-item { + position: relative; + border-inline-start-width: 0; +} + +.theme-firebug .tabs .tabs-menu-item a { + font-family: var(--proportional-font-family); + font-weight: bold; + color: var(--theme-body-color); + border-radius: 4px 4px 0 0; +} + +.theme-firebug .tabs .tabs-menu-item:hover:not(.is-active) a { + border: 1px solid #C8C8C8; + border-bottom: 1px solid transparent; + background-color: transparent; +} + +.theme-firebug .tabs .tabs-menu-item.is-active a { + background-color: rgb(247, 251, 254); + border: 1px solid rgb(170, 188, 207); + border-bottom-color: transparent; + color: var(--theme-body-color); +} + +.theme-firebug .tabs .tabs-menu-item:hover:active a { + background-color: var(--toolbar-tab-hover-active); +} + +.theme-firebug .tabs .tabs-menu-item.is-active:hover:active a { + background-color: var(--theme-selection-background); + color: var(--theme-selection-color); +} + +.theme-firebug .tabs .tabs-menu-item a { + border: 1px solid transparent; + padding: 4px 8px; +} diff --git a/devtools/client/shared/components/tabs/tabs.js b/devtools/client/shared/components/tabs/tabs.js new file mode 100644 index 000000000..eaa0738b3 --- /dev/null +++ b/devtools/client/shared/components/tabs/tabs.js @@ -0,0 +1,369 @@ +/* -*- 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: + * + * <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> + */ + 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; +}); |