diff --git a/src-docs/src/services/routes/routes.js b/src-docs/src/services/routes/routes.js index 2ecbdaac45c..eb68ba38b8f 100644 --- a/src-docs/src/services/routes/routes.js +++ b/src-docs/src/services/routes/routes.js @@ -118,6 +118,10 @@ import { TitleExample } import { ToastExample } from '../../views/toast/toast_example'; +import { TooltipExample } + from '../../views/tooltip/tooltip_example'; + + // Sandboxes import AdvancedSettingsSandbox @@ -193,6 +197,7 @@ const components = [ TextExample, TitleExample, ToastExample, + TooltipExample, ].map(example => createExample(example)); const sandboxes = [{ diff --git a/src-docs/src/views/tooltip/examples.js b/src-docs/src/views/tooltip/examples.js new file mode 100644 index 00000000000..da312e2ff28 --- /dev/null +++ b/src-docs/src/views/tooltip/examples.js @@ -0,0 +1,59 @@ +import React from 'react'; + +import { + TooltipTrigger, +} from '../../../../src/components'; + +const autoPlacementTooltip = `I should be on the top but may get placed in another location +if I overflow the browser window. This will come in handy when tooltips get placed near the top +of pages. Its very hard to read a tooltip when part of it gets cut off and if you can't read it +then what is the point?`; + +export default () => ( +
+
+ Check out this {( + + tooltip with title. + + )} +
+
+ Check out this {( + + tooltip on the top. + + )} +
+
+ Check out this {( + + tooltip on click. + + )} +
+
+ Check out this {( + + tooltip on the left. + + )} +
+ +
+ Check out this {( + + tooltip on the right. + + )} +
+ +
+ Check out this {( + + tooltip on the bottom. + + )} +
+
+); diff --git a/src-docs/src/views/tooltip/tooltip_example.js b/src-docs/src/views/tooltip/tooltip_example.js new file mode 100644 index 00000000000..4c81fa51ff8 --- /dev/null +++ b/src-docs/src/views/tooltip/tooltip_example.js @@ -0,0 +1,49 @@ +import React from 'react'; + +import { renderToHtml } from '../../services'; + +import { + GuideSectionTypes, +} from '../../components'; + +import { + EuiCallOut, + EuiSpacer, +} from '../../../../src/components'; + +import TooltipExamples from './examples'; +const examplesSource = require('!!raw-loader!./examples'); +const examplesHtml = renderToHtml(TooltipExamples); + +export const TooltipExample = { + title: 'Tooltip', + intro: ( +
+ +

+ This component is still undergoing active development, and its interface and implementation + are both subject to change. +

+
+ + +
+ ), + sections: [{ + title: 'Tooltip', + source: [{ + type: GuideSectionTypes.JS, + code: examplesSource, + }, { + type: GuideSectionTypes.HTML, + code: examplesHtml, + }], + text: ( +

+ ), + demo: , + }], +}; diff --git a/src/components/index.js b/src/components/index.js index 24fd4181be1..08edc51e236 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -197,6 +197,11 @@ export { EuiToast, } from './toast'; +export { + Tooltip, + TooltipTrigger +} from './tooltip'; + export { EuiTitle, } from './title'; diff --git a/src/components/index.scss b/src/components/index.scss index 55f3d87c8b2..ef2b75b6ae0 100644 --- a/src/components/index.scss +++ b/src/components/index.scss @@ -33,4 +33,5 @@ @import 'tabs/index'; @import 'title/index'; @import 'toast/index'; +@import 'tooltip/index'; @import 'text/index'; diff --git a/src/components/tooltip/_index.scss b/src/components/tooltip/_index.scss new file mode 100644 index 00000000000..467d91f2207 --- /dev/null +++ b/src/components/tooltip/_index.scss @@ -0,0 +1 @@ +@import "tooltip"; diff --git a/src/components/tooltip/_tooltip.scss b/src/components/tooltip/_tooltip.scss new file mode 100644 index 00000000000..e399c6643a6 --- /dev/null +++ b/src/components/tooltip/_tooltip.scss @@ -0,0 +1,311 @@ +@mixin transition-pui($type:all, $speed:300ms, $ease: ease-out){ + -webkit-transition: $type $speed $ease; + -moz-transition: $type $speed $ease; + transition: $type $speed $ease; + @if($type == all) { + -webkit-transition-property: background-color, color, opacity; + -moz-transition-property: background-color, color, opacity; + transition-property: background-color, color, opacity; + } +} + +$font-size-extra-small: 12px; +$base-unit: 8px; + +$blue-3: #1B78B3; + +$navy-2: #243641; +$navy-9: #DFE5E8; + +$dark-2: $navy-2; +$dark-9: $navy-9; + +$accent-3: $blue-3; + +$link-color: $accent-3; + +$box-shadow-key-1: 0px 2px 2px 0px rgba(36,54,65,0.1); + +$box-shadow-amb-1: 0px 0px 2px 0px rgba(36,54,65,0.1); + +$tooltip-border-radius: 2px; +$tooltip-font-size: $font-size-extra-small; +$tooltip-padding: ($base-unit / 2) $base-unit; +$tooltip-margin: $base-unit; +$tooltip-container-z: 10; +$tooltip-arrow-pull: -4px; +$tooltip-arrow-z-bottom: -1; +$tooltip-arrow-z-top: 1; +$tooltip-opacity-transition: ease-out .2s; + + +$tooltip-bg: $dark-2; +$tooltip-color: #fff; +$tooltip-border-color: $dark-2; + +$tooltip-light-bg: #fff; +$tooltip-light-color: $dark-2; +$tooltip-light-border-color: $dark-9; + +$tooltip-sm-width: $base-unit * 15; +$tooltip-md-width: $base-unit * 30; +$tooltip-lg-width: $base-unit * 45; + +//Hover Effect for HTML & CSS ONLY +.tooltip:hover .tooltip-container:not(.tooltip-container-hidden) { + visibility: visible; + opacity: 1; +} + +.tooltip-container { + //Hover Effect for HTML & CSS ONLY + visibility: hidden; + transition: opacity $tooltip-opacity-transition; + z-index: $tooltip-container-z; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin: 0 0 $tooltip-margin 0; //space for the triangle indicator + text-align: left; + + &.tooltip-container-visible { + visibility: visible; + } + + &.tooltip-hoverable { + &:after { + content:""; + position: absolute; + width:calc(100% + 16px); + height:calc(100% + 16px); + top:50%; + left:50%; + transform: translateX(-50%) translateY(-50%); + } + } + + .tooltip-content { + white-space: nowrap; + padding: $tooltip-padding; + font-size: $tooltip-font-size; + line-height: 16px; + font-weight: 400; + letter-spacing: 0; + text-transform: none; + background-color: $tooltip-bg; + color: $tooltip-color; + border-radius: $tooltip-border-radius; + border: 1px solid $tooltip-border-color; + box-shadow: $box-shadow-key-1, $box-shadow-amb-1; + + &:before { + content: ""; + z-index: $tooltip-arrow-z-top; + position: absolute; + bottom: $tooltip-arrow-pull; + left: 50%; + transform: translateX(-50%) rotateZ(45deg); + background-color: $tooltip-bg; + border-bottom: 1px solid $tooltip-border-color; + border-right: 1px solid $tooltip-border-color; + width: $base-unit; + height: $base-unit; + } + + &:after { + content: ""; + box-sizing: content-box; + z-index: $tooltip-arrow-z-bottom; + position: absolute; + bottom: $tooltip-arrow-pull; + left: 50%; + transform: translateX(-50%) rotateZ(45deg); + background-color: $tooltip-bg; + box-shadow: $box-shadow-key-1, $box-shadow-amb-1; + width: $base-unit; + height: $base-unit; + } + } +} + +.tooltip { + position: relative; + display: inline-block; + + a { + svg { + fill: currentColor; + } + } + + &.tooltip-light { + .tooltip-content { + background-color: $tooltip-light-bg; + color: $tooltip-light-color; + border: 1px solid $tooltip-light-border-color; + + &:before { + background-color: $tooltip-light-bg; + border-bottom: 1px solid $tooltip-light-border-color; + border-right: 1px solid $tooltip-light-border-color; + } + + &:after { + background-color: $tooltip-light-bg; + } + } + } + + &.tooltip-bottom { + .tooltip-container { + top: 100%; + bottom: auto; + left: 50%; + transform: translateX(-50%); + margin: $tooltip-margin 0 0 0; + + .tooltip-content { + &:before { + bottom: auto; + top: $tooltip-arrow-pull; + border-top: 1px solid $tooltip-border-color; + border-right: none; + border-bottom: none; + border-left: 1px solid $tooltip-border-color; + } + + &:after { + bottom: auto; + top: $tooltip-arrow-pull; + } + } + } + + &.tooltip-light { + .tooltip-content:before { + border-top: 1px solid $tooltip-light-border-color; + border-left: 1px solid $tooltip-light-border-color; + } + } + } + + &.tooltip-right { + .tooltip-container { + top: 50%; + bottom: auto; + left: 100%; + transform: translatey(-50%); + margin: 0 0 0 $tooltip-margin; + + .tooltip-content { + &:before { + bottom: auto; + left: $tooltip-arrow-pull; + top: 50%; + transform: translatey(-50%) rotateZ(45deg); + border-top: none; + border-right: none; + border-bottom: 1px solid $tooltip-border-color; + border-left: 1px solid $tooltip-border-color; + } + + &:after { + bottom: auto; + left: $tooltip-arrow-pull; + top: 50%; + transform: translatey(-50%) rotateZ(45deg); + } + } + } + + &.tooltip-light { + .tooltip-content:before { + border-bottom: 1px solid $tooltip-light-border-color; + border-left: 1px solid $tooltip-light-border-color; + } + } + } + &.tooltip-left { + .tooltip-container { + top: 50%; + bottom: auto; + right: 100%; + left: auto; + transform: translatey(-50%); + margin: 0 $tooltip-margin 0 0; + + .tooltip-content { + &:before { + bottom: auto; + right: $tooltip-arrow-pull; + left: auto; + top: 50%; + transform: translatey(-50%) rotateZ(45deg); + border-top: 1px solid $tooltip-border-color; + border-right: 1px solid $tooltip-border-color; + border-bottom: none; + border-left: none; + } + + &:after { + bottom: auto; + right: $tooltip-arrow-pull; + left: auto; + top: 50%; + transform: translatey(-50%) rotateZ(45deg); + } + } + } + + &.tooltip-light { + .tooltip-content:before { + border-top: 1px solid $tooltip-light-border-color; + border-right: 1px solid $tooltip-light-border-color; + } + } + } +} + +//Tooltip Sizes +.tooltip-s.tooltip-container { + width: $tooltip-sm-width; + .tooltip-content { + white-space: normal; + } +} + +.tooltip-m.tooltip-container { + width: $tooltip-md-width; + .tooltip-content { + white-space: normal; + } +} + +.tooltip-l.tooltip-container { + width: $tooltip-lg-width; + .tooltip-content { + white-space: normal; + } +} + +//Overlay +.tether-element { + z-index: 99; +} + +.overlay-trigger { + color: $link-color; + @include transition-pui(); + + &:hover, &:focus { + color: lighten($link-color, 6%); + cursor: pointer; + outline:none; + text-decoration: none; + } + + &:active, &.active{ + color: darken($link-color, 6%); + } +} diff --git a/src/components/tooltip/index.js b/src/components/tooltip/index.js new file mode 100644 index 00000000000..215305339ab --- /dev/null +++ b/src/components/tooltip/index.js @@ -0,0 +1,2 @@ +export { Tooltip } from './tooltip'; +export { TooltipTrigger } from './tooltip_trigger'; diff --git a/src/components/tooltip/tooltip.js b/src/components/tooltip/tooltip.js new file mode 100644 index 00000000000..a66fd7a9603 --- /dev/null +++ b/src/components/tooltip/tooltip.js @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { SIZE } from './tooltip_constants'; + +export class Tooltip extends React.PureComponent { + static propTypes = { + isVisible: PropTypes.bool, + size: PropTypes.oneOf([SIZE.AUTO, SIZE.SMALL, SIZE.MEDIUM, SIZE.LARGE]), + isSticky: PropTypes.bool, + title: PropTypes.string + }; + + static defaultProps = { + isVisible: true, + size: SIZE.AUTO, + isSticky: false + }; + + render() { + const { + isSticky, + isVisible, + size, + title, + className, + children, + ...others + } = this.props; + + const newClasses = classnames('tooltip-container', { + 'tooltip-container-visible': isVisible, + 'tooltip-container-hidden': !isVisible, + 'tooltip-hoverable': isSticky, + [`tooltip-${size}`]: size !== 'auto' + }, className); + + let tooltipTitle; + if (title) { + tooltipTitle = ( +

