From 20742822bcdfacc264d0ed6b516559dd5ce0ee64 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Wed, 11 Oct 2017 09:22:58 -0700 Subject: [PATCH] [UI Framework] Add KuiContextMenu. (#14183) (#14427) * Add KuiContextMenu. - Update KuiPopover to use K7 code. - Add KuiPanelSimple for use within KuiPopover; it's just the K7 KuiPanel renamed. - Refactor/rewrite KuiExpression and KuiExpressionButton to depend upon KuiPopover. - Add K7 shadow mixins and size and z-index vars to global_styling. * Update Dashboard panel to use KuiContextMenu. - Fix reloading issue when editing a visualization from within a dashboard. * Completely refactor KuiContextMenu to enable a single panel. - Move keyboard navigation logic into KuiContextPanel. - Set focus on the item which shows the panel we're leaving within KuiContextMenu. - Remove unnecessary logic from KuiPopoverTitle. - Replace confusing idToPanelMap and idToPreviousPanelIdMap props with a panels prop. - Replace panelRef prop with onHeightChange prop. - Migrate transition state and logic from KuiContextMenu into KuiContextMenuPanel. - Rename 'current panel' to 'incoming panel' for cohesion with 'outgoing panel.' - Map panel items to panels up-front. - Convert maps from state variables into instance variables. --- package.json | 2 + .../public/dashboard/grid/dashboard_grid.js | 11 +- .../dashboard_panel.test.js.snap | 50 --- .../public/dashboard/panel/dashboard_panel.js | 3 + .../public/dashboard/panel/panel_menu_item.js | 27 -- .../dashboard/panel/panel_options_menu.js | 124 +++--- .../kibana/public/dashboard/styles/index.less | 15 - ui_framework/dist/ui_framework.css | 387 +++++++++++++----- .../src/components/guide_demo/guide_demo.js | 27 +- .../doc_site/src/services/routes/routes.js | 18 + .../src/views/context_menu/context_menu.js | 151 +++++++ .../context_menu/context_menu_example.js | 69 ++++ .../src/views/context_menu/single_panel.js | 82 ++++ .../src/views/expression/expression.js | 175 +++++--- .../views/expression/expression_example.js | 5 +- .../src/views/panel_simple/panel_simple.js | 37 ++ .../panel_simple/panel_simple_example.js | 43 ++ .../doc_site/src/views/popover/popover.js | 2 +- .../views/popover/popover_anchor_position.js | 2 +- .../src/views/popover/popover_example.js | 52 ++- .../views/popover/popover_panel_class_name.js | 48 +++ .../src/views/popover/popover_with_title.js | 59 +++ .../__snapshots__/context_menu.test.js.snap | 205 ++++++++++ .../context_menu_item.test.js.snap | 53 +++ .../context_menu_panel.test.js.snap | 75 ++++ .../context_menu/_context_menu.scss | 26 ++ .../context_menu/_context_menu_item.scss | 50 +++ .../context_menu/_context_menu_panel.scss | 104 +++++ .../src/components/context_menu/_index.scss | 3 + .../components/context_menu/context_menu.js | 269 ++++++++++++ .../context_menu/context_menu.test.js | 115 ++++++ .../context_menu/context_menu_item.js | 60 +++ .../context_menu/context_menu_item.test.js | 67 +++ .../context_menu/context_menu_panel.js | 296 ++++++++++++++ .../context_menu/context_menu_panel.test.js | 244 +++++++++++ .../src/components/context_menu/index.js | 11 + .../__snapshots__/expression.test.js.snap | 17 + .../expression_button.test.js.snap | 57 +++ .../expression_item.test.js.snap | 17 - .../expression_item_button.test.js.snap | 57 --- .../expression_item_popover.test.js.snap | 80 ---- .../components/expression/_expression.scss | 120 +----- .../{expression_item.js => expression.js} | 6 +- ...ession_item.test.js => expression.test.js} | 12 +- ...on_item_button.js => expression_button.js} | 16 +- ...tton.test.js => expression_button.test.js} | 14 +- .../expression/expression_item_popover.js | 54 --- .../expression_item_popover.test.js | 68 --- .../src/components/expression/index.js | 5 +- ui_framework/src/components/index.js | 16 +- ui_framework/src/components/index.scss | 2 + .../__snapshots__/panel_simple.test.js.snap | 9 + .../src/components/panel_simple/_index.scss | 1 + .../panel_simple/_panel_simple.scss | 33 ++ .../src/components/panel_simple/index.js | 4 + .../components/panel_simple/panel_simple.js | 59 +++ .../panel_simple/panel_simple.test.js | 16 + .../__snapshots__/popover.test.js.snap | 30 +- .../__snapshots__/popover_title.test.js.snap | 9 + .../src/components/popover/_index.scss | 4 +- .../src/components/popover/_mixins.scss | 12 + .../src/components/popover/_popover.scss | 83 ++-- .../components/popover/_popover_title.scss | 3 + ui_framework/src/components/popover/index.js | 5 +- .../src/components/popover/popover.js | 167 ++++++-- .../src/components/popover/popover_title.js | 21 + .../components/popover/popover_title.test.js | 16 + .../src/global_styling/mixins/_index.scss | 1 + .../src/global_styling/mixins/_shadow.scss | 26 ++ .../src/global_styling/variables/_colors.scss | 3 + .../global_styling/variables/_z_index.scss | 19 + .../accessibility/cascading_menu_key_codes.js | 27 ++ .../src/services/accessibility/index.js | 1 + ui_framework/src/services/index.js | 1 + ui_framework/src/services/key_codes.js | 2 + ui_framework/src/test/snapshot_component.js | 6 + 76 files changed, 3226 insertions(+), 840 deletions(-) delete mode 100644 src/core_plugins/kibana/public/dashboard/panel/panel_menu_item.js create mode 100644 ui_framework/doc_site/src/views/context_menu/context_menu.js create mode 100644 ui_framework/doc_site/src/views/context_menu/context_menu_example.js create mode 100644 ui_framework/doc_site/src/views/context_menu/single_panel.js create mode 100644 ui_framework/doc_site/src/views/panel_simple/panel_simple.js create mode 100644 ui_framework/doc_site/src/views/panel_simple/panel_simple_example.js create mode 100644 ui_framework/doc_site/src/views/popover/popover_panel_class_name.js create mode 100644 ui_framework/doc_site/src/views/popover/popover_with_title.js create mode 100644 ui_framework/src/components/context_menu/__snapshots__/context_menu.test.js.snap create mode 100644 ui_framework/src/components/context_menu/__snapshots__/context_menu_item.test.js.snap create mode 100644 ui_framework/src/components/context_menu/__snapshots__/context_menu_panel.test.js.snap create mode 100644 ui_framework/src/components/context_menu/_context_menu.scss create mode 100644 ui_framework/src/components/context_menu/_context_menu_item.scss create mode 100644 ui_framework/src/components/context_menu/_context_menu_panel.scss create mode 100644 ui_framework/src/components/context_menu/_index.scss create mode 100644 ui_framework/src/components/context_menu/context_menu.js create mode 100644 ui_framework/src/components/context_menu/context_menu.test.js create mode 100644 ui_framework/src/components/context_menu/context_menu_item.js create mode 100644 ui_framework/src/components/context_menu/context_menu_item.test.js create mode 100644 ui_framework/src/components/context_menu/context_menu_panel.js create mode 100644 ui_framework/src/components/context_menu/context_menu_panel.test.js create mode 100644 ui_framework/src/components/context_menu/index.js create mode 100644 ui_framework/src/components/expression/__snapshots__/expression.test.js.snap create mode 100644 ui_framework/src/components/expression/__snapshots__/expression_button.test.js.snap delete mode 100644 ui_framework/src/components/expression/__snapshots__/expression_item.test.js.snap delete mode 100644 ui_framework/src/components/expression/__snapshots__/expression_item_button.test.js.snap delete mode 100644 ui_framework/src/components/expression/__snapshots__/expression_item_popover.test.js.snap rename ui_framework/src/components/expression/{expression_item.js => expression.js} (70%) rename ui_framework/src/components/expression/{expression_item.test.js => expression.test.js} (71%) rename ui_framework/src/components/expression/{expression_item_button.js => expression_button.js} (56%) rename ui_framework/src/components/expression/{expression_item_button.test.js => expression_button.test.js} (86%) delete mode 100644 ui_framework/src/components/expression/expression_item_popover.js delete mode 100644 ui_framework/src/components/expression/expression_item_popover.test.js create mode 100644 ui_framework/src/components/panel_simple/__snapshots__/panel_simple.test.js.snap create mode 100644 ui_framework/src/components/panel_simple/_index.scss create mode 100644 ui_framework/src/components/panel_simple/_panel_simple.scss create mode 100644 ui_framework/src/components/panel_simple/index.js create mode 100644 ui_framework/src/components/panel_simple/panel_simple.js create mode 100644 ui_framework/src/components/panel_simple/panel_simple.test.js create mode 100644 ui_framework/src/components/popover/__snapshots__/popover_title.test.js.snap create mode 100644 ui_framework/src/components/popover/_mixins.scss create mode 100644 ui_framework/src/components/popover/_popover_title.scss create mode 100644 ui_framework/src/components/popover/popover_title.js create mode 100644 ui_framework/src/components/popover/popover_title.test.js create mode 100644 ui_framework/src/global_styling/mixins/_shadow.scss create mode 100644 ui_framework/src/services/accessibility/cascading_menu_key_codes.js create mode 100644 ui_framework/src/test/snapshot_component.js diff --git a/package.json b/package.json index 95854f4ad74ed..0d206e93fd39f 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "expose-loader": "0.7.0", "extract-text-webpack-plugin": "0.8.2", "file-loader": "0.8.4", + "focus-trap-react": "3.0.3", "font-awesome": "4.4.0", "glob": "5.0.13", "glob-all": "3.0.1", @@ -194,6 +195,7 @@ "script-loader": "0.6.1", "semver": "5.1.0", "style-loader": "0.12.3", + "tabbable": "1.1.0", "tar": "2.2.0", "tinygradient": "0.3.0", "trunc-html": "1.0.2", diff --git a/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js index c84589f381383..9e0a3735397f4 100644 --- a/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js +++ b/src/core_plugins/kibana/public/dashboard/grid/dashboard_grid.js @@ -79,10 +79,17 @@ export class DashboardGrid extends React.Component { }; onPanelFocused = panelIndex => { - this.gridItems[panelIndex].style.zIndex = '1'; + const gridItem = this.gridItems[panelIndex]; + if (gridItem) { + gridItem.style.zIndex = '1'; + } }; + onPanelBlurred = panelIndex => { - this.gridItems[panelIndex].style.zIndex = 'auto'; + const gridItem = this.gridItems[panelIndex]; + if (gridItem) { + gridItem.style.zIndex = 'auto'; + } }; renderDOM() { diff --git a/src/core_plugins/kibana/public/dashboard/panel/__snapshots__/dashboard_panel.test.js.snap b/src/core_plugins/kibana/public/dashboard/panel/__snapshots__/dashboard_panel.test.js.snap index 2de4ff1f3dd4e..5df3da725ea3a 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/__snapshots__/dashboard_panel.test.js.snap +++ b/src/core_plugins/kibana/public/dashboard/panel/__snapshots__/dashboard_panel.test.js.snap @@ -29,56 +29,6 @@ exports[`DashboardPanel matches snapshot 1`] = ` role="button" tabindex="0" /> -
- -
diff --git a/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js b/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js index 1243bdf2ac90c..7e2aee70001da 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js +++ b/src/core_plugins/kibana/public/dashboard/panel/dashboard_panel.js @@ -60,9 +60,11 @@ export class DashboardPanel extends React.Component { } toggleExpandedPanel = () => this.props.onToggleExpanded(this.props.panel.panelIndex); + deletePanel = () => { this.props.onDeletePanel(this.props.panel.panelIndex); }; + onEditPanel = () => window.location = this.state.editUrl; onFocus = () => { @@ -71,6 +73,7 @@ export class DashboardPanel extends React.Component { onPanelFocused(this.props.panel.panelIndex); } }; + onBlur = () => { const { onPanelBlurred } = this.props; if (onPanelBlurred) { diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_menu_item.js b/src/core_plugins/kibana/public/dashboard/panel/panel_menu_item.js deleted file mode 100644 index df80d5f8cd6ee..0000000000000 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_menu_item.js +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { KuiMenuItem } from 'ui_framework/components'; - -export function PanelMenuItem({ iconClass, onClick, label, ...props }) { - const iconClasses = classNames('kuiButton__icon kuiIcon', iconClass); - return ( - - - ); -} - -PanelMenuItem.propTypes = { - iconClass: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, - label: PropTypes.string.isRequired -}; diff --git a/src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js b/src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js index 362c3b690c9ca..4fdab2f53d50c 100644 --- a/src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js +++ b/src/core_plugins/kibana/public/dashboard/panel/panel_options_menu.js @@ -1,78 +1,100 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { PanelMenuItem } from './panel_menu_item'; import { KuiPopover, - KuiMenu, - KuiKeyboardAccessible + KuiContextMenuPanel, + KuiContextMenuItem, + KuiKeyboardAccessible, } from 'ui_framework/components'; export class PanelOptionsMenu extends React.Component { state = { - showMenu: false + isPopoverOpen: false }; toggleMenu = () => { - this.setState({ showMenu: !this.state.showMenu }); + this.setState({ isPopoverOpen: !this.state.isPopoverOpen }); }; - closeMenu = () => this.setState({ showMenu: false }); - renderEditVisualizationMenuItem() { - return ( - this.setState({ isPopoverOpen: false }); + + renderItems() { + const items = [( + - ); - } + onClick={this.props.onEditPanel} + icon={( + + ), ( + + )]; - renderDeleteMenuItem() { - return ( - - ); - } + if (!this.props.isExpanded) { + items.push( + + ); + } - renderToggleExpandMenuItem() { - return ( - - ); + return items; } render() { + const button = ( + + + + ); + return ( - - - )} - isOpen={this.state.showMenu} + button={button} + isOpen={this.state.isPopoverOpen} + closePopover={this.closePopover} + panelPaddingSize="none" anchorPosition="right" - closePopover={this.closeMenu} > - - {this.renderEditVisualizationMenuItem()} - {this.renderToggleExpandMenuItem()} - {this.props.isExpanded ? null : this.renderDeleteMenuItem()} - + ); } diff --git a/src/core_plugins/kibana/public/dashboard/styles/index.less b/src/core_plugins/kibana/public/dashboard/styles/index.less index 129e4e5079dcc..29b12697be1b1 100644 --- a/src/core_plugins/kibana/public/dashboard/styles/index.less +++ b/src/core_plugins/kibana/public/dashboard/styles/index.less @@ -215,21 +215,6 @@ dashboard-panel { z-index: 25; } - .dashboardPanelMenuItem { - padding: 10px; - color: @text-color; - - p { - display: inline; - padding: 0 0 0 5px; - } - - &:hover { - color: @link-hover-color; - } - - } - .panel-title { font-size: inherit; diff --git a/ui_framework/dist/ui_framework.css b/ui_framework/dist/ui_framework.css index 9b45bdf9312a5..1199fcb19dc56 100644 --- a/ui_framework/dist/ui_framework.css +++ b/ui_framework/dist/ui_framework.css @@ -856,6 +856,213 @@ main { /* 2 */ width: 100%; } +.kuiContextMenu { + width: 256px; + position: relative; + overflow: hidden; + transition: height 150ms cubic-bezier(0.694, 0.0482, 0.335, 1); + border-radius: 4px; } + .kuiContextMenu .kuiContextMenu__content { + padding: 8px; } + +.kuiContextMenu__panel { + position: absolute; } + +.kuiContextMenu__icon { + margin-right: 8px; } + +.kuiContextMenu__itemLayout { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-align: center; + -webkit-align-items: center; + -ms-flex-align: center; + align-items: center; } + +.kuiContextMenuPanel { + width: 100%; + visibility: visible; + background-color: #ffffff; } + .kuiContextMenuPanel.kuiContextMenuPanel-txInLeft { + pointer-events: none; + -webkit-animation: kuiContextMenuPanelTxInLeft 250ms cubic-bezier(0.694, 0.0482, 0.335, 1); + animation: kuiContextMenuPanelTxInLeft 250ms cubic-bezier(0.694, 0.0482, 0.335, 1); } + .kuiContextMenuPanel.kuiContextMenuPanel-txOutLeft { + pointer-events: none; + -webkit-animation: kuiContextMenuPanelTxOutLeft 250ms cubic-bezier(0.694, 0.0482, 0.335, 1); + animation: kuiContextMenuPanelTxOutLeft 250ms cubic-bezier(0.694, 0.0482, 0.335, 1); } + .kuiContextMenuPanel.kuiContextMenuPanel-txInRight { + pointer-events: none; + -webkit-animation: kuiContextMenuPanelTxInRight 250ms cubic-bezier(0.694, 0.0482, 0.335, 1); + animation: kuiContextMenuPanelTxInRight 250ms cubic-bezier(0.694, 0.0482, 0.335, 1); } + .kuiContextMenuPanel.kuiContextMenuPanel-txOutRight { + pointer-events: none; + -webkit-animation: kuiContextMenuPanelTxOutRight 250ms cubic-bezier(0.694, 0.0482, 0.335, 1); + animation: kuiContextMenuPanelTxOutRight 250ms cubic-bezier(0.694, 0.0482, 0.335, 1); } + .theme-dark .kuiContextMenuPanel { + background-color: #777777; } + +.kuiContextMenuPanel--next { + -webkit-transform: translateX(256px); + transform: translateX(256px); + visibility: hidden; } + +.kuiContextMenuPanel--previous { + -webkit-transform: translateX(-256px); + transform: translateX(-256px); + visibility: hidden; } + +/** + * 1. Button reset. + */ +.kuiContextMenuPanelTitle { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + /* 1 */ + border: none; + /* 1 */ + cursor: pointer; + /* 1 */ + background-color: #e6e6e6; + border-bottom: 1px solid #D9D9D9; + padding: 12px; + font-size: 14px; + width: 100%; + text-align: left; + /** + * 1. Overwrite default style. + */ } + .theme-dark .kuiContextMenuPanelTitle { + background-color: #777777; + border-color: #444444; + color: #ffffff; } + .kuiContextMenuPanelTitle:hover .kuiContextMenu__text, .kuiContextMenuPanelTitle:focus .kuiContextMenu__text { + text-decoration: underline; } + .kuiContextMenuPanelTitle:focus { + box-shadow: none; + /* 1 */ } + +@-webkit-keyframes kuiContextMenuPanelTxInLeft { + 0% { + -webkit-transform: translateX(100%); + transform: translateX(100%); } + 100% { + -webkit-transform: translateX(0); + transform: translateX(0); } } + +@keyframes kuiContextMenuPanelTxInLeft { + 0% { + -webkit-transform: translateX(100%); + transform: translateX(100%); } + 100% { + -webkit-transform: translateX(0); + transform: translateX(0); } } + +@-webkit-keyframes kuiContextMenuPanelTxOutLeft { + 0% { + -webkit-transform: translateX(0); + transform: translateX(0); } + 100% { + -webkit-transform: translateX(-100%); + transform: translateX(-100%); } } + +@keyframes kuiContextMenuPanelTxOutLeft { + 0% { + -webkit-transform: translateX(0); + transform: translateX(0); } + 100% { + -webkit-transform: translateX(-100%); + transform: translateX(-100%); } } + +@-webkit-keyframes kuiContextMenuPanelTxInRight { + 0% { + -webkit-transform: translateX(-100%); + transform: translateX(-100%); } + 100% { + -webkit-transform: translateX(0); + transform: translateX(0); } } + +@keyframes kuiContextMenuPanelTxInRight { + 0% { + -webkit-transform: translateX(-100%); + transform: translateX(-100%); } + 100% { + -webkit-transform: translateX(0); + transform: translateX(0); } } + +@-webkit-keyframes kuiContextMenuPanelTxOutRight { + 0% { + -webkit-transform: translateX(0); + transform: translateX(0); } + 100% { + -webkit-transform: translateX(100%); + transform: translateX(100%); } } + +@keyframes kuiContextMenuPanelTxOutRight { + 0% { + -webkit-transform: translateX(0); + transform: translateX(0); } + 100% { + -webkit-transform: translateX(100%); + transform: translateX(100%); } } + +/** + * 1. Button reset. + * 2. Ensure buttons stack. + */ +.kuiContextMenuItem { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + /* 1 */ + background-color: transparent; + /* 1 */ + font-size: 14px; + /* 1 */ + border: none; + /* 1 */ + cursor: pointer; + /* 1 */ + display: block; + /* 2 */ + padding: 12px; + width: 100%; + text-align: left; + color: #2d2d2d; + /** + * 1. Overwrite default style. + */ } + .kuiContextMenuItem:hover .kuiContextMenuItem__text, .kuiContextMenuItem:focus .kuiContextMenuItem__text { + text-decoration: underline; } + .kuiContextMenuItem:focus { + background-color: rgba(63, 168, 199, 0.2); + box-shadow: none; + /* 1 */ } + .theme-dark .kuiContextMenuItem:focus { + background-color: transparent; } + .theme-dark .kuiContextMenuItem { + color: #ffffff; } + +.kuiContextMenuItem__inner { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; } + +.kuiContextMenuItem__text { + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; } + +.kuiContextMenuItem__arrow { + -webkit-align-self: flex-end; + -ms-flex-item-align: end; + align-self: flex-end; } + .kuiEvent { display: -webkit-box; display: -webkit-flex; @@ -887,13 +1094,11 @@ main { line-height: 1.5; color: #666; } -.kuiExpressionItem { - display: inline-block; - position: relative; } - .kuiExpressionItem + .kuiExpressionItem { - margin-left: 10px; } +.kuiExpression { + padding: 20px; + white-space: nowrap; } -.kuiExpressionItem__button { +.kuiExpressionButton { background-color: transparent; padding: 5px 0px; border: none; @@ -901,95 +1106,17 @@ main { font-size: 14px; cursor: pointer; } -.kuiExpressionItem__buttonDescription { +.kuiExpressionButton__description { color: #00A69B; text-transform: uppercase; } -.kuiExpressionItem__buttonValue { +.kuiExpressionButton__value { color: #2d2d2d; text-transform: lowercase; } -.kuiExpressionItem__button--isActive { +.kuiExpressionButton-isActive { border-bottom: solid 2px #00A69B; } -.kuiExpressionItem__popover { - position: absolute; - top: calc(100% + 15px); - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-orient: vertical; - -webkit-box-direction: normal; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-flex: 1; - -webkit-flex: 1 1 auto; - -ms-flex: 1 1 auto; - flex: 1 1 auto; - background-color: white; - border: 1px solid #D9D9D9; - border-radius: 6px; - box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.1); - visibility: visible; - opacity: 1; - -webkit-transform: translateY(-5px) translateZ(0); - transform: translateY(-5px) translateZ(0); - transition: opacity 250ms cubic-bezier(0.34, 1.61, 0.7, 1), -webkit-transform 250ms cubic-bezier(0.34, 1.61, 0.7, 1); - transition: transform 250ms cubic-bezier(0.34, 1.61, 0.7, 1), opacity 250ms cubic-bezier(0.34, 1.61, 0.7, 1); - transition: transform 250ms cubic-bezier(0.34, 1.61, 0.7, 1), opacity 250ms cubic-bezier(0.34, 1.61, 0.7, 1), -webkit-transform 250ms cubic-bezier(0.34, 1.61, 0.7, 1); } - .kuiExpressionItem__popover.ng-hide { - display: block !important; - visibility: hidden; - opacity: 0; - -webkit-transform: translateY(0px) translateZ(0); - transform: translateY(0px) translateZ(0); } - .kuiExpressionItem__popover:before { - position: absolute; - content: ""; - top: -8px; - left: 20px; - height: 0; - width: 0; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - border-bottom: 8px solid #D9D9D9; } - .kuiExpressionItem__popover:after { - position: absolute; - content: ""; - top: -7px; - left: 20px; - height: 0; - width: 0; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - border-bottom: 8px solid #e6e6e6; } - .kuiExpressionItem__popover.kuiExpressionItem__popover--alignRight { - right: 0; } - .kuiExpressionItem__popover.kuiExpressionItem__popover--alignRight:before, .kuiExpressionItem__popover.kuiExpressionItem__popover--alignRight:after { - left: auto; - right: 20px; } - -.kuiExpressionItem__popoverTitle { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-flex: 1; - -webkit-flex: 1 1 auto; - -ms-flex: 1 1 auto; - flex: 1 1 auto; - background-color: #e6e6e6; - border-radius: 4px 4px 0 0; - color: #2d2d2d; - padding: 5px 10px; - line-height: 1.5; } - -.kuiExpressionItem__popoverContent { - padding: 20px; - white-space: nowrap; } - .kuiFlexGroup { display: -webkit-box; display: -webkit-flex; @@ -3112,38 +3239,59 @@ main { .kuiPanelBody { padding: 10px; } +.kuiPanelSimple { + box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.1); + background-color: #FFF; + border: 1px solid #D9D9D9; + border-radius: 4px; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; } + .kuiPanelSimple.kuiPanelSimple--paddingSmall { + padding: 8px; } + .kuiPanelSimple.kuiPanelSimple--paddingMedium { + padding: 16px; } + .kuiPanelSimple.kuiPanelSimple--paddingLarge { + padding: 24px; } + .kuiPanelSimple.kuiPanelSimple--shadow { + box-shadow: 0 16px 16px -8px rgba(0, 0, 0, 0.1); } + .kuiPanelSimple.kuiPanelSimple--flexGrowZero { + -webkit-box-flex: 0; + -webkit-flex-grow: 0; + -ms-flex-positive: 0; + flex-grow: 0; } + .theme-dark .kuiPanelSimple { + background-color: #777777; + border-color: #444444; } + .kuiPopover { display: inline-block; position: relative; } - .kuiPopover.kuiPopover-isOpen .kuiPopover__body { + .kuiPopover.kuiPopover-isOpen .kuiPopover__panel { opacity: 1; visibility: visible; - display: inline-block; - z-index: 1; - margin-top: 10px; - box-shadow: 0 16px 16px -8px rgba(0, 0, 0, 0.1); } + z-index: 2000; + margin-top: 8px; + pointer-events: auto; } -.kuiPopover__body { - line-height: 1.5; - font-size: 14px; +.kuiPopover__panel { position: absolute; min-width: 256px; top: 100%; left: 50%; - background: #FFF; - border: 1px solid #D9D9D9; - border-radius: 4px 0 4px 4px; - padding: 16px; -webkit-transform: translateX(-50%) translateY(8px) translateZ(0); transform: translateX(-50%) translateY(8px) translateZ(0); -webkit-backface-visibility: hidden; backface-visibility: hidden; + transition: opacity cubic-bezier(0.34, 1.61, 0.7, 1) 350ms, visibility cubic-bezier(0.34, 1.61, 0.7, 1) 350ms, margin-top cubic-bezier(0.34, 1.61, 0.7, 1) 350ms; -webkit-transform-origin: center top; transform-origin: center top; opacity: 0; - display: none; - margin-top: 32px; } - .kuiPopover__body:before { + visibility: hidden; + pointer-events: none; + margin-top: 24px; } + .kuiPopover__panel:before { position: absolute; content: ""; top: -16px; @@ -3154,7 +3302,9 @@ main { border-left: 16px solid transparent; border-right: 16px solid transparent; border-bottom: 16px solid #D9D9D9; } - .kuiPopover__body:after { + .theme-dark .kuiPopover__panel:before { + border-bottom-color: #444444; } + .kuiPopover__panel:after { position: absolute; content: ""; top: -15px; @@ -3165,25 +3315,42 @@ main { width: 0; border-left: 16px solid transparent; border-right: 16px solid transparent; - border-bottom: 16px solid #FFF; } + border-bottom: 16px solid #ffffff; } + .theme-dark .kuiPopover__panel:after { + border-bottom-color: #777777; } + +.kuiPopover--withTitle .kuiPopover__panel:after { + border-bottom-color: #e6e6e6; } + .theme-dark .kuiPopover--withTitle .kuiPopover__panel:after { + border-bottom-color: #777777; } -.kuiPopover--anchorLeft .kuiPopover__body { +.kuiPopover--anchorLeft .kuiPopover__panel { left: 0; -webkit-transform: translateX(0%) translateY(8px) translateZ(0); transform: translateX(0%) translateY(8px) translateZ(0); } - .kuiPopover--anchorLeft .kuiPopover__body:before, .kuiPopover--anchorLeft .kuiPopover__body:after { + .kuiPopover--anchorLeft .kuiPopover__panel:before, .kuiPopover--anchorLeft .kuiPopover__panel:after { right: auto; - left: 8px; + left: 16px; margin: 0; } -.kuiPopover--anchorRight .kuiPopover__body { +.kuiPopover--anchorRight .kuiPopover__panel { left: 100%; -webkit-transform: translateX(-100%) translateY(8px) translateZ(0); transform: translateX(-100%) translateY(8px) translateZ(0); } - .kuiPopover--anchorRight .kuiPopover__body:before, .kuiPopover--anchorRight .kuiPopover__body:after { - right: 8px; + .kuiPopover--anchorRight .kuiPopover__panel:before, .kuiPopover--anchorRight .kuiPopover__panel:after { + right: 16px; left: auto; } +.kuiPopoverTitle { + background-color: #e6e6e6; + border-bottom: 1px solid #D9D9D9; + padding: 12px; + font-size: 14px; } + .theme-dark .kuiPopoverTitle { + background-color: #777777; + border-color: #444444; + color: #ffffff; } + .kuiEmptyTablePrompt { display: -webkit-box; display: -webkit-flex; diff --git a/ui_framework/doc_site/src/components/guide_demo/guide_demo.js b/ui_framework/doc_site/src/components/guide_demo/guide_demo.js index 3ca6a7ed6cc50..1ad99b5ba7edb 100644 --- a/ui_framework/doc_site/src/components/guide_demo/guide_demo.js +++ b/ui_framework/doc_site/src/components/guide_demo/guide_demo.js @@ -34,15 +34,30 @@ export class GuideDemo extends Component { } render() { - const classes = classNames('guideDemo', this.props.className, { - 'guideDemo--fullScreen': this.props.isFullScreen, - 'guideDemo--darkTheme': this.props.isDarkTheme, - 'theme-dark': this.props.isDarkTheme, + const { + isFullScreen, + isDarkTheme, + children, + className, + js, // eslint-disable-line no-unused-vars + html, // eslint-disable-line no-unused-vars + css, // eslint-disable-line no-unused-vars + ...rest, + } = this.props; + + const classes = classNames('guideDemo', className, { + 'guideDemo--fullScreen': isFullScreen, + 'guideDemo--darkTheme': isDarkTheme, + 'theme-dark': isDarkTheme, }); return ( -
(this.content = c)}> - {this.props.children} +
(this.content = c)} + {...rest} + > + {children}
); } diff --git a/ui_framework/doc_site/src/services/routes/routes.js b/ui_framework/doc_site/src/services/routes/routes.js index 7cf4d7be713bd..072d43e56b54d 100644 --- a/ui_framework/doc_site/src/services/routes/routes.js +++ b/ui_framework/doc_site/src/services/routes/routes.js @@ -30,6 +30,9 @@ import ColorPickerExample import ColumnExample from '../../views/column/column_example'; +import ContextMenuExample + from '../../views/context_menu/context_menu_example'; + import EventExample from '../../views/event/event_example'; @@ -93,6 +96,9 @@ import PagerExample import PanelExample from '../../views/panel/panel_example'; +import PanelSimpleExample + from '../../views/panel_simple/panel_simple_example'; + import PopoverExample from '../../views/popover/popover_example'; @@ -162,6 +168,14 @@ const components = [{ }, { name: 'Column', component: ColumnExample, +}, { + name: 'CollapseButton', + component: CollapseButtonExample, + hasReact: true, +}, { + name: 'ContextMenu', + component: ContextMenuExample, + hasReact: true, }, { name: 'EmptyTablePrompt', component: EmptyTablePromptExample, @@ -230,6 +244,10 @@ const components = [{ }, { name: 'Panel', component: PanelExample, +}, { + name: 'PanelSimple', + component: PanelSimpleExample, + hasReact: true, }, { name: 'Popover', component: PopoverExample, diff --git a/ui_framework/doc_site/src/views/context_menu/context_menu.js b/ui_framework/doc_site/src/views/context_menu/context_menu.js new file mode 100644 index 0000000000000..9e98b8e4c2204 --- /dev/null +++ b/ui_framework/doc_site/src/views/context_menu/context_menu.js @@ -0,0 +1,151 @@ +import React, { + Component, +} from 'react'; + +import { + KuiButton, + KuiContextMenu, + KuiFieldGroup, + KuiFieldGroupSection, + KuiPopover, +} from '../../../../components'; + +function flattenPanelTree(tree, array = []) { + array.push(tree); + + if (tree.items) { + tree.items.forEach(item => { + if (item.panel) { + flattenPanelTree(item.panel, array); + item.panel = item.panel.id; + } + }); + } + + return array; +} + +export default class extends Component { + constructor(props) { + super(props); + + this.state = { + isPopoverOpen: false, + }; + + const panelTree = { + id: 0, + title: 'View options', + items: [{ + name: 'Show fullscreen', + icon: ( + + ), + onClick: () => window.alert('Show fullscreen'), + }, { + name: 'Share this dasbhoard', + icon: , + panel: { + id: 1, + title: 'Share this dashboard', + items: [{ + name: 'PDF reports', + icon: , + onClick: () => window.alert('PDF reports'), + }, { + name: 'CSV reports', + icon: , + onClick: () => window.alert('CSV reports'), + }, { + name: 'Embed code', + icon: , + panel: { + id: 2, + title: 'Embed code', + content: ( +
+
+ + +
+
+ +
+ + + + + + +
+ +
+ Save +
+
+ ), + }, + }, { + name: 'Permalinks', + icon: , + onClick: () => window.alert('Permalinks'), + }], + }, + }, { + name: 'Edit / add panels', + icon: , + onClick: () => window.alert('Edit / add panels'), + }, { + name: 'Display options', + icon: , + onClick: () => window.alert('Display options'), + }], + }; + + this.panels = flattenPanelTree(panelTree); + } + + onButtonClick() { + this.setState({ + isPopoverOpen: !this.state.isPopoverOpen, + }); + } + + closePopover() { + this.setState({ + isPopoverOpen: false, + }); + } + + render() { + const button = ( + + Click me to load a context menu + + ); + + return ( + + + + ); + } +} diff --git a/ui_framework/doc_site/src/views/context_menu/context_menu_example.js b/ui_framework/doc_site/src/views/context_menu/context_menu_example.js new file mode 100644 index 0000000000000..a7511686ea61c --- /dev/null +++ b/ui_framework/doc_site/src/views/context_menu/context_menu_example.js @@ -0,0 +1,69 @@ +import React from 'react'; + +import { renderToHtml } from '../../services'; + +import { + GuideCode, + GuideDemo, + GuidePage, + GuideSection, + GuideSectionTypes, + GuideText, +} from '../../components'; + +import ContextMenu from './context_menu'; +const contextMenuSource = require('!!raw!./context_menu'); +const contextMenuHtml = renderToHtml(ContextMenu); + +import SinglePanel from './single_panel'; +const singlePanelSource = require('!!raw!./single_panel'); +const singlePanelHtml = renderToHtml(SinglePanel); + +export default props => ( + + + + KuiContextMenu is a nested menu system useful + for navigating complicated trees. It lives within a KuiPopover + which itself can be wrapped around any component (like a button in this example). + + + + + + + + + + + + + + You can put a single panel inside of the menu using the + KuiContextMenuPanel component directly. + + + + + + + +); diff --git a/ui_framework/doc_site/src/views/context_menu/single_panel.js b/ui_framework/doc_site/src/views/context_menu/single_panel.js new file mode 100644 index 0000000000000..9a5beed67edea --- /dev/null +++ b/ui_framework/doc_site/src/views/context_menu/single_panel.js @@ -0,0 +1,82 @@ +import React, { + Component, +} from 'react'; + +import { + KuiButton, + KuiContextMenuPanel, + KuiContextMenuItem, + KuiPopover, +} from '../../../../components'; + +export default class extends Component { + constructor(props) { + super(props); + + this.state = { + isPopoverOpen: false, + }; + } + + onButtonClick() { + this.setState({ + isPopoverOpen: !this.state.isPopoverOpen, + }); + } + + closePopover() { + this.setState({ + isPopoverOpen: false, + }); + } + + render() { + const button = ( + + Click me to load a context menu + + ); + + const items = [( + } + onClick={() => { window.alert('A'); }} + > + Option A + + ), ( + } + onClick={() => { window.alert('B'); }} + > + Option B + + ), ( + } + onClick={() => { window.alert('C'); }} + > + Option C + + )]; + + return ( + + + + ); + } +} diff --git a/ui_framework/doc_site/src/views/expression/expression.js b/ui_framework/doc_site/src/views/expression/expression.js index e917dba12851b..3aac97f58c2e0 100644 --- a/ui_framework/doc_site/src/views/expression/expression.js +++ b/ui_framework/doc_site/src/views/expression/expression.js @@ -1,8 +1,11 @@ import React, { PropTypes } from 'react'; import { - KuiExpressionItem, - KuiExpressionItemButton, - KuiExpressionItemPopover, + KuiExpression, + KuiExpressionButton, + KuiFieldGroup, + KuiFieldGroupSection, + KuiPopover, + KuiPopoverTitle, } from '../../../../components'; @@ -12,6 +15,7 @@ class KuiExpressionItemExample extends React.Component { this.state = { example1: { + isOpen: false, value: 'count()' }, example2: { @@ -19,10 +23,53 @@ class KuiExpressionItemExample extends React.Component { value: '100', description: 'Is above' }, - activeButton: props.defaultActiveButton }; } + openExample1 = () => { + this.setState({ + example1: { + ...this.state.example1, + isOpen: true, + }, + example2: { + ...this.state.example2, + isOpen: false, + }, + }); + }; + + closeExample1 = () => { + this.setState({ + example1: { + ...this.state.example1, + isOpen: false, + }, + }); + }; + + openExample2 = () => { + this.setState({ + example1: { + ...this.state.example1, + isOpen: false, + }, + example2: { + ...this.state.example2, + isOpen: true, + }, + }); + }; + + closeExample2 = () => { + this.setState({ + example2: { + ...this.state.example2, + isOpen: false, + }, + }); + }; + changeExample1 = (event) => { this.setState({ example1: { ...this.state.example1, value: event.target.value } }); } @@ -39,73 +86,81 @@ class KuiExpressionItemExample extends React.Component { this.setState({ example2: { ...this.state.example2, description: event.target.value } }); } - onOutsideClick = () => { - this.setState({ activeButton:null }); - } - render() { - //Rise the popovers above GuidePageSideNav - const popoverStyle = { zIndex:'200' }; - - const popover1 = (this.state.activeButton === 'example1') ? this.getPopover1(popoverStyle) : null; - const popover2 = (this.state.activeButton === 'example2') ? this.getPopover2(popoverStyle) : null; + // Rise the popovers above GuidePageSideNav + const popoverStyle = { zIndex: '200' }; return ( -
- - this.setState({ activeButton:'example1' })} - /> - {popover1} - - - this.setState({ activeButton:'example2' })} - /> - {popover2} - -
+ + + + )} + isOpen={this.state.example1.isOpen} + closePopover={this.closeExample1} + panelPaddingSize="none" + withTitle + > + {this.getPopover1(popoverStyle)} + + + + + + )} + isOpen={this.state.example2.isOpen} + closePopover={this.closeExample2} + panelPaddingSize="none" + withTitle + anchorPosition="left" + > + {this.getPopover2(popoverStyle)} + + + ); } getPopover1(popoverStyle) { return ( - - - +
+ When + + + +
); } getPopover2(popoverStyle) { return ( - -
+
+ {this.state.example2.description} + -
- + +
); } } diff --git a/ui_framework/doc_site/src/views/expression/expression_example.js b/ui_framework/doc_site/src/views/expression/expression_example.js index 65e0cd8634c9e..fb60e3bcd29b7 100644 --- a/ui_framework/doc_site/src/views/expression/expression_example.js +++ b/ui_framework/doc_site/src/views/expression/expression_example.js @@ -16,7 +16,7 @@ const expressionHtml = renderToHtml(Expression, { defaultActiveButton: 'example2 export default props => ( ( }]} > - ExpressionItems allow you to compress a complicated form into a small space. - Left aligned to the button by default. Can be optionally right aligned (as in the last example). + ExpressionButtons allow you to compress a complicated form into a small space. diff --git a/ui_framework/doc_site/src/views/panel_simple/panel_simple.js b/ui_framework/doc_site/src/views/panel_simple/panel_simple.js new file mode 100644 index 0000000000000..fa3012729b9b1 --- /dev/null +++ b/ui_framework/doc_site/src/views/panel_simple/panel_simple.js @@ -0,0 +1,37 @@ +import React from 'react'; + +import { + KuiPanelSimple, +} from '../../../../components'; + +export default () => ( +
+ + sizePadding="none" + + +
+ + + sizePadding="s" + + +
+ + + sizePadding="m" + + +
+ + + sizePadding="l" + + +
+ + + sizePadding="l", hasShadow + +
+); diff --git a/ui_framework/doc_site/src/views/panel_simple/panel_simple_example.js b/ui_framework/doc_site/src/views/panel_simple/panel_simple_example.js new file mode 100644 index 0000000000000..8b470d8f9ace2 --- /dev/null +++ b/ui_framework/doc_site/src/views/panel_simple/panel_simple_example.js @@ -0,0 +1,43 @@ +import React from 'react'; + +import { Link } from 'react-router'; + +import { renderToHtml } from '../../services'; + +import { + GuideCode, + GuideDemo, + GuidePage, + GuideSection, + GuideSectionTypes, + GuideText, +} from '../../components'; + +import PanelSimple from './panel_simple'; +const panelSimpleSource = require('!!raw!./panel_simple'); +const panelSimpleHtml = renderToHtml(PanelSimple); + +export default props => ( + + + + PanelSimple is a simple wrapper component to add + depth to a contained layout. It it commonly used as a base for + other larger components like Popover. + + + + + + + +); diff --git a/ui_framework/doc_site/src/views/popover/popover.js b/ui_framework/doc_site/src/views/popover/popover.js index 7e40ccd2fdbf7..27112d5b21592 100644 --- a/ui_framework/doc_site/src/views/popover/popover.js +++ b/ui_framework/doc_site/src/views/popover/popover.js @@ -31,7 +31,7 @@ export default class extends Component { render() { const button = ( - Click me + Show popover ); diff --git a/ui_framework/doc_site/src/views/popover/popover_anchor_position.js b/ui_framework/doc_site/src/views/popover/popover_anchor_position.js index a5f78f2607c11..f78d8960ea178 100644 --- a/ui_framework/doc_site/src/views/popover/popover_anchor_position.js +++ b/ui_framework/doc_site/src/views/popover/popover_anchor_position.js @@ -62,7 +62,7 @@ export default class extends Component { - Popover anchored to the right. + Popover anchored to the left. )} isOpen={this.state.isPopoverOpen2} diff --git a/ui_framework/doc_site/src/views/popover/popover_example.js b/ui_framework/doc_site/src/views/popover/popover_example.js index da88e038a30db..cce611e531ce4 100644 --- a/ui_framework/doc_site/src/views/popover/popover_example.js +++ b/ui_framework/doc_site/src/views/popover/popover_example.js @@ -18,9 +18,13 @@ import PopoverAnchorPosition from './popover_anchor_position'; const popoverAnchorPositionSource = require('!!raw!./popover_anchor_position'); const popoverAnchorPositionHtml = renderToHtml(PopoverAnchorPosition); -import PopoverBodyClassName from './popover_body_class_name'; -const popoverBodyClassNameSource = require('!!raw!./popover_body_class_name'); -const popoverBodyClassNameHtml = renderToHtml(PopoverBodyClassName); +import PopoverPanelClassName from './popover_panel_class_name'; +const popoverPanelClassNameSource = require('!!raw!./popover_panel_class_name'); +const popoverPanelClassNameHtml = renderToHtml(PopoverPanelClassName); + +import PopoverWithTitle from './popover_with_title'; +const popoverWithTitleSource = require('!!raw!./popover_with_title'); +const popoverWithTitleHtml = renderToHtml(PopoverWithTitle); export default props => ( @@ -43,6 +47,28 @@ export default props => (
+ + + Popovers often have need for titling. This can be applied through + a prop or used separately as its own component + KuiPopoverTitle nested somwhere in the child + prop. + + + + + + + ( code: popoverAnchorPositionHtml, }]} > + + The alignment and arrow on your popover can be set with + the anchorPostion prop. + + + + Use the panelPaddingSize prop to adjust the padding + on the panel within the panel. Use the panelClassName + prop to pass a custom class to the panel. + inside a popover. + + - +
diff --git a/ui_framework/doc_site/src/views/popover/popover_panel_class_name.js b/ui_framework/doc_site/src/views/popover/popover_panel_class_name.js new file mode 100644 index 0000000000000..0a2c19f9c20fb --- /dev/null +++ b/ui_framework/doc_site/src/views/popover/popover_panel_class_name.js @@ -0,0 +1,48 @@ +import React, { + Component, +} from 'react'; + +import { + KuiPopover, + KuiButton, +} from '../../../../components'; + +export default class extends Component { + constructor(props) { + super(props); + + this.state = { + isPopoverOpen: false, + }; + } + + onButtonClick() { + this.setState({ + isPopoverOpen: !this.state.isPopoverOpen, + }); + } + + closePopover() { + this.setState({ + isPopoverOpen: false, + }); + } + + render() { + return ( + + Turn padding off and apply a custom class + + )} + isOpen={this.state.isPopoverOpen} + closePopover={this.closePopover.bind(this)} + panelClassName="yourClassNameHere" + panelPaddingSize="none" + > + This should have no padding, and if you inspect, also a custom class. + + ); + } +} diff --git a/ui_framework/doc_site/src/views/popover/popover_with_title.js b/ui_framework/doc_site/src/views/popover/popover_with_title.js new file mode 100644 index 0000000000000..9674954770332 --- /dev/null +++ b/ui_framework/doc_site/src/views/popover/popover_with_title.js @@ -0,0 +1,59 @@ +import React, { + Component, +} from 'react'; + +import { + KuiPopover, + KuiPopoverTitle, + KuiButton, +} from '../../../../components'; + +export default class extends Component { + constructor(props) { + super(props); + + this.state = { + isPopoverOpen: false, + }; + } + + onButtonClick() { + this.setState({ + isPopoverOpen: !this.state.isPopoverOpen, + }); + } + + closePopover() { + this.setState({ + isPopoverOpen: false, + }); + } + + render() { + const button = ( + + Show popover with Title + + ); + + return ( + +
+ Hello, I’m a popover title +

+ Popover content that’s wider than the default width +

+
+
+ ); + } +} diff --git a/ui_framework/src/components/context_menu/__snapshots__/context_menu.test.js.snap b/ui_framework/src/components/context_menu/__snapshots__/context_menu.test.js.snap new file mode 100644 index 0000000000000..a02339d4297cd --- /dev/null +++ b/ui_framework/src/components/context_menu/__snapshots__/context_menu.test.js.snap @@ -0,0 +1,205 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`KuiContextMenu is rendered 1`] = ` +
+`; + +exports[`KuiContextMenu props idToPanelMap and initialPanelId renders the referenced panel 1`] = ` +
+
+ +
+ 2 +
+
+
+`; + +exports[`KuiContextMenu props idToPreviousPanelIdMap allows you to click the title button to go back to the previous panel 1`] = ` +
+
+ +
+ 2 +
+
+
+`; + +exports[`KuiContextMenu props idToPreviousPanelIdMap allows you to click the title button to go back to the previous panel 2`] = ` +
+
+ +
+ 2 +
+
+
+ + + + +
+
+`; + +exports[`KuiContextMenu props isVisible causes the first panel to be shown when it becomes true 1`] = ` +
+
+ +
+ 2 +
+
+
+`; diff --git a/ui_framework/src/components/context_menu/__snapshots__/context_menu_item.test.js.snap b/ui_framework/src/components/context_menu/__snapshots__/context_menu_item.test.js.snap new file mode 100644 index 0000000000000..de10f1d71ac7e --- /dev/null +++ b/ui_framework/src/components/context_menu/__snapshots__/context_menu_item.test.js.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`KuiContextMenuItem is rendered 1`] = ` + +`; + +exports[`KuiContextMenuItem props hasPanel is rendered 1`] = ` + +`; + +exports[`KuiContextMenuItem props icon is rendered 1`] = ` + +`; diff --git a/ui_framework/src/components/context_menu/__snapshots__/context_menu_panel.test.js.snap b/ui_framework/src/components/context_menu/__snapshots__/context_menu_panel.test.js.snap new file mode 100644 index 0000000000000..87386cdc8d3b6 --- /dev/null +++ b/ui_framework/src/components/context_menu/__snapshots__/context_menu_panel.test.js.snap @@ -0,0 +1,75 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`KuiContextMenuPanel is rendered 1`] = ` +
+ Hello +
+`; + +exports[`KuiContextMenuPanel props onClose renders a button as a title 1`] = ` +
+ +
+`; + +exports[`KuiContextMenuPanel props title is rendered 1`] = ` +
+
+ + Title + +
+
+`; + +exports[`KuiContextMenuPanel props transitionDirection next with transitionType in is rendered 1`] = ` +
+`; + +exports[`KuiContextMenuPanel props transitionDirection next with transitionType out is rendered 1`] = ` +
+`; + +exports[`KuiContextMenuPanel props transitionDirection previous with transitionType in is rendered 1`] = ` +
+`; + +exports[`KuiContextMenuPanel props transitionDirection previous with transitionType out is rendered 1`] = ` +
+`; diff --git a/ui_framework/src/components/context_menu/_context_menu.scss b/ui_framework/src/components/context_menu/_context_menu.scss new file mode 100644 index 0000000000000..e2c9ea04a27dd --- /dev/null +++ b/ui_framework/src/components/context_menu/_context_menu.scss @@ -0,0 +1,26 @@ +$kuiContextMenuWidth: $kuiSize * 16; + +.kuiContextMenu { + width: $kuiContextMenuWidth; + position: relative; + overflow: hidden; + transition: height $kuiAnimSpeedFast $kuiAnimSlightResistance; + border-radius: $kuiBorderRadius; + + .kuiContextMenu__content { + padding: $kuiSizeS; + } +} + + .kuiContextMenu__panel { + position: absolute; + } + + .kuiContextMenu__icon { + margin-right: $kuiSizeS; + } + + .kuiContextMenu__itemLayout { + display: flex; + align-items: center; + } diff --git a/ui_framework/src/components/context_menu/_context_menu_item.scss b/ui_framework/src/components/context_menu/_context_menu_item.scss new file mode 100644 index 0000000000000..0b10016134e20 --- /dev/null +++ b/ui_framework/src/components/context_menu/_context_menu_item.scss @@ -0,0 +1,50 @@ +/** + * 1. Button reset. + * 2. Ensure buttons stack. + */ +.kuiContextMenuItem { + appearance: none; /* 1 */ + background-color: transparent; /* 1 */ + font-size: $kuiFontSize; /* 1 */ + border: none; /* 1 */ + cursor: pointer; /* 1 */ + display: block; /* 2 */ + padding: 12px; + width: 100%; + text-align: left; + color: $kuiTextColor; + + &:hover, &:focus { + .kuiContextMenuItem__text { + text-decoration: underline; + } + } + + /** + * 1. Overwrite default style. + */ + &:focus { + background-color: $kuiFocusAltBackgroundColor; + box-shadow: none; /* 1 */ + + @include darkTheme { + background-color: transparent; + } + } + + @include darkTheme { + color: #ffffff; + } +} + + .kuiContextMenuItem__inner { + display: flex; + } + + .kuiContextMenuItem__text { + flex-grow: 1; + } + + .kuiContextMenuItem__arrow { + align-self: flex-end; + } diff --git a/ui_framework/src/components/context_menu/_context_menu_panel.scss b/ui_framework/src/components/context_menu/_context_menu_panel.scss new file mode 100644 index 0000000000000..450f20b20bccc --- /dev/null +++ b/ui_framework/src/components/context_menu/_context_menu_panel.scss @@ -0,0 +1,104 @@ +@import '../popover/mixins'; + +.kuiContextMenuPanel { + width: 100%; + visibility: visible; + background-color: #ffffff; + + &.kuiContextMenuPanel-txInLeft { + pointer-events: none; + animation: kuiContextMenuPanelTxInLeft $kuiAnimSpeedNormal $kuiAnimSlightResistance; + } + + &.kuiContextMenuPanel-txOutLeft { + pointer-events: none; + animation: kuiContextMenuPanelTxOutLeft $kuiAnimSpeedNormal $kuiAnimSlightResistance; + } + + &.kuiContextMenuPanel-txInRight { + pointer-events: none; + animation: kuiContextMenuPanelTxInRight $kuiAnimSpeedNormal $kuiAnimSlightResistance; + } + + &.kuiContextMenuPanel-txOutRight { + pointer-events: none; + animation: kuiContextMenuPanelTxOutRight $kuiAnimSpeedNormal $kuiAnimSlightResistance; + } + + @include darkTheme { + background-color: $kuiBackgroundColor--darkTheme; + } +} + +.kuiContextMenuPanel--next { + transform: translateX($kuiContextMenuWidth); + visibility: hidden; +} + +.kuiContextMenuPanel--previous { + transform: translateX(-$kuiContextMenuWidth); + visibility: hidden; +} + +/** + * 1. Button reset. + */ +.kuiContextMenuPanelTitle { + appearance: none; /* 1 */ + border: none; /* 1 */ + cursor: pointer; /* 1 */ + + @include kuiPopoverTitle; + + width: 100%; + text-align: left; + + &:hover, &:focus { + .kuiContextMenu__text { + text-decoration: underline; + } + } + + /** + * 1. Overwrite default style. + */ + &:focus { + box-shadow: none; /* 1 */ + } +} + +@keyframes kuiContextMenuPanelTxInLeft { + 0% { + transform: translateX(100%); + } + 100% { + transform: translateX(0); + } +} + +@keyframes kuiContextMenuPanelTxOutLeft { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-100%); + } +} + +@keyframes kuiContextMenuPanelTxInRight { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(0); + } +} + +@keyframes kuiContextMenuPanelTxOutRight { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(100%); + } +} diff --git a/ui_framework/src/components/context_menu/_index.scss b/ui_framework/src/components/context_menu/_index.scss new file mode 100644 index 0000000000000..aaa11e331f219 --- /dev/null +++ b/ui_framework/src/components/context_menu/_index.scss @@ -0,0 +1,3 @@ +@import 'context_menu'; +@import 'context_menu_panel'; +@import 'context_menu_item'; diff --git a/ui_framework/src/components/context_menu/context_menu.js b/ui_framework/src/components/context_menu/context_menu.js new file mode 100644 index 0000000000000..2bcb4086b87b3 --- /dev/null +++ b/ui_framework/src/components/context_menu/context_menu.js @@ -0,0 +1,269 @@ +import React, { + Component, +} from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { KuiContextMenuPanel } from './context_menu_panel'; +import { KuiContextMenuItem } from './context_menu_item'; + +function mapIdsToPanels(panels) { + const map = {}; + + panels.forEach(panel => { + map[panel.id] = panel; + }); + + return map; +} + +function mapIdsToPreviousPanels(panels) { + const idToPreviousPanelIdMap = {}; + + panels.forEach(panel => { + if (Array.isArray(panel.items)) { + panel.items.forEach(item => { + const isCloseable = item.panel !== undefined; + if (isCloseable) { + idToPreviousPanelIdMap[item.panel] = panel.id; + } + }); + } + }); + + return idToPreviousPanelIdMap; +} + +function mapPanelItemsToPanels(panels) { + const idAndItemIndexToPanelIdMap = {}; + + panels.forEach(panel => { + idAndItemIndexToPanelIdMap[panel.id] = {}; + + if (panel.items) { + panel.items.forEach((item, index) => { + if (item.panel) { + idAndItemIndexToPanelIdMap[panel.id][index] = item.panel; + } + }); + } + }); + + return idAndItemIndexToPanelIdMap; +} + +export class KuiContextMenu extends Component { + static propTypes = { + className: PropTypes.string, + panels: PropTypes.array, + initialPanelId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + isVisible: PropTypes.bool.isRequired, + } + + static defaultProps = { + panels: [], + isVisible: true, + } + + constructor(props) { + super(props); + + this.idToPanelMap = {}; + this.idToPreviousPanelIdMap = {}; + this.idAndItemIndexToPanelIdMap = {}; + + this.state = { + height: undefined, + outgoingPanelId: undefined, + incomingPanelId: props.initialPanelId, + transitionDirection: undefined, + isOutgoingPanelVisible: false, + focusedItemIndex: undefined, + }; + } + + hasPreviousPanel = panelId => { + const previousPanelId = this.idToPreviousPanelIdMap[panelId]; + return typeof previousPanelId !== 'undefined'; + }; + + showPanel(panelId, direction) { + this.setState({ + outgoingPanelId: this.state.incomingPanelId, + incomingPanelId: panelId, + transitionDirection: direction, + isOutgoingPanelVisible: true, + }); + } + + showNextPanel = itemIndex => { + const nextPanelId = this.idAndItemIndexToPanelIdMap[this.state.incomingPanelId][itemIndex]; + if (nextPanelId) { + this.showPanel(nextPanelId, 'next'); + } + }; + + showPreviousPanel = () => { + // If there's a previous panel, then we can close the current panel to go back to it. + if (this.hasPreviousPanel(this.state.incomingPanelId)) { + const previousPanelId = this.idToPreviousPanelIdMap[this.state.incomingPanelId]; + + // Set focus on the item which shows the panel we're leaving. + const previousPanel = this.idToPanelMap[previousPanelId]; + const focusedItemIndex = previousPanel.items.findIndex( + item => item.panel === this.state.incomingPanelId + ); + + if (focusedItemIndex !== -1) { + this.setState({ + focusedItemIndex, + }); + } + + this.showPanel(previousPanelId, 'previous'); + } + }; + + onIncomingPanelHeightChange = height => { + this.setState({ + height, + }); + } + + onOutGoingPanelTransitionComplete = () => { + this.setState({ + isOutgoingPanelVisible: false, + }); + } + + updatePanelMaps(panels) { + this.idToPanelMap = mapIdsToPanels(panels); + this.idToPreviousPanelIdMap = mapIdsToPreviousPanels(panels); + this.idAndItemIndexToPanelIdMap = mapPanelItemsToPanels(panels); + } + + componentWillMount() { + this.updatePanelMaps(this.props.panels); + } + + componentWillReceiveProps(nextProps) { + // If the user is opening the context menu, reset the state. + if (nextProps.isVisible && !this.props.isVisible) { + this.setState({ + outgoingPanelId: undefined, + incomingPanelId: nextProps.initialPanelId, + transitionDirection: undefined, + focusedItemIndex: undefined, + }); + } + + if (nextProps.panels !== this.props.panels) { + this.updatePanelMaps(nextProps.panels); + } + } + + renderItems(items = []) { + return items.map((item, index) => { + const { + panel, + name, + icon, + onClick, + ...rest, + } = item; + + const onClickHandler = panel + ? () => { + // This component is commonly wrapped in a KuiOutsideClickDetector, which means we'll + // need to wait for that logic to complete before re-rendering the DOM via showPanel. + window.requestAnimationFrame(() => { + if (onClick) onClick(); + this.showNextPanel(index); + }); + } : onClick; + + return ( + + {name} + + ); + }); + } + + renderPanel(panelId, transitionType) { + const panel = this.idToPanelMap[panelId]; + + if (!panel) { + return; + } + + // As above, we need to wait for KuiOutsideClickDetector to complete its logic before + // re-rendering via showPanel. + let onClose; + if (this.hasPreviousPanel(panelId)) { + onClose = () => window.requestAnimationFrame(this.showPreviousPanel); + } + + return ( + + {panel.content} + + ); + } + + render() { + const { + panels, // eslint-disable-line no-unused-vars + className, + initialPanelId, // eslint-disable-line no-unused-vars + isVisible, // eslint-disable-line no-unused-vars + ...rest, + } = this.props; + + const incomingPanel = this.renderPanel(this.state.incomingPanelId, 'in'); + let outgoingPanel; + + if (this.state.isOutgoingPanelVisible) { + outgoingPanel = this.renderPanel(this.state.outgoingPanelId, 'out'); + } + + const classes = classNames('kuiContextMenu', className); + + return ( +
{ this.menu = node; }} + className={classes} + style={{ height: this.state.height }} + {...rest} + > + {outgoingPanel} + {incomingPanel} +
+ ); + } +} diff --git a/ui_framework/src/components/context_menu/context_menu.test.js b/ui_framework/src/components/context_menu/context_menu.test.js new file mode 100644 index 0000000000000..a71f869c4f12d --- /dev/null +++ b/ui_framework/src/components/context_menu/context_menu.test.js @@ -0,0 +1,115 @@ +import React from 'react'; +import { render, mount } from 'enzyme'; +import { + requiredProps, + takeMountedSnapshot, +} from '../../test'; + +import { KuiContextMenu } from './context_menu'; + +const panel2 = { + id: 2, + title: '2', + content:
2
, +}; + +const panel1 = { + id: 1, + title: '1', + items: [{ + name: '2a', + panel: 2, + }, { + name: '2b', + panel: 2, + }, { + name: '2c', + panel: 2, + }], +}; + +const panel0 = { + id: 0, + title: '0', + items: [{ + name: '1', + panel: 1, + }], +}; + +const panels = [ + panel0, + panel1, + panel2, +]; + +describe('KuiContextMenu', () => { + test('is rendered', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + + describe('props', () => { + describe('idToPanelMap and initialPanelId', () => { + it('renders the referenced panel', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + }); + + describe('idToPreviousPanelIdMap', () => { + it('allows you to click the title button to go back to the previous panel', () => { + const component = mount( + + ); + + expect(takeMountedSnapshot(component)) + .toMatchSnapshot(); + + // Navigate to a different panel. + component.find('[data-test-subj="contextMenuPanelTitleButton"]').simulate('click'); + + expect(takeMountedSnapshot(component)) + .toMatchSnapshot(); + }); + }); + + describe('isVisible', () => { + it('causes the first panel to be shown when it becomes true', () => { + const component = mount( + + ); + + // Navigate to a different panel. + component.find('[data-test-subj="contextMenuPanelTitleButton"]').simulate('click'); + + // Hide and then show the menu to reset the panel to the initial one. + component.setProps({ isVisible: false }); + component.setProps({ isVisible: true }); + + expect(takeMountedSnapshot(component)) + .toMatchSnapshot(); + }); + }); + }); +}); diff --git a/ui_framework/src/components/context_menu/context_menu_item.js b/ui_framework/src/components/context_menu/context_menu_item.js new file mode 100644 index 0000000000000..dbdcbe481c45f --- /dev/null +++ b/ui_framework/src/components/context_menu/context_menu_item.js @@ -0,0 +1,60 @@ +import React, { + cloneElement, + Component, +} from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export class KuiContextMenuItem extends Component { + static propTypes = { + children: PropTypes.node, + className: PropTypes.string, + icon: PropTypes.element, + onClick: PropTypes.func, + hasPanel: PropTypes.bool, + buttonRef: PropTypes.func, + } + + render() { + const { + children, + className, + hasPanel, + icon, + buttonRef, + ...rest, + } = this.props; + + let iconInstance; + + if (icon) { + iconInstance = cloneElement(icon, { + className: classNames(icon.props.className, 'kuiContextMenu__icon'), + }); + } + + let arrow; + + if (hasPanel) { + arrow = ; + } + + const classes = classNames('kuiContextMenuItem', className); + + return ( + + ); + } +} diff --git a/ui_framework/src/components/context_menu/context_menu_item.test.js b/ui_framework/src/components/context_menu/context_menu_item.test.js new file mode 100644 index 0000000000000..3240dc2b2a20b --- /dev/null +++ b/ui_framework/src/components/context_menu/context_menu_item.test.js @@ -0,0 +1,67 @@ +import React from 'react'; +import { render, shallow } from 'enzyme'; +import sinon from 'sinon'; +import { requiredProps } from '../../test/required_props'; + +import { KuiContextMenuItem } from './context_menu_item'; + +describe('KuiContextMenuItem', () => { + test('is rendered', () => { + const component = render( + + Hello + + ); + + expect(component) + .toMatchSnapshot(); + }); + + describe('props', () => { + describe('icon', () => { + test('is rendered', () => { + const component = render( + } /> + ); + + expect(component) + .toMatchSnapshot(); + }); + }); + + describe('onClick', () => { + test(`isn't called upon instantiation`, () => { + const onClickHandler = sinon.stub(); + + shallow( + + ); + + sinon.assert.notCalled(onClickHandler); + }); + + test('is called when the item is clicked', () => { + const onClickHandler = sinon.stub(); + + const component = shallow( + + ); + + component.simulate('click'); + + sinon.assert.calledOnce(onClickHandler); + }); + }); + + describe('hasPanel', () => { + test('is rendered', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + }); + }); +}); diff --git a/ui_framework/src/components/context_menu/context_menu_panel.js b/ui_framework/src/components/context_menu/context_menu_panel.js new file mode 100644 index 0000000000000..2a8412cf54225 --- /dev/null +++ b/ui_framework/src/components/context_menu/context_menu_panel.js @@ -0,0 +1,296 @@ +import React, { + cloneElement, + Component, +} from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import tabbable from 'tabbable'; + +import { KuiPopoverTitle } from '../../components'; +import { cascadingMenuKeyCodes } from '../../services'; + +const transitionDirectionAndTypeToClassNameMap = { + next: { + in: 'kuiContextMenuPanel-txInLeft', + out: 'kuiContextMenuPanel-txOutLeft', + }, + previous: { + in: 'kuiContextMenuPanel-txInRight', + out: 'kuiContextMenuPanel-txOutRight', + }, +}; + +export class KuiContextMenuPanel extends Component { + static propTypes = { + children: PropTypes.node, + className: PropTypes.string, + title: PropTypes.string, + onClose: PropTypes.func, + onHeightChange: PropTypes.func, + transitionType: PropTypes.oneOf(['in', 'out']), + transitionDirection: PropTypes.oneOf(['next', 'previous']), + onTransitionComplete: PropTypes.func, + hasFocus: PropTypes.bool, + items: PropTypes.array, + showNextPanel: PropTypes.func, + showPreviousPanel: PropTypes.func, + focusedItemIndex: PropTypes.number, + } + + static defaultProps = { + hasFocus: true, + items: [], + } + + constructor(props) { + super(props); + + this.menuItems = []; + this.state = { + pressedArrowDirection: undefined, + isTransitioning: Boolean(props.transitionType), + }; + } + + onKeyDown = e => { + // If this panel contains items you can use the left arrow key to go back at any time. + // But if it doesn't contain items, then you have to focus on the back button specifically, + // since there could be content inside the panel which requires use of the left arrow key, + // e.g. text inputs. + if (this.props.items.length || document.activeElement === this.backButton) { + if (e.keyCode === cascadingMenuKeyCodes.LEFT) { + if (this.props.showPreviousPanel) { + this.props.showPreviousPanel(); + } + } + } + + if (this.props.items.length) { + switch (e.keyCode) { + case cascadingMenuKeyCodes.TAB: + // Normal tabbing doesn't work within panels with items. + e.preventDefault(); + break; + + case cascadingMenuKeyCodes.UP: + e.preventDefault(); + this.setState({ pressedArrowDirection: 'up' }); + break; + + case cascadingMenuKeyCodes.DOWN: + e.preventDefault(); + this.setState({ pressedArrowDirection: 'down' }); + break; + + case cascadingMenuKeyCodes.RIGHT: + if (this.props.showNextPanel) { + this.props.showNextPanel(this.getFocusedMenuItemIndex()); + } + break; + + default: + break; + } + } + }; + + isMenuItemFocused() { + const indexOfActiveElement = this.menuItems.indexOf(document.activeElement); + return indexOfActiveElement !== -1; + } + + getFocusedMenuItemIndex() { + return this.menuItems.indexOf(document.activeElement); + } + + updateFocusedMenuItem() { + // If this panel isn't active, don't focus any items. + if (!this.props.hasFocus) { + if (this.isMenuItemFocused()) { + document.activeElement.blur(); + } + return; + } + + // Setting focus while transitioning causes the animation to glitch, so we have to wait + // until it's finished before we focus anything. + if (this.state.isTransitioning) { + return; + } + + // If we're active, but nothing is focused then we should focus the first item. + if (!this.isMenuItemFocused()) { + if (this.props.focusedItemIndex !== undefined) { + this.menuItems[this.props.focusedItemIndex].focus(); + return; + } + + if (this.menuItems.length !== 0) { + this.menuItems[0].focus(); + return; + } + + // Focus first tabbable item. + const tabbableItems = tabbable(this.panel); + if (tabbableItems.length) { + tabbableItems[0].focus(); + } + return; + } + + // Update focused state based on arrow key navigation. + if (this.state.pressedArrowDirection) { + const indexOfActiveElement = this.getFocusedMenuItemIndex(); + let nextFocusedMenuItemIndex; + + switch (this.state.pressedArrowDirection) { + case 'up': + nextFocusedMenuItemIndex = + (indexOfActiveElement - 1) !== -1 + ? indexOfActiveElement - 1 + : this.menuItems.length - 1; + break; + + case 'down': + nextFocusedMenuItemIndex = + (indexOfActiveElement + 1) !== this.menuItems.length + ? indexOfActiveElement + 1 + : 0; + break; + + default: + break; + } + + this.menuItems[nextFocusedMenuItemIndex].focus(); + this.setState({ pressedArrowDirection: undefined }); + } + } + + onTransitionComplete = () => { + this.setState({ + isTransitioning: false, + }); + + if (this.props.onTransitionComplete) { + this.props.onTransitionComplete(); + } + } + + componentWillReceiveProps(nextProps) { + // Clear refs to menuItems if we're getting new ones. + if (nextProps.items !== this.props.items) { + this.menuItems = []; + } + + if (nextProps.transitionType) { + this.setState({ + isTransitioning: true, + }); + } + } + + componentDidMount() { + this.updateFocusedMenuItem(); + } + + componentDidUpdate() { + this.updateFocusedMenuItem(); + } + + componentWillUnmount() { + this.panel.removeEventListener('animationend', this.onTransitionComplete); + } + + menuItemRef = (index, node) => { + // There's a weird bug where if you navigate to a panel without items, then this callback + // is still invoked, so we have to do a truthiness check. + if (node) { + // Store all menu items. + this.menuItems[index] = node; + } + }; + + panelRef = node => { + if (node) { + this.panel = node; + this.panel.addEventListener('animationend', this.onTransitionComplete); + + if (this.props.onHeightChange) { + this.props.onHeightChange(node.clientHeight); + } + } + } + + render() { + const { + children, + className, + onClose, + title, + onHeightChange, // eslint-disable-line no-unused-vars + transitionType, + transitionDirection, + onTransitionComplete, // eslint-disable-line no-unused-vars + hasFocus, // eslint-disable-line no-unused-vars + items, + focusedItemIndex, // eslint-disable-line no-unused-vars + showNextPanel, // eslint-disable-line no-unused-vars + showPreviousPanel, // eslint-disable-line no-unused-vars + ...rest, + } = this.props; + let panelTitle; + + if (title) { + if (Boolean(onClose)) { + panelTitle = ( + + ); + } else { + panelTitle = ( + + + {title} + + + ); + } + } + + const classes = classNames('kuiContextMenuPanel', className, ( + this.state.isTransitioning && transitionDirectionAndTypeToClassNameMap[transitionDirection] + ? transitionDirectionAndTypeToClassNameMap[transitionDirection][transitionType] + : undefined + )); + + const content = items.length + ? items.map((MenuItem, index) => cloneElement(MenuItem, { + buttonRef: this.menuItemRef.bind(this, index), + })) + : children; + + return ( +
+ {panelTitle} + {content} +
+ ); + } +} diff --git a/ui_framework/src/components/context_menu/context_menu_panel.test.js b/ui_framework/src/components/context_menu/context_menu_panel.test.js new file mode 100644 index 0000000000000..c6749b23d37e2 --- /dev/null +++ b/ui_framework/src/components/context_menu/context_menu_panel.test.js @@ -0,0 +1,244 @@ +import React from 'react'; +import { render, shallow, mount } from 'enzyme'; +import sinon from 'sinon'; +import { requiredProps } from '../../test/required_props'; + +import { + KuiContextMenuPanel, +} from './context_menu_panel'; + +import { + KuiContextMenuItem, +} from './context_menu_item'; + +import { keyCodes } from '../../services'; + +const items = [( + + Option A + +), ( + + Option B + +), ( + + Option C + +)]; + +describe('KuiContextMenuPanel', () => { + test('is rendered', () => { + const component = render( + + Hello + + ); + + expect(component) + .toMatchSnapshot(); + }); + + describe('props', () => { + describe('title', () => { + test('is rendered', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + }); + + describe('onClose', () => { + test('renders a button as a title', () => { + const component = render( + {}} /> + ); + + expect(component) + .toMatchSnapshot(); + }); + + test(`isn't called upon instantiation`, () => { + const onCloseHandler = sinon.stub(); + + shallow( + + ); + + sinon.assert.notCalled(onCloseHandler); + }); + + test('is called when the title is clicked', () => { + const onCloseHandler = sinon.stub(); + + const component = shallow( + + ); + + component.find('button').simulate('click'); + + sinon.assert.calledOnce(onCloseHandler); + }); + }); + + describe('onHeightChange', () => { + it('is called with a height value', () => { + const onHeightChange = sinon.stub(); + + mount( + + ); + + sinon.assert.calledWith(onHeightChange, 0); + }); + }); + + describe('transitionDirection', () => { + describe('next', () => { + describe('with transitionType', () => { + describe('in', () => { + test('is rendered', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + }); + + describe('out', () => { + test('is rendered', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + }); + }); + }); + + describe('previous', () => { + describe('with transitionType', () => { + describe('in', () => { + test('is rendered', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + }); + + describe('out', () => { + test('is rendered', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); + }); + }); + }); + }); + + describe('focusedItemIndex', () => { + it('sets focus on the item occupying that index', () => { + const component = mount( + + ); + + expect( + component.find('[data-test-subj="itemB"]').matchesElement(document.activeElement) + ).toBe(true); + }); + }); + }); + + describe('behavior', () => { + describe('focus', () => { + it('is set on the first focusable element by default, if there are no items', () => { + const component = mount( + + +`; + +exports[`KuiExpressionButton Props isActive true renders active 1`] = ` + +`; + +exports[`KuiExpressionButton renders 1`] = ` + +`; diff --git a/ui_framework/src/components/expression/__snapshots__/expression_item.test.js.snap b/ui_framework/src/components/expression/__snapshots__/expression_item.test.js.snap deleted file mode 100644 index d149874c1ecb4..0000000000000 --- a/ui_framework/src/components/expression/__snapshots__/expression_item.test.js.snap +++ /dev/null @@ -1,17 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`KuiExpressionItem Props children is rendered 1`] = ` -
- some expression -
-`; - -exports[`KuiExpressionItem renders 1`] = ` -
-`; diff --git a/ui_framework/src/components/expression/__snapshots__/expression_item_button.test.js.snap b/ui_framework/src/components/expression/__snapshots__/expression_item_button.test.js.snap deleted file mode 100644 index a3a5581338e27..0000000000000 --- a/ui_framework/src/components/expression/__snapshots__/expression_item_button.test.js.snap +++ /dev/null @@ -1,57 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`KuiExpressionItemButton Props isActive false renders inactive 1`] = ` - -`; - -exports[`KuiExpressionItemButton Props isActive true renders active 1`] = ` - -`; - -exports[`KuiExpressionItemButton renders 1`] = ` - -`; diff --git a/ui_framework/src/components/expression/__snapshots__/expression_item_popover.test.js.snap b/ui_framework/src/components/expression/__snapshots__/expression_item_popover.test.js.snap deleted file mode 100644 index 2ea66ad87044b..0000000000000 --- a/ui_framework/src/components/expression/__snapshots__/expression_item_popover.test.js.snap +++ /dev/null @@ -1,80 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`KuiExpressionItemPopover Props align renders default 1`] = ` -
-
- title -
-
-
-`; - -exports[`KuiExpressionItemPopover Props align renders the left class 1`] = ` -
-
- title -
-
-
-`; - -exports[`KuiExpressionItemPopover Props align renders the right class 1`] = ` -
-
- title -
-
-
-`; - -exports[`KuiExpressionItemPopover Props children is rendered 1`] = ` -
-
- title -
-
- popover content -
-
-`; - -exports[`KuiExpressionItemPopover renders 1`] = ` -
-
- title -
-
-
-`; diff --git a/ui_framework/src/components/expression/_expression.scss b/ui_framework/src/components/expression/_expression.scss index 3290ac55a5723..bb01f5fa83593 100644 --- a/ui_framework/src/components/expression/_expression.scss +++ b/ui_framework/src/components/expression/_expression.scss @@ -1,103 +1,27 @@ -.kuiExpressionItem { - display: inline-block; - position: relative; - - & + & { - margin-left: 10px; - } +.kuiExpression { + padding: 20px; + white-space: nowrap; } - .kuiExpressionItem__button { - background-color: transparent; - padding: 5px 0px; - border: none; - border-bottom: dotted 2px $kuiBorderColor; - font-size: $kuiFontSize; - cursor: pointer; - } - - .kuiExpressionItem__buttonDescription { - color: $expressionColorHighlight; - text-transform: uppercase; - } - - .kuiExpressionItem__buttonValue { - color: $kuiTextColor; - text-transform: lowercase; - } - - - .kuiExpressionItem__button--isActive { - border-bottom: solid 2px $expressionColorHighlight; - } - - .kuiExpressionItem__popover { - position: absolute; - top: calc(100% + 15px); - display: flex; - flex-direction: column; - flex: 1 1 auto; - background-color: white; - border: 1px solid $kuiBorderColor; - border-radius: 6px; - box-shadow: $kuiBoxShadow; - visibility: visible; - opacity: 1; - transform: translateY(-5px) translateZ(0); - transition: transform $kuiAnimSpeedNormal $kuiAnimSlightBounce, opacity $kuiAnimSpeedNormal $kuiAnimSlightBounce; - - // 1. Angulars ng-hide uses display: none. To use animations we need to use visibility instead. - &.ng-hide { - display: block !important; // 1 - visibility: hidden; - opacity: 0; - transform: translateY(0px) translateZ(0); - } - - &:before { - position: absolute; - content: ""; - top: -($kuiBorderRadius * 2); - left: 20px; - height: 0; - width: 0; - border-left: $kuiBorderRadius * 2 solid transparent; - border-right: $kuiBorderRadius * 2 solid transparent; - border-bottom: $kuiBorderRadius * 2 solid $kuiBorderColor; - } - - &:after { - position: absolute; - content: ""; - top: -($kuiBorderRadius * 2) + 1; - left: 20px; - height: 0; - width: 0; - border-left: $kuiBorderRadius * 2 solid transparent; - border-right: $kuiBorderRadius * 2 solid transparent; - border-bottom: $kuiBorderRadius * 2 solid lighten($kuiBorderColor, 5%); - } +.kuiExpressionButton { + background-color: transparent; + padding: 5px 0px; + border: none; + border-bottom: dotted 2px $kuiBorderColor; + font-size: $kuiFontSize; + cursor: pointer; +} - &.kuiExpressionItem__popover--alignRight { - right: 0; - &:before, &:after { - left: auto; - right: 20px; - } - } - } +.kuiExpressionButton__description { + color: $expressionColorHighlight; + text-transform: uppercase; +} - .kuiExpressionItem__popoverTitle { - display: flex; - flex: 1 1 auto; - background-color: lighten($kuiBorderColor, 5%); - border-radius: $kuiBorderRadius $kuiBorderRadius 0 0; - color: $kuiTextColor; - padding: 5px 10px; - line-height: $kuiLineHeight; - } +.kuiExpressionButton__value { + color: $kuiTextColor; + text-transform: lowercase; +} - .kuiExpressionItem__popoverContent { - padding: 20px; - white-space: nowrap; - } +.kuiExpressionButton-isActive { + border-bottom: solid 2px $expressionColorHighlight; +} diff --git a/ui_framework/src/components/expression/expression_item.js b/ui_framework/src/components/expression/expression.js similarity index 70% rename from ui_framework/src/components/expression/expression_item.js rename to ui_framework/src/components/expression/expression.js index a1e35b625b980..dadd8db2819b9 100644 --- a/ui_framework/src/components/expression/expression_item.js +++ b/ui_framework/src/components/expression/expression.js @@ -2,12 +2,12 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -export const KuiExpressionItem = ({ +export const KuiExpression = ({ children, className, ...rest }) => { - const classes = classNames('kuiExpressionItem', className); + const classes = classNames('kuiExpression', className); return (
{ +describe('KuiExpression', () => { test('renders', () => { const component = ( - + ); expect(render(component)).toMatchSnapshot(); @@ -19,9 +19,9 @@ describe('KuiExpressionItem', () => { describe('children', () => { test('is rendered', () => { const component = render( - + some expression - + ); expect(component) diff --git a/ui_framework/src/components/expression/expression_item_button.js b/ui_framework/src/components/expression/expression_button.js similarity index 56% rename from ui_framework/src/components/expression/expression_item_button.js rename to ui_framework/src/components/expression/expression_button.js index 4973f1953c6a3..3e950c8845a6b 100644 --- a/ui_framework/src/components/expression/expression_item_button.js +++ b/ui_framework/src/components/expression/expression_button.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -export const KuiExpressionItemButton = ({ +export const KuiExpressionButton = ({ className, description, buttonValue, @@ -10,8 +10,8 @@ export const KuiExpressionItemButton = ({ onClick, ...rest }) => { - const classes = classNames('kuiExpressionItem__button', className, { - 'kuiExpressionItem__button--isActive': isActive + const classes = classNames('kuiExpressionButton', className, { + 'kuiExpressionButton-isActive': isActive }); return ( @@ -20,16 +20,20 @@ export const KuiExpressionItemButton = ({ onClick={onClick} {...rest} > - {description}{' '} - {buttonValue} + {description}{' '} + {buttonValue} ); }; -KuiExpressionItemButton.propTypes = { +KuiExpressionButton.propTypes = { className: PropTypes.string, description: PropTypes.string.isRequired, buttonValue: PropTypes.string.isRequired, isActive: PropTypes.bool.isRequired, onClick: PropTypes.func.isRequired, }; + +KuiExpressionButton.defaultProps = { + isActive: false, +}; diff --git a/ui_framework/src/components/expression/expression_item_button.test.js b/ui_framework/src/components/expression/expression_button.test.js similarity index 86% rename from ui_framework/src/components/expression/expression_item_button.test.js rename to ui_framework/src/components/expression/expression_button.test.js index 27b03a7c33dc4..01147c87c4783 100644 --- a/ui_framework/src/components/expression/expression_item_button.test.js +++ b/ui_framework/src/components/expression/expression_button.test.js @@ -4,13 +4,13 @@ import { requiredProps } from '../../test/required_props'; import sinon from 'sinon'; import { - KuiExpressionItemButton, -} from './expression_item_button'; + KuiExpressionButton, +} from './expression_button'; -describe('KuiExpressionItemButton', () => { +describe('KuiExpressionButton', () => { test('renders', () => { const component = ( - { describe('isActive', () => { test('true renders active', () => { const component = ( - { test('false renders inactive', () => { const component = ( - { const onClickHandler = sinon.spy(); const button = shallow( - { - const classes = classNames('kuiExpressionItem__popover', className, { - 'kuiExpressionItem__popover--alignRight': align === 'right' - }); - return ( - -
-
- {title} -
-
- {children} -
-
-
- ); -}; - -KuiExpressionItemPopover.defaultProps = { - align: 'left', -}; - -KuiExpressionItemPopover.propTypes = { - className: PropTypes.string, - title: PropTypes.string.isRequired, - children: PropTypes.node, - align: PropTypes.oneOf(POPOVER_ALIGN), - onOutsideClick: PropTypes.func.isRequired, -}; - -export { - POPOVER_ALIGN, - KuiExpressionItemPopover -}; diff --git a/ui_framework/src/components/expression/expression_item_popover.test.js b/ui_framework/src/components/expression/expression_item_popover.test.js deleted file mode 100644 index 0ffdd5315a319..0000000000000 --- a/ui_framework/src/components/expression/expression_item_popover.test.js +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; -import { render } from 'enzyme'; -import { requiredProps } from '../../test/required_props'; - -import { - KuiExpressionItemPopover, - POPOVER_ALIGN -} from './expression_item_popover'; - -describe('KuiExpressionItemPopover', () => { - test('renders', () => { - const component = ( - {}} - {...requiredProps} - /> - ); - - expect(render(component)).toMatchSnapshot(); - }); - - describe('Props', () => { - describe('children', () => { - test('is rendered', () => { - const component = render( - {}} - > - popover content - - ); - - expect(component).toMatchSnapshot(); - }); - }); - - describe('align', () => { - test('renders default', () => { - const component = render( - {}} - /> - ); - - expect(component).toMatchSnapshot(); - }); - - POPOVER_ALIGN.forEach(align => { - test(`renders the ${align} class`, () => { - const component = render( - {}} - /> - ); - - expect(component).toMatchSnapshot(); - }); - }); - }); - }); -}); diff --git a/ui_framework/src/components/expression/index.js b/ui_framework/src/components/expression/index.js index 0a98d7b5cd8cb..fd5fc718cead2 100644 --- a/ui_framework/src/components/expression/index.js +++ b/ui_framework/src/components/expression/index.js @@ -1,3 +1,2 @@ -export { KuiExpressionItem } from './expression_item'; -export { KuiExpressionItemButton } from './expression_item_button'; -export { KuiExpressionItemPopover } from './expression_item_popover'; +export { KuiExpression } from './expression'; +export { KuiExpressionButton } from './expression_button'; diff --git a/ui_framework/src/components/index.js b/ui_framework/src/components/index.js index bcecd97c0cca5..fb2fb59b6e3d3 100644 --- a/ui_framework/src/components/index.js +++ b/ui_framework/src/components/index.js @@ -39,6 +39,12 @@ export { KuiCollapseButton, } from './collapse_button'; +export { + KuiContextMenu, + KuiContextMenuPanel, + KuiContextMenuItem, +} from './context_menu'; + export { KuiEmptyTablePrompt, KuiEmptyTablePromptMessage, @@ -54,9 +60,8 @@ export { } from './event'; export { - KuiExpressionItem, - KuiExpressionItemButton, - KuiExpressionItemPopover, + KuiExpression, + KuiExpressionButton, } from './expression'; export { @@ -121,8 +126,13 @@ export { KuiPagerButtonGroup, } from './pager'; +export { + KuiPanelSimple, +} from './panel_simple'; + export { KuiPopover, + KuiPopoverTitle, } from './popover'; export { diff --git a/ui_framework/src/components/index.scss b/ui_framework/src/components/index.scss index 92a02537ea8ed..9c93168ee33e4 100644 --- a/ui_framework/src/components/index.scss +++ b/ui_framework/src/components/index.scss @@ -22,6 +22,7 @@ @import "collapse_button/index"; @import "color_picker/index"; @import "column/index"; +@import 'context_menu/index'; @import "event/index"; @import "expression/index"; @import "flex/index"; @@ -41,6 +42,7 @@ @import "notice/index"; @import "pager/index"; @import "panel/index"; +@import "panel_simple/index"; @import "popover/index"; @import "empty_table_prompt/index"; @import "status_text/index"; diff --git a/ui_framework/src/components/panel_simple/__snapshots__/panel_simple.test.js.snap b/ui_framework/src/components/panel_simple/__snapshots__/panel_simple.test.js.snap new file mode 100644 index 0000000000000..f826cf6182732 --- /dev/null +++ b/ui_framework/src/components/panel_simple/__snapshots__/panel_simple.test.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`KuiPanelSimple is rendered 1`] = ` +
+`; diff --git a/ui_framework/src/components/panel_simple/_index.scss b/ui_framework/src/components/panel_simple/_index.scss new file mode 100644 index 0000000000000..31e793246dfee --- /dev/null +++ b/ui_framework/src/components/panel_simple/_index.scss @@ -0,0 +1 @@ +@import 'panel_simple'; diff --git a/ui_framework/src/components/panel_simple/_panel_simple.scss b/ui_framework/src/components/panel_simple/_panel_simple.scss new file mode 100644 index 0000000000000..3fc77bfa39871 --- /dev/null +++ b/ui_framework/src/components/panel_simple/_panel_simple.scss @@ -0,0 +1,33 @@ +.kuiPanelSimple { + @include kuiBottomShadowSmall; + + background-color: $kuiColorEmptyShade; + border: $kuiBorderThin; + border-radius: $kuiBorderRadius; + flex-grow: 1; + + &.kuiPanelSimple--paddingSmall { + padding: $kuiSizeS; + } + + &.kuiPanelSimple--paddingMedium { + padding: $kuiSize; + } + + &.kuiPanelSimple--paddingLarge { + padding: $kuiSizeL; + } + + &.kuiPanelSimple--shadow { + @include kuiBottomShadow; + } + + &.kuiPanelSimple--flexGrowZero { + flex-grow: 0; + } + + @include darkTheme { + background-color: $kuiBackgroundColor--darkTheme; + border-color: $kuiInputBorderColor--darkTheme; + } +} diff --git a/ui_framework/src/components/panel_simple/index.js b/ui_framework/src/components/panel_simple/index.js new file mode 100644 index 0000000000000..2c948eca87c75 --- /dev/null +++ b/ui_framework/src/components/panel_simple/index.js @@ -0,0 +1,4 @@ +export { + KuiPanelSimple, + SIZES, +} from './panel_simple'; diff --git a/ui_framework/src/components/panel_simple/panel_simple.js b/ui_framework/src/components/panel_simple/panel_simple.js new file mode 100644 index 0000000000000..1e25c3fc5b54e --- /dev/null +++ b/ui_framework/src/components/panel_simple/panel_simple.js @@ -0,0 +1,59 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +const paddingSizeToClassNameMap = { + 'none': null, + 's': 'kuiPanelSimple--paddingSmall', + 'm': 'kuiPanelSimple--paddingMedium', + 'l': 'kuiPanelSimple--paddingLarge', +}; + +export const SIZES = Object.keys(paddingSizeToClassNameMap); + +export const KuiPanelSimple = ({ + children, + className, + paddingSize, + hasShadow, + grow, + panelRef, + ...rest, +}) => { + + const classes = classNames( + 'kuiPanelSimple', + paddingSizeToClassNameMap[paddingSize], + { + 'kuiPanelSimple--shadow': hasShadow, + 'kuiPanelSimple--flexGrowZero': !grow, + }, + className + ); + + return ( +
+ {children} +
+ ); + +}; + +KuiPanelSimple.propTypes = { + children: PropTypes.node, + className: PropTypes.string, + hasShadow: PropTypes.bool, + paddingSize: PropTypes.oneOf(SIZES), + grow: PropTypes.bool, + panelRef: PropTypes.func, +}; + +KuiPanelSimple.defaultProps = { + paddingSize: 'm', + hasShadow: false, + grow: true, +}; diff --git a/ui_framework/src/components/panel_simple/panel_simple.test.js b/ui_framework/src/components/panel_simple/panel_simple.test.js new file mode 100644 index 0000000000000..8adfc8540fcd4 --- /dev/null +++ b/ui_framework/src/components/panel_simple/panel_simple.test.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { render } from 'enzyme'; +import { requiredProps } from '../../test/required_props'; + +import { KuiPanelSimple } from './panel_simple'; + +describe('KuiPanelSimple', () => { + test('is rendered', () => { + const component = render( + + ); + + expect(component) + .toMatchSnapshot(); + }); +}); diff --git a/ui_framework/src/components/popover/__snapshots__/popover.test.js.snap b/ui_framework/src/components/popover/__snapshots__/popover.test.js.snap index 83e3145e6bb96..845123c84601c 100644 --- a/ui_framework/src/components/popover/__snapshots__/popover.test.js.snap +++ b/ui_framework/src/components/popover/__snapshots__/popover.test.js.snap @@ -5,9 +5,6 @@ exports[`KuiPopover anchorPosition defaults to center 1`] = ` class="kuiPopover" >
`; @@ -53,9 +39,6 @@ exports[`KuiPopover is rendered 1`] = ` data-test-subj="test subject string" >