diff --git a/CHANGELOG.md b/CHANGELOG.md index ba1b869048d..49986e0f8cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ - Added `initialPageSize` option to `EuiInMemoryTable` ([#477](https://github.com/elastic/eui/pull/477)) - Added design guidelines for button and toast usage ([#371](https://github.com/elastic/eui/pull/371)) +**Breaking changes** + +- Complete refactor of `EuiToolTip`. They now work. Only a breaking change if you were using them. ([#484](https://github.com/elastic/eui/pull/484)) + # [`0.0.24`](https://github.com/elastic/eui/tree/v0.0.24) - Removed hover and focus states from non-selectable `EuiSideNavItem`s ([#434](https://github.com/elastic/eui/pull/434)) diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index 62837d86aa4..74d2cce28dd 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -143,6 +143,9 @@ import { PanelExample } import { PopoverExample } from './views/popover/popover_example'; +import { PortalExample } + from './views/portal/portal_example'; + import { ProgressExample } from './views/progress/progress_example'; @@ -176,8 +179,8 @@ import { TitleExample } import { ToastExample } from './views/toast/toast_example'; -import { TooltipExample } - from './views/tooltip/tooltip_example'; +import { ToolTipExample } + from './views/tool_tip/tool_tip_example'; /** * Lowercases input and replaces spaces with hyphens: @@ -283,7 +286,7 @@ const navigation = [{ TextExample, TitleExample, ToastExample, - TooltipExample, + ToolTipExample, ].map(example => createExample(example)), }, { name: 'Forms', @@ -304,6 +307,7 @@ const navigation = [{ ErrorBoundaryExample, IsColorDarkExample, OutsideClickDetectorExample, + PortalExample, ].map(example => createExample(example)), }].map(({ name, items, ...rest }) => ({ name, diff --git a/src-docs/src/views/portal/portal.js b/src-docs/src/views/portal/portal.js new file mode 100644 index 00000000000..0889c98d6b2 --- /dev/null +++ b/src-docs/src/views/portal/portal.js @@ -0,0 +1,49 @@ +import React, { + Component, +} from 'react'; + +import { + EuiPortal, + EuiButton, + EuiBottomBar, +} from '../../../../src/components'; + +export class Portal extends Component { + constructor(props) { + super(props); + + this.state = { + isPortalVisible: false, + }; + + this.togglePortal = this.togglePortal.bind(this); + } + + togglePortal() { + this.setState(prevState => ({ isPortalVisible: !prevState.isPortalVisible })); + } + + render() { + + let portal; + + if (this.state.isPortalVisible) { + portal = ( + + +

This element is appended to the body in the DOM if you inspect

+
+
+ ); + } + return ( +
+ + Toggle portal + + + {portal} +
+ ); + } +} diff --git a/src-docs/src/views/portal/portal_example.js b/src-docs/src/views/portal/portal_example.js new file mode 100644 index 00000000000..d56ce50d40e --- /dev/null +++ b/src-docs/src/views/portal/portal_example.js @@ -0,0 +1,39 @@ +import React from 'react'; + +import { renderToHtml } from '../../services'; + +import { + GuideSectionTypes, +} from '../../components'; + +import { + EuiCode, + EuiPortal, +} from '../../../../src/components'; + +import { Portal } from './portal'; +const portalSource = require('!!raw-loader!./portal'); +const portalHtml = renderToHtml(Portal); + +export const PortalExample = { + title: 'Portal', + sections: [{ + title: 'Portal', + source: [{ + type: GuideSectionTypes.JS, + code: portalSource, + }, { + type: GuideSectionTypes.HTML, + code: portalHtml, + }], + text: ( +

+ EuiPortal allows you to append its contained children + onto the document body. It is useful for moving fixed elements like modals, + tooltips or toasts when you are worried about a z-index or overflow conflict. +

+ ), + components: { EuiPortal }, + demo: , + }], +}; diff --git a/src-docs/src/views/tool_tip/tool_tip.js b/src-docs/src/views/tool_tip/tool_tip.js new file mode 100644 index 00000000000..ea7d5ca0fc7 --- /dev/null +++ b/src-docs/src/views/tool_tip/tool_tip.js @@ -0,0 +1,62 @@ +import React from 'react'; + +import { + EuiIcon, + EuiToolTip, + EuiLink, + EuiText, + EuiFieldText, + EuiSpacer, + EuiButton, +} from '../../../../src/components'; + +export default () => ( +
+ +

+ This tooltip appears on the{' '} + + top + +

+ +

+ This tooltip appears on the{' '} + + left + + {' '} and includes the optional title. +

+ +

+ This tooltip appears on the{' '} + + right + +

+ +

+ This tooltip appears on the bottom of this icon:{' '} + + + +

+
+ + + + + + + + + + + alert('Buttons are still clickable within tooltips.')}>Hover over me + +
+); diff --git a/src-docs/src/views/tool_tip/tool_tip_example.js b/src-docs/src/views/tool_tip/tool_tip_example.js new file mode 100644 index 00000000000..ba6aa79ffd6 --- /dev/null +++ b/src-docs/src/views/tool_tip/tool_tip_example.js @@ -0,0 +1,41 @@ +import React from 'react'; + +import { renderToHtml } from '../../services'; + +import { + GuideSectionTypes, +} from '../../components'; + +import { + EuiCode, + EuiToolTip, +} from '../../../../src/components'; + +import ToolTip from './tool_tip'; +const toolTipSource = require('!!raw-loader!./tool_tip'); +const toolTipHtml = renderToHtml(ToolTip); + +export const ToolTipExample = { + title: 'ToolTip', + sections: [{ + title: 'ToolTip', + source: [{ + type: GuideSectionTypes.JS, + code: toolTipSource, + }, { + type: GuideSectionTypes.HTML, + code: toolTipHtml, + }], + text: ( +

+ Wrap EuiToolTip around any item that you need a tooltip for. + The position prop will take a suggested position, but will + change it if the tool tip gets too close to the edge of the screen. You can use + the clickOnly prop to tell the too tip to only appear on click + wrather than on hover. +

+ ), + props: { EuiToolTip }, + demo: , + }], +}; diff --git a/src-docs/src/views/tooltip/examples.js b/src-docs/src/views/tooltip/examples.js deleted file mode 100644 index 21efbbe69ca..00000000000 --- a/src-docs/src/views/tooltip/examples.js +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; - -import { - EuiLink, - 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 {( - - medium tooltip - - )} with title. -
-
-
-
- Check out this {( - - tooltip on the top. - - )} -
-
-
-
- Check out this {( - - tooltip on click. - - )} -
-
-
-
- Check out this {( - - large 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 deleted file mode 100644 index 11158eec1dd..00000000000 --- a/src-docs/src/views/tooltip/tooltip_example.js +++ /dev/null @@ -1,51 +0,0 @@ -import React from 'react'; - -import { renderToHtml } from '../../services'; - -import { - GuideSectionTypes, -} from '../../components'; - -import { - EuiCallOut, - EuiSpacer, - TooltipTrigger, -} 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: ( -

- ), - props: { TooltipTrigger }, - demo: , - }], -}; diff --git a/src/components/index.js b/src/components/index.js index b99f97ab8a3..6d33e33c80c 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -204,6 +204,10 @@ export { EuiPopoverTitle, } from './popover'; +export { + EuiPortal, +} from './portal'; + export { EuiProgress, } from './progress'; @@ -260,9 +264,8 @@ export { } from './toast'; export { - Tooltip, - TooltipTrigger -} from './tooltip'; + EuiToolTip, +} from './tool_tip'; export { EuiTitle, diff --git a/src/components/index.scss b/src/components/index.scss index d03ad7b27ad..305a8a731f0 100644 --- a/src/components/index.scss +++ b/src/components/index.scss @@ -44,5 +44,5 @@ @import 'tabs/index'; @import 'title/index'; @import 'toast/index'; -@import 'tooltip/index'; +@import 'tool_tip/index'; @import 'text/index'; diff --git a/src/components/portal/__snapshots__/portal.test.js.snap b/src/components/portal/__snapshots__/portal.test.js.snap new file mode 100644 index 00000000000..092d405cb69 --- /dev/null +++ b/src/components/portal/__snapshots__/portal.test.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiPortal is rendered 1`] = ` +

+ Content +
+`; diff --git a/src/components/portal/index.js b/src/components/portal/index.js new file mode 100644 index 00000000000..f55db5dad03 --- /dev/null +++ b/src/components/portal/index.js @@ -0,0 +1,3 @@ +export { + EuiPortal, +} from './portal'; diff --git a/src/components/portal/portal.js b/src/components/portal/portal.js new file mode 100644 index 00000000000..3e44ece314d --- /dev/null +++ b/src/components/portal/portal.js @@ -0,0 +1,40 @@ +/** + * NOTE: We can't test this component because Enzyme doesn't support rendering + * into portals. + */ + +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import { createPortal } from 'react-dom'; + +export class EuiPortal extends Component { + constructor(props) { + super(props); + + const { + children, // eslint-disable-line no-unused-vars + } = this.props; + + this.portalNode = document.createElement('div'); + } + + componentDidMount() { + document.body.appendChild(this.portalNode); + } + + componentWillUnmount() { + document.body.removeChild(this.portalNode); + this.portalNode = null; + } + + render() { + return createPortal( + this.props.children, + this.portalNode + ); + } +} + +EuiPortal.propTypes = { + children: PropTypes.node, +}; diff --git a/src/components/portal/portal.test.js b/src/components/portal/portal.test.js new file mode 100644 index 00000000000..d89df757e0a --- /dev/null +++ b/src/components/portal/portal.test.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { render } from 'enzyme'; +import ReactDOM from 'react-dom'; +import { requiredProps } from '../../test'; +import { EuiPortal } from './portal'; + +// TODO: Temporary hack which we can remove once react-test-renderer supports portals. +// More info at https://github.com/facebook/react/issues/11565. +ReactDOM.createPortal = node => node; + +describe('EuiPortal', () => { + test('is rendered', () => { + const component = render( +
+ + Content + +
+ ); + + expect(component) + .toMatchSnapshot(); + }); +}); diff --git a/src/components/tool_tip/__snapshots__/tool_tip.test.js.snap b/src/components/tool_tip/__snapshots__/tool_tip.test.js.snap new file mode 100644 index 00000000000..389c48a626a --- /dev/null +++ b/src/components/tool_tip/__snapshots__/tool_tip.test.js.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiToolTip is rendered 1`] = ` + + + +`; diff --git a/src/components/tool_tip/__snapshots__/tool_tip_popover.test.js.snap b/src/components/tool_tip/__snapshots__/tool_tip_popover.test.js.snap new file mode 100644 index 00000000000..5016391cd67 --- /dev/null +++ b/src/components/tool_tip/__snapshots__/tool_tip_popover.test.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiToolTipPopover is rendered 1`] = ` +
+`; diff --git a/src/components/tool_tip/_index.scss b/src/components/tool_tip/_index.scss new file mode 100644 index 00000000000..3e998fcbf09 --- /dev/null +++ b/src/components/tool_tip/_index.scss @@ -0,0 +1 @@ +@import 'tool_tip'; diff --git a/src/components/tool_tip/_tool_tip.scss b/src/components/tool_tip/_tool_tip.scss new file mode 100644 index 00000000000..99037652415 --- /dev/null +++ b/src/components/tool_tip/_tool_tip.scss @@ -0,0 +1,117 @@ +/** + * 1. Relative / absolute positioning so they still work during scrolling. + */ +.euiBody-hasToolTip { + position: relative; /* 1 */ +} + +.euiToolTip { + @include euiBottomShadow; + @include euiFontSizeS(); + + position: absolute; /* 1 */ + border-radius: $euiBorderRadius; + padding: $euiSizeM; + background-color: tintOrShade($euiColorFullShade, 25%, 90%); + color: $euiColorGhost; + max-width: 256px; + opacity: 0; + animation: euiToolTipTop $euiAnimSpeedSlow ease-out $euiAnimSpeedNormal forwards; + z-index: $euiZLevel9; + + &::before { + content: ""; + position: absolute; + bottom: -$euiSize/2; + left: 50%; + transform: translateX(-50%) rotateZ(45deg); + transform-origin: center; + background-color: tintOrShade($euiColorFullShade, 25%, 90%); + width: $euiSize; + height: $euiSize; + } + + // Positions the arrow and animates in from the same side. + &.euiToolTip--right { + animation-name: euiToolTipRight; + + &:before { + bottom: 50%; + left: -$euiSize/2; + transform: translateY(50%) rotateZ(45deg); + } + } + + &.euiToolTip--bottom { + animation-name: euiToolTipBottom; + + &:before { + bottom: auto; + top: -$euiSize/2; + } + } + + &.euiToolTip--left { + animation-name: euiToolTipLeft; + + &:before { + bottom: 50%; + left: auto; + right: -$euiSize/2; + transform: translateY(50%) rotateZ(45deg); + } + } + + .euiToolTip__title { + font-weight: $euiFontWeightBold; + border-bottom: solid 1px tintOrShade($euiColorFullShade, 35%, 80%); + padding-bottom: $euiSizeXS; + margin-bottom: $euiSizeXS; + } +} + + +// Keyframes to animate in the tooltip. +@keyframes euiToolTipTop { + 0% { + opacity: 0; + transform: translateY(-$euiSize); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes euiToolTipBottom { + 0% { + opacity: 0; + transform: translateY($euiSize); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes euiToolTipLeft { + 0% { + opacity: 0; + transform: translateX(-$euiSize); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes euiToolTipRight { + 0% { + opacity: 0; + transform: translateX($euiSize); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/components/tool_tip/index.js b/src/components/tool_tip/index.js new file mode 100644 index 00000000000..cadac9c8b64 --- /dev/null +++ b/src/components/tool_tip/index.js @@ -0,0 +1,3 @@ +export { + EuiToolTip, +} from './tool_tip'; diff --git a/src/components/tool_tip/tool_tip.js b/src/components/tool_tip/tool_tip.js new file mode 100644 index 00000000000..b56bc3da12a --- /dev/null +++ b/src/components/tool_tip/tool_tip.js @@ -0,0 +1,167 @@ +import React, { + Component, + cloneElement, + Fragment, +} from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { EuiPortal } from '../portal'; +import { EuiToolTipPopover } from './tool_tip_popover'; +import { calculatePopoverPosition, calculatePopoverStyles } from '../../services'; + +import makeId from '../form/form_row/make_id'; + +const positionsToClassNameMap = { + top: 'euiToolTip--top', + right: 'euiToolTip--right', + bottom: 'euiToolTip--bottom', + left: 'euiToolTip--left', +}; + +export const POSITIONS = Object.keys(positionsToClassNameMap); + +export class EuiToolTip extends Component { + constructor(props) { + super(props); + + this.state = { + visible: false, + hasFocus: false, + calculatedPosition: this.props.position, + toolTipStyles: {}, + id: this.props.id || makeId(), + }; + } + + showToolTip = () => { + this.setState({ visible: true }); + }; + + positionToolTip = (toolTipRect) => { + const wrapperRect = this.wrapper.getBoundingClientRect(); + const userPosition = this.props.position; + + const calculatedPosition = calculatePopoverPosition(wrapperRect, toolTipRect, userPosition); + const toolTipStyles = calculatePopoverStyles(wrapperRect, toolTipRect, calculatedPosition); + + this.setState({ + visible: true, + calculatedPosition, + toolTipStyles, + }); + }; + + hideToolTip = () => { + this.setState({ visible: false }); + }; + + onFocus = () => { + this.setState({ + hasFocus: true, + }); + this.showToolTip(); + }; + + onBlur = () => { + this.setState({ + hasFocus: false, + }); + this.hideToolTip(); + }; + + onMouseOut = () => { + if (!this.state.hasFocus) { + this.hideToolTip(); + } + }; + + render() { + const { + children, + className, + content, + title, + ...rest + } = this.props; + + const classes = classNames( + 'euiToolTip', + positionsToClassNameMap[this.state.calculatedPosition], + className + ); + + let tooltip; + if (this.state.visible) { + tooltip = ( + + + {content} + + + ); + } + + const trigger = ( + this.wrapper = wrapper}> + {cloneElement(children, { + onFocus: this.showToolTip, + onBlur: this.hideToolTip, + 'aria-describedby': this.state.id, + onMouseOver: this.showToolTip, + onMouseOut: this.onMouseOut + })} + + ); + + return ( + + {trigger} + {tooltip} + + ); + } +} + +EuiToolTip.propTypes = { + /** + * The in-view trigger for your tooltip. + */ + children: PropTypes.element.isRequired, + /** + * The main content of your tooltip. + */ + content: PropTypes.node.isRequired, + + /** + * An optional title for your tooltip. + */ + title: PropTypes.node, + + /** + * Suggested position. If there is not enough room for it this will be changed. + */ + position: PropTypes.oneOf(POSITIONS), + + /** + * Passes onto the tooltip itself, not the trigger. + */ + className: PropTypes.string, + + /** + * Unless you provide one, this will be randomly generated. + */ + id: PropTypes.string, +}; + +EuiToolTip.defaultProps = { + position: 'top', +}; diff --git a/src/components/tool_tip/tool_tip.test.js b/src/components/tool_tip/tool_tip.test.js new file mode 100644 index 00000000000..ecf2908c50c --- /dev/null +++ b/src/components/tool_tip/tool_tip.test.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { render } from 'enzyme'; +import { requiredProps } from '../../test'; + +import { EuiToolTip } from './tool_tip'; + +describe('EuiToolTip', () => { + test('is rendered', () => { + const component = render( + + + + ); + + expect(component) + .toMatchSnapshot(); + }); +}); diff --git a/src/components/tool_tip/tool_tip_popover.js b/src/components/tool_tip/tool_tip_popover.js new file mode 100644 index 00000000000..41063bff46b --- /dev/null +++ b/src/components/tool_tip/tool_tip_popover.js @@ -0,0 +1,74 @@ +import React, { + Component, +} from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export class EuiToolTipPopover extends Component { + static propTypes = { + children: PropTypes.node, + className: PropTypes.string, + title: PropTypes.node, + positionToolTip: PropTypes.func.isRequired, + } + + constructor(props) { + super(props); + + this.updateDimensions = this.updateDimensions.bind(this); + } + + componentDidMount() { + document.body.classList.add('euiBody-hasToolTip'); + + this.updateDimensions(); + window.addEventListener('resize', this.updateDimensions); + } + + updateDimensions() { + requestAnimationFrame(() => { + // Because of this delay, sometimes `positionToolTip` becomes unavailable. + if (this.popover) { + this.props.positionToolTip(this.popover.getBoundingClientRect()); + } + }); + } + + componentWillUnmount() { + document.body.classList.remove('euiBody-hasToolTip'); + window.removeEventListener('resize', this.updateDimensions); + } + + render() { + const { + children, + title, + className, + positionToolTip, // eslint-disable-line no-unused-vars + ...rest + } = this.props; + + const classes = classNames( + 'euiToolTipPopover', + className + ); + + let optionalTitle; + if (title) { + optionalTitle = ( +
{title}
+ ); + } + + return ( +
this.popover = popover} + {...rest} + > + {optionalTitle} + {children} +
+ ); + } +} diff --git a/src/components/tool_tip/tool_tip_popover.test.js b/src/components/tool_tip/tool_tip_popover.test.js new file mode 100644 index 00000000000..44eb63ef5d5 --- /dev/null +++ b/src/components/tool_tip/tool_tip_popover.test.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { render } from 'enzyme'; +import { requiredProps } from '../../test'; + +import { EuiToolTipPopover } from './tool_tip_popover'; + +describe('EuiToolTipPopover', () => { + test('is rendered', () => { + const component = render( + {}} {...requiredProps} /> + ); + + expect(component) + .toMatchSnapshot(); + }); +}); diff --git a/src/components/tooltip/_index.scss b/src/components/tooltip/_index.scss deleted file mode 100644 index 91752d2fa28..00000000000 --- a/src/components/tooltip/_index.scss +++ /dev/null @@ -1,2 +0,0 @@ -@import "mixins"; -@import "tooltip"; diff --git a/src/components/tooltip/_mixins.scss b/src/components/tooltip/_mixins.scss deleted file mode 100644 index 4fd5d809a92..00000000000 --- a/src/components/tooltip/_mixins.scss +++ /dev/null @@ -1,7 +0,0 @@ -@mixin euiTooltipSize($multiplier){ - max-width: $euiSize * $multiplier; - - .euiTooltip__content { - white-space: inherit; // allow text wrapping of content - } -} \ No newline at end of file diff --git a/src/components/tooltip/_tooltip.scss b/src/components/tooltip/_tooltip.scss deleted file mode 100644 index a82ef408b0a..00000000000 --- a/src/components/tooltip/_tooltip.scss +++ /dev/null @@ -1,120 +0,0 @@ -// Outer most element (contains both the triggering element and tooltip itself) -.euiTooltip { - display: inline-block; -} - -// The tooltip itself (for positioning, sizing, animating) -.euiTooltip__container { - position: absolute; // TODO: Change to fixed position? - - // SIZING - &.euiTooltip--small { - @include euiTooltipSize(10); - } - - &.euiTooltip--medium { - @include euiTooltipSize(15); - } - - &.euiTooltip--large { - @include euiTooltipSize(30); - } - - // ANIMATING -- with slight delay - transition: - opacity $euiAnimSpeedNormal ease-out $euiAnimSpeedSlow, - visibility $euiAnimSpeedNormal ease-out $euiAnimSpeedSlow, - transform $euiAnimSpeedNormal ease-out $euiAnimSpeedSlow; - opacity: 0; - visibility: hidden; - transform: translateX(0) translateY($euiSize * -1) translateY(0); // default starting position of top - - // Don't delay the animation if the tooltip is on click - .euiTooltip--click & { - transition: - opacity $euiAnimSpeedNormal ease-out, - visibility $euiAnimSpeedNormal ease-out, - transform $euiAnimSpeedNormal ease-out; - } - - &.euiTooltip-isVisible { - opacity: 1; - visibility: visible; - transform: translateX(0) translateY(0) translateZ(0) !important; - // Using important here so we're always at (0,0,0) when visible, no matter the position. - // May need to revisit if we find override issues. - } - - // Starting positions - .euiTooltip--right & { - transform: translateX($euiSize) translateY(0) translateZ(0); - } - - .euiTooltip--bottom & { - transform: translateX(0) translateY($euiSize) translateZ(0); - } - - .euiTooltip--left & { - transform: translateX($euiSize * -1) translateY(0) translateZ(0); - } -} - - // The tooltip content (for styling) - - .euiTooltip__content { - - // Scoped variables for component-only re-use - $background-color: $euiColorDarkestShade; - $text-color: $euiColorEmptyShade; - $arrow-size: $euiSize; - - // STYLING - @include euiBottomShadow; - @include euiFontSizeS(); - background-color: $background-color; - border-radius: $euiBorderRadius; - padding: $euiSizeM; - color: $text-color; - white-space: nowrap; - - // ARROW - position: relative; - &::before { - content: ""; - position: absolute; - bottom: -$arrow-size/2; - left: 50%; - transform: translateX(-50%) rotateZ(45deg); - transform-origin: center; - background-color: $background-color; - width: $arrow-size; - height: $arrow-size; - - // Positions - .euiTooltip--right & { - bottom: 50%; - left: -$arrow-size/2; - transform: translateY(50%) rotateZ(45deg); - } - - .euiTooltip--bottom & { - bottom: auto; - top: -$arrow-size/2; - } - - .euiTooltip--left & { - bottom: 50%; - left: auto; - right: -$arrow-size/2; - transform: translateY(50%) rotateZ(45deg); - } - } - } - - // The tooltip title if it exists - .euiTooltip__title { - @include euiFontSize; - font-weight: $euiFontWeightMedium; - margin-bottom: $euiSizeS; - border-bottom: $euiBorderWidthThin solid $euiColorDarkShade; - } diff --git a/src/components/tooltip/index.js b/src/components/tooltip/index.js deleted file mode 100644 index 215305339ab..00000000000 --- a/src/components/tooltip/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { Tooltip } from './tooltip'; -export { TooltipTrigger } from './tooltip_trigger'; diff --git a/src/components/tooltip/tooltip.js b/src/components/tooltip/tooltip.js deleted file mode 100644 index 89f02cae1f1..00000000000 --- a/src/components/tooltip/tooltip.js +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; - -const sizeToClassNameMap = { - auto: null, - s: 'euiTooltip--small', - m: 'euiTooltip--medium', - l: 'euiTooltip--large', -}; - -export const SIZES = Object.keys(sizeToClassNameMap); - -export const Tooltip = ({ - children, - className, - size, - isVisible, - isSticky, - title, - ...rest -}) => { - - const classes = classNames( - 'euiTooltip__container', - sizeToClassNameMap[size], - { - 'euiTooltip-isVisible': isVisible, - 'euiTooltip-isHidden': !isVisible, - 'euiTooltip-isSticky': isSticky, - }, - className - ); - - let tooltipTitle; - if (title) { - tooltipTitle = ( -
{title}
- ); - } - - return ( -
-
- {tooltipTitle} - {children} -
-
- ); - -}; - -Tooltip.propTypes = { - children: PropTypes.node, - className: PropTypes.string, - size: PropTypes.oneOf(SIZES), - isVisible: PropTypes.bool, - isSticky: PropTypes.bool, - title: PropTypes.string -}; - -Tooltip.defaultProps = { - size: 'auto', - isVisible: true, - isSticky: false -}; diff --git a/src/components/tooltip/tooltip_constants.js b/src/components/tooltip/tooltip_constants.js deleted file mode 100644 index 6b9e7b5861f..00000000000 --- a/src/components/tooltip/tooltip_constants.js +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 509e3b3e96c..00000000000 --- a/src/components/tooltip/tooltip_trigger.js +++ /dev/null @@ -1,149 +0,0 @@ -import React, { Component } 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 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('euiTooltip__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('euiTooltip', className, { - 'tooltip-light': theme === 'light', - [`euiTooltip--${this.state.noOverflowPlacement}`]: this.state.noOverflowPlacement !== 'top', - [`euiTooltip--${trigger}`]: trigger !== 'hover', - }); - 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 2226e139dc0..c3c1fea6743 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -59,5 +59,6 @@ export { } from './sort'; export { - noOverflowPlacement, -} from './overflow'; + calculatePopoverPosition, + calculatePopoverStyles, +} from './popover'; diff --git a/src/services/overflow/index.js b/src/services/overflow/index.js deleted file mode 100644 index 9fabb253d76..00000000000 --- a/src/services/overflow/index.js +++ /dev/null @@ -1 +0,0 @@ -export { noOverflowPlacement } from './no_overflow_placement'; diff --git a/src/services/overflow/no_overflow_placement.js b/src/services/overflow/no_overflow_placement.js deleted file mode 100644 index 5e17d68da4b..00000000000 --- a/src/services/overflow/no_overflow_placement.js +++ /dev/null @@ -1,62 +0,0 @@ - -/** - * 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; -} diff --git a/src/services/popover/index.js b/src/services/popover/index.js new file mode 100644 index 00000000000..28cdd4279cc --- /dev/null +++ b/src/services/popover/index.js @@ -0,0 +1,2 @@ +export { calculatePopoverPosition } from './popover_calculate_position'; +export { calculatePopoverStyles } from './popover_calculate_styles'; diff --git a/src/services/popover/popover_calculate_position.js b/src/services/popover/popover_calculate_position.js new file mode 100644 index 00000000000..afa6e9f3ab5 --- /dev/null +++ b/src/services/popover/popover_calculate_position.js @@ -0,0 +1,60 @@ + +/** + * Determine the best position for a popup that avoids clipping by the window view port. + * + * @param {native DOM Element} wrapperRect - getBoundingClientRect() of wrapping node around the popover. + * @param {native DOM Element} popupRect - getBoundingClientRect() of the popup node. + * @param {string} requestedPosition - Position the user wants. One of ["top", "right", "bottom", "left"] + * @param {number} buffer - The space between the wrapper and the popup. Also the minimum space between the popup and the window. + * + * @returns {string} One of ["top", "right", "bottom", "left"] that ensures no window overflow. + */ +export function calculatePopoverPosition(wrapperRect, popupRect, requestedPosition, buffer = 16) { + + // determine popup overflow in each direction + // negative values signal window overflow, large values signal lots of free space + const popupOverflow = { + top: wrapperRect.top - (popupRect.height + (2 * buffer)), + right: window.innerWidth - wrapperRect.right - (popupRect.width + (2 * buffer)), + left: wrapperRect.left - (popupRect.width + (2 * buffer)), + bottom: window.innerHeight - wrapperRect.bottom - (popupRect.height + (2 * buffer)), + }; + + function hasCrossDimensionOverflow(key) { + if (key === 'left' || key === 'right') { + const domNodeCenterY = wrapperRect.top + (wrapperRect.height / 2); + const tooltipTop = domNodeCenterY - ((popupRect.height / 2) + buffer); + if (tooltipTop <= 0) { + return true; + } + const tooltipBottom = domNodeCenterY + (popupRect.height / 2) + buffer; + if (tooltipBottom >= window.innerHeight) { + return true; + } + } else { + const domNodeCenterX = wrapperRect.left + (wrapperRect.width / 2); + const tooltipLeft = domNodeCenterX - ((popupRect.width / 2) + buffer); + if (tooltipLeft <= 0) { + return true; + } + const tooltipRight = domNodeCenterX + (popupRect.width / 2) + buffer; + if (tooltipRight >= window.innerWidth) { + return true; + } + } + return false; + } + + let calculatedPopoverPosition = requestedPosition; + if (popupOverflow[requestedPosition] <= 0 || hasCrossDimensionOverflow(requestedPosition)) { + // requested position overflows window bounds + // select direction what has the most free space + Object.keys(popupOverflow).forEach((key) => { + if (popupOverflow[key] > popupOverflow[calculatedPopoverPosition] && !hasCrossDimensionOverflow(key)) { + calculatedPopoverPosition = key; + } + }); + } + + return calculatedPopoverPosition; +} diff --git a/src/services/popover/popover_calculate_styles.js b/src/services/popover/popover_calculate_styles.js new file mode 100644 index 00000000000..741d3e2c422 --- /dev/null +++ b/src/services/popover/popover_calculate_styles.js @@ -0,0 +1,19 @@ +export function calculatePopoverStyles(wrapperNodeRect, popupNodeRect, position, buffer = 16) { + const styles = {}; + + if (position === 'top') { + styles.top = wrapperNodeRect.top + window.scrollY - (popupNodeRect.height + buffer); + styles.left = wrapperNodeRect.left + (wrapperNodeRect.width / 2) - (popupNodeRect.width / 2); + } else if (position === 'bottom') { + styles.top = wrapperNodeRect.top + window.scrollY + wrapperNodeRect.height + buffer; + styles.left = wrapperNodeRect.left + (wrapperNodeRect.width / 2) - (popupNodeRect.width / 2); + } else if (position === 'right') { + styles.top = wrapperNodeRect.top + window.scrollY - ((popupNodeRect.height - wrapperNodeRect.height) / 2); + styles.left = wrapperNodeRect.left + wrapperNodeRect.width + buffer; + } else if (position === 'left') { + styles.top = wrapperNodeRect.top + window.scrollY - ((popupNodeRect.height - wrapperNodeRect.height) / 2); + styles.left = wrapperNodeRect.left - popupNodeRect.width - buffer; + } + + return styles; +}