{title}
+ ); + } + + return ( +
+
+ {tooltipTitle} + {children} +
+
+ ); + } +} diff --git a/src/components/tooltip/tooltip_constants.js b/src/components/tooltip/tooltip_constants.js new file mode 100644 index 00000000000..6b9e7b5861f --- /dev/null +++ b/src/components/tooltip/tooltip_constants.js @@ -0,0 +1,6 @@ +export const SIZE = { + SMALL: 's', + MEDIUM: 'm', + LARGE: 'l', + AUTO: 'auto', +}; diff --git a/src/components/tooltip/tooltip_trigger.js b/src/components/tooltip/tooltip_trigger.js new file mode 100644 index 00000000000..5a8275c4557 --- /dev/null +++ b/src/components/tooltip/tooltip_trigger.js @@ -0,0 +1,148 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { Tooltip } from './tooltip'; +import { SIZE } from './tooltip_constants'; +import { noOverflowPlacement } from '../../services'; + +export class TooltipTrigger extends React.Component { + static propTypes = { + display: PropTypes.bool, + title: PropTypes.string, + tooltip: PropTypes.oneOfType([PropTypes.node, PropTypes.object]).isRequired, + placement: PropTypes.oneOf(['left', 'right', 'bottom', 'top']), + trigger: PropTypes.oneOf(['manual', 'hover', 'click']), + clickHideDelay: PropTypes.number, + onClick: PropTypes.func, + onEntered: PropTypes.func, + onExited: PropTypes.func, + theme: PropTypes.oneOf(['dark', 'light']), + size: PropTypes.oneOf([SIZE.AUTO, SIZE.SMALL, SIZE.MEDIUM, SIZE.LARGE]), + isSticky: PropTypes.bool + }; + + static defaultProps = { + display: false, + placement: 'top', + trigger: 'hover', + clickHideDelay: 1000, + onClick: () => {}, + onEntered: () => {}, + onExited: () => {}, + theme: 'dark', + size: SIZE.AUTO, + isSticky: false + }; + + constructor(props) { + super(props); + const openOnLoad = props.trigger === 'manual' ? props.display : false; + this.state = { + isVisible: openOnLoad, + noOverflowPlacement: props.placement + }; + this.clickHandler = this.clickHandler.bind(this); + } + + getPlacement() { + const domNode = ReactDOM.findDOMNode(this); + const tooltipContainer = domNode.getElementsByClassName('tooltip-container')[0]; + const userPlacement = this.props.placement; + const WINDOW_BUFFER = 8; + return noOverflowPlacement(domNode, tooltipContainer, userPlacement, WINDOW_BUFFER); + } + + hoverHandler(e) { + this.setState({ + isVisible: e.type === 'mouseenter', + noOverflowPlacement: this.getPlacement() + }); + } + + clickHandler(e, onClick) { + this.setState({ + isVisible: true, + noOverflowPlacement: this.getPlacement() + }); + onClick(e); + setTimeout(() => { + this.setState({ isVisible: false }); + }, this.props.clickHideDelay); + } + + componentWillReceiveProps(nextProps) { + const triggerChanged = this.props.trigger !== nextProps.trigger; + const displayChanged = this.props.display !== nextProps.display; + + if (triggerChanged && nextProps.trigger === 'manual') { + this.setState({ isVisible: nextProps.display }); + } else if (triggerChanged) { + this.setState({ isVisible: false }); + } else if (displayChanged) { + this.setState({ isVisible: nextProps.display }); + } + } + + componentDidUpdate(prevProps, prevState) { + if(prevState.isVisible && !this.state.isVisible) { + this.props.onExited(); + } else if(!prevState.isVisible && this.state.isVisible) { + this.props.onEntered(); + } + } + + getTriggerHandler(trigger, onClick) { + switch(trigger) { + case 'click': + return { onClick: e => this.clickHandler(e, onClick) }; + case 'manual': + return {}; + default: + return { + onClick, + onMouseEnter: this.hoverHandler.bind(this), + onMouseLeave: this.hoverHandler.bind(this) + }; + } + } + + render() { + const { + isSticky, + title, + tooltip, + trigger, + className, + clickHideDelay, // eslint-disable-line no-unused-vars + onEntered, // eslint-disable-line no-unused-vars + onExited, // eslint-disable-line no-unused-vars + theme, + size, + onClick, + display, // eslint-disable-line no-unused-vars + ...rest + } = this.props; + const { isVisible } = this.state; + + const triggerHandler = this.getTriggerHandler(trigger, onClick); + + const newClasses = classnames('tooltip', className, { + 'tooltip-light': theme === 'light', + [`tooltip-${this.state.noOverflowPlacement}`]: this.state.noOverflowPlacement !== 'top' + }); + const newProps = { + className: newClasses, + ...triggerHandler, + ...rest + }; + const tooltipProps = { isSticky, size, isVisible, title }; + + return ( +
+ {this.props.children} + {tooltip} +
+ ); + } +} diff --git a/src/services/index.js b/src/services/index.js index 6d339006ccf..a77970a9a65 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -21,3 +21,7 @@ export { export { SortableProperties, } from './sort'; + +export { + noOverflowPlacement, +} from './overflow'; diff --git a/src/services/overflow/index.js b/src/services/overflow/index.js new file mode 100644 index 00000000000..9fabb253d76 --- /dev/null +++ b/src/services/overflow/index.js @@ -0,0 +1 @@ +export { noOverflowPlacement } from './no_overflow_placement'; diff --git a/src/services/overflow/no_overflow_placement.js b/src/services/overflow/no_overflow_placement.js new file mode 100644 index 00000000000..5e17d68da4b --- /dev/null +++ b/src/services/overflow/no_overflow_placement.js @@ -0,0 +1,62 @@ + +/** + * Determine the best placement for a popup that avoids clipping by the window view port. + * + * @param {native DOM Element} staticNode - DOM node that popup placement is referenced too. + * @param {native DOM Element} popupNode - DOM node containing popup content. + * @param {string} requestedPlacement - Preferred placement. One of ["top", "right", "bottom", "left"] + * @param {number} buffer - avoid popup from getting too close to window edge + * + * @returns {string} One of ["top", "right", "bottom", "left"] that ensures no window overflow. + */ +export function noOverflowPlacement(staticNode, popupNode, requestedPlacement, buffer = 0) { + const staticNodeRect = staticNode.getBoundingClientRect(); + const popupNodeRect = popupNode.getBoundingClientRect(); + + // determine popup overflow in each direction + // negative values signal window overflow, large values signal lots of free space + const popupOverflow = { + top: staticNodeRect.top - (popupNodeRect.height + buffer), + right: window.innerWidth - (staticNodeRect.right + popupNodeRect.width + buffer), + bottom: window.innerHeight - (staticNodeRect.bottom + popupNodeRect.height + buffer), + left: staticNodeRect.left - (staticNodeRect.width + buffer) + }; + + function hasCrossDimensionOverflow(key) { + if (key === 'left' || key === 'right') { + const domNodeCenterY = staticNodeRect.top + (staticNodeRect.height / 2); + const tooltipTop = domNodeCenterY - ((popupNodeRect.height / 2) + buffer); + if (tooltipTop <= 0) { + return true; + } + const tooltipBottom = domNodeCenterY + (popupNodeRect.height / 2) + buffer; + if (tooltipBottom >= window.innerHeight) { + return true; + } + } else { + const domNodeCenterX = staticNodeRect.left + (staticNodeRect.width / 2); + const tooltipLeft = domNodeCenterX - ((popupNodeRect.width / 2) + buffer); + if (tooltipLeft <= 0) { + return true; + } + const tooltipRight = domNodeCenterX + (popupNodeRect.width / 2) + buffer; + if (tooltipRight >= window.innerWidth) { + return true; + } + } + return false; + } + + let noOverflowPlacement = requestedPlacement; + if (popupOverflow[requestedPlacement] <= 0 || hasCrossDimensionOverflow(requestedPlacement)) { + // requested placement overflows window bounds + // select direction what has the most free space + Object.keys(popupOverflow).forEach((key) => { + if (popupOverflow[key] > popupOverflow[noOverflowPlacement] && !hasCrossDimensionOverflow(key)) { + noOverflowPlacement = key; + } + }); + } + + return noOverflowPlacement; +}