diff --git a/CHANGELOG.md b/CHANGELOG.md index b305d21b83e..093aff78ac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Attach `noreferrer` also to links without `target="_blank"` ([#2008](https://github.com/elastic/eui/pull/2008)) - Convert observer utility components to TypeScript ([#2009](https://github.com/elastic/eui/pull/2009)) +- Convert tool tip components to TypeScript ([#2013](https://github.com/elastic/eui/pull/2013)) **Bug fixes** diff --git a/src/components/badge/index.d.ts b/src/components/badge/index.d.ts index bc8ef6ef073..e034e2ffaec 100644 --- a/src/components/badge/index.d.ts +++ b/src/components/badge/index.d.ts @@ -1,5 +1,5 @@ import { IconType } from '../icon'; -/// +import { ToolTipPositions } from '../tool_tip/tool_tip'; import { HTMLAttributes, diff --git a/src/components/index.d.ts b/src/components/index.d.ts index e4ecf1ad4d9..fadcac60c2f 100644 --- a/src/components/index.d.ts +++ b/src/components/index.d.ts @@ -28,7 +28,6 @@ /// /// /// -/// declare module '@elastic/eui' { // @ts-ignore diff --git a/src/components/tool_tip/__snapshots__/icon_tip.test.js.snap b/src/components/tool_tip/__snapshots__/icon_tip.test.tsx.snap similarity index 100% rename from src/components/tool_tip/__snapshots__/icon_tip.test.js.snap rename to src/components/tool_tip/__snapshots__/icon_tip.test.tsx.snap diff --git a/src/components/tool_tip/__snapshots__/tool_tip.test.js.snap b/src/components/tool_tip/__snapshots__/tool_tip.test.tsx.snap similarity index 100% rename from src/components/tool_tip/__snapshots__/tool_tip.test.js.snap rename to src/components/tool_tip/__snapshots__/tool_tip.test.tsx.snap diff --git a/src/components/tool_tip/icon_tip.js b/src/components/tool_tip/icon_tip.js deleted file mode 100644 index df88cdcb035..00000000000 --- a/src/components/tool_tip/icon_tip.js +++ /dev/null @@ -1,57 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import { EuiIcon } from '../icon'; -import { EuiToolTip } from './tool_tip'; - -export const EuiIconTip = ({ - type, - 'aria-label': ariaLabel, - color, - size, - iconProps, - ...rest -}) => ( - - - -); - -EuiIconTip.propTypes = { - /** - * The icon type. - */ - type: PropTypes.string, - - /** - * The icon color. - */ - color: PropTypes.string, - - /** - * The icon size. - */ - size: PropTypes.string, - - /** - * Explain what this icon means for screen readers. - */ - 'aria-label': PropTypes.string, - - /** - * Pass certain props down to `EuiIcon` - */ - iconProps: PropTypes.object, -}; - -EuiIconTip.defaultProps = { - type: 'questionInCircle', - 'aria-label': 'Info', -}; diff --git a/src/components/tool_tip/icon_tip.test.js b/src/components/tool_tip/icon_tip.test.tsx similarity index 100% rename from src/components/tool_tip/icon_tip.test.js rename to src/components/tool_tip/icon_tip.test.tsx diff --git a/src/components/tool_tip/icon_tip.tsx b/src/components/tool_tip/icon_tip.tsx new file mode 100644 index 00000000000..ded7a9d181e --- /dev/null +++ b/src/components/tool_tip/icon_tip.tsx @@ -0,0 +1,62 @@ +import React, { FunctionComponent } from 'react'; + +import { Omit, PropsOf } from '../common'; +import { EuiIcon, IconSize, IconType } from '../icon'; +import { EuiToolTip, Props as EuiToolTipProps } from './tool_tip'; + +export interface EuiIconTipProps { + /** + * The icon color. + */ + color?: string; + /** + * The icon type. + */ + type?: IconType; + /** + * The icon size. + */ + size?: IconSize; + /** + * Explain what this icon means for screen readers. + */ + 'aria-label'?: string; + + /** + * Pass certain props down to `EuiIcon` + */ + // EuiIconTip's `type` is passed to EuiIcon, so we want to exclude `type` from + // iconProps; however, due to TS's bivariant function arguments `type` could be + // passed without any error/feedback so we explicitly set it to `never` type + iconProps?: Omit, 'type'> & { type?: never }; +} + +type Props = Omit & + EuiIconTipProps & { + // This are copied from EuiToolTipProps, but made optional. Defaults + // are applied below. + delay?: EuiToolTipProps['delay']; + position?: EuiToolTipProps['position']; + }; + +export const EuiIconTip: FunctionComponent = ({ + type = 'questionInCircle', + 'aria-label': ariaLabel = 'Info', + color, + size, + iconProps, + position = 'top', + delay = 'regular', + ...rest +}) => ( + + + +); diff --git a/src/components/tool_tip/index.d.ts b/src/components/tool_tip/index.d.ts deleted file mode 100644 index 51773f81a36..00000000000 --- a/src/components/tool_tip/index.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -export { EuiToolTipPopover } from './tool_tip_popover'; -import { ReactElement, ReactNode, FunctionComponent } from 'react'; -import { EuiIcon, IconType } from '../icon'; -import { Omit, PropsOf } from '../common'; - -declare module '@elastic/eui' { - export type ToolTipPositions = 'top' | 'right' | 'bottom' | 'left'; - - export type ToolTipDelay = 'regular' | 'long'; - - export interface EuiToolTipProps { - anchorClassName?: string; - children: ReactElement; - className?: string; - content?: ReactNode; - delay?: ToolTipDelay; - title?: ReactNode; - id?: string; - position?: ToolTipPositions; - } - export const EuiToolTip: FunctionComponent; - - export interface EuiIconTipProps { - color?: string; - type?: IconType; - size?: string; - 'aria-label'?: string; - - // EuiIconTip's `type` is passed to EuiIcon, so we want to exclude `type` from - // iconProps; however, due to TS's bivariant function arguments `type` could be - // passed without any error/feedback so we explicitly set it to `never` type - iconProps?: Omit, 'type'> & { type?: never }; - } - - export const EuiIconTip: FunctionComponent< - Omit & EuiIconTipProps - >; -} diff --git a/src/components/tool_tip/index.js b/src/components/tool_tip/index.ts similarity index 100% rename from src/components/tool_tip/index.js rename to src/components/tool_tip/index.ts diff --git a/src/components/tool_tip/tool_tip.test.js b/src/components/tool_tip/tool_tip.test.tsx similarity index 100% rename from src/components/tool_tip/tool_tip.test.js rename to src/components/tool_tip/tool_tip.test.tsx diff --git a/src/components/tool_tip/tool_tip.js b/src/components/tool_tip/tool_tip.tsx similarity index 74% rename from src/components/tool_tip/tool_tip.js rename to src/components/tool_tip/tool_tip.tsx index 9be8d74b7fc..e65f4775508 100644 --- a/src/components/tool_tip/tool_tip.js +++ b/src/components/tool_tip/tool_tip.tsx @@ -1,7 +1,14 @@ -import React, { Component, cloneElement, Fragment } from 'react'; -import PropTypes from 'prop-types'; +import React, { + Component, + cloneElement, + Fragment, + ReactElement, + ReactNode, + MouseEvent as ReactMouseEvent, +} from 'react'; import classNames from 'classnames'; +import { keysOf } from '../common'; import { EuiPortal } from '../portal'; import { EuiToolTipPopover } from './tool_tip_popover'; import { findPopoverPosition } from '../../services'; @@ -9,23 +16,34 @@ import { findPopoverPosition } from '../../services'; import makeId from '../form/form_row/make_id'; import { EuiResizeObserver } from '../observer/resize_observer'; -const positionsToClassNameMap = { +export type ToolTipPositions = 'top' | 'right' | 'bottom' | 'left'; + +const positionsToClassNameMap: { [key in ToolTipPositions]: string } = { top: 'euiToolTip--top', right: 'euiToolTip--right', bottom: 'euiToolTip--bottom', left: 'euiToolTip--left', }; -export const POSITIONS = Object.keys(positionsToClassNameMap); +export const POSITIONS = keysOf(positionsToClassNameMap); + +export type ToolTipDelay = 'regular' | 'long'; -const delayToClassNameMap = { +const delayToClassNameMap: { [key in ToolTipDelay]: string | null } = { regular: null, long: 'euiToolTip--delayLong', }; -export const DELAY = Object.keys(delayToClassNameMap); +export const DELAY = keysOf(delayToClassNameMap); -const DEFAULT_TOOLTIP_STYLES = { +interface ToolTipStyles { + top: number; + left: number | 'auto'; + right?: number | 'auto'; + opacity?: number; +} + +const DEFAULT_TOOLTIP_STYLES: ToolTipStyles = { // position the tooltip content near the top-left // corner of the window so it can't create scrollbars // 50,50 because who knows what negative margins, padding, etc @@ -36,19 +54,74 @@ const DEFAULT_TOOLTIP_STYLES = { opacity: 0, }; -export class EuiToolTip extends Component { - constructor(props) { - super(props); +export interface Props { + /** + * Passes onto the the trigger. + */ + anchorClassName?: string; + /** + * The in-view trigger for your tooltip. + */ + children: ReactElement; + /** + * Passes onto the tooltip itself, not the trigger. + */ + className?: string; + /** + * The main content of your tooltip. + */ + content?: ReactNode; + /** + * Delay before showing tooltip. Good for repeatable items. + */ + delay: ToolTipDelay; + /** + * An optional title for your tooltip. + */ + title?: ReactNode; + /** + * Unless you provide one, this will be randomly generated. + */ + id?: string; + /** + * Suggested position. If there is not enough room for it this will be changed. + */ + position: ToolTipPositions; + + /** + * If supplied, called when mouse movement causes the tool tip to be + * hidden. + */ + onMouseOut?: (event: ReactMouseEvent) => void; +} - this.state = { - visible: false, - hasFocus: false, - calculatedPosition: this.props.position, - toolTipStyles: DEFAULT_TOOLTIP_STYLES, - arrowStyles: {}, - id: this.props.id || makeId(), - }; - } +interface State { + visible: boolean; + hasFocus: boolean; + calculatedPosition: ToolTipPositions; + toolTipStyles: ToolTipStyles; + arrowStyles: undefined | { left: number; top: number }; + id: string; +} + +export class EuiToolTip extends Component { + _isMounted = false; + anchor: null | HTMLElement = null; + popover: null | HTMLElement = null; + + state: State = { + visible: false, + hasFocus: false, + calculatedPosition: this.props.position, + toolTipStyles: DEFAULT_TOOLTIP_STYLES, + arrowStyles: undefined, + id: this.props.id || makeId(), + }; + + static defaultProps: Partial = { + position: 'top', + delay: 'regular', + }; componentDidMount() { this._isMounted = true; @@ -58,7 +131,7 @@ export class EuiToolTip extends Component { this._isMounted = false; } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps: Props, prevState: State) { if (prevState.visible === false && this.state.visible === true) { requestAnimationFrame(this.testAnchor); } @@ -79,7 +152,7 @@ export class EuiToolTip extends Component { } }; - setPopoverRef = ref => { + setPopoverRef = (ref: HTMLElement) => { this.popover = ref; // if the popover has been unmounted, clear @@ -87,7 +160,7 @@ export class EuiToolTip extends Component { if (ref == null) { this.setState({ toolTipStyles: DEFAULT_TOOLTIP_STYLES, - arrowStyles: {}, + arrowStyles: undefined, }); } }; @@ -99,6 +172,10 @@ export class EuiToolTip extends Component { positionToolTip = () => { const requestedPosition = this.props.position; + if (!this.anchor || !this.popover) { + return; + } + const { position, left, top, arrow } = findPopoverPosition({ anchor: this.anchor, popover: this.popover, @@ -120,7 +197,7 @@ export class EuiToolTip extends Component { document.documentElement.clientWidth || window.innerWidth; const useRightValue = windowWidth / 2 < left; - const toolTipStyles = { + const toolTipStyles: ToolTipStyles = { top, left: useRightValue ? 'auto' : left, right: useRightValue @@ -156,12 +233,12 @@ export class EuiToolTip extends Component { this.hideToolTip(); }; - onMouseOut = e => { + onMouseOut = (e: ReactMouseEvent) => { // Prevent mousing over children from hiding the tooltip by testing for whether the mouse has // left the anchor for a non-child. if ( this.anchor === e.relatedTarget || - !this.anchor.contains(e.relatedTarget) + (this.anchor != null && !this.anchor.contains(e.relatedTarget as Node)) ) { if (!this.state.hasFocus) { this.hideToolTip(); @@ -169,7 +246,7 @@ export class EuiToolTip extends Component { } if (this.props.onMouseOut) { - this.props.onMouseOut(); + this.props.onMouseOut(e); } }; @@ -248,49 +325,3 @@ export class EuiToolTip extends Component { ); } } - -EuiToolTip.propTypes = { - /** - * The in-view trigger for your tooltip. - */ - children: PropTypes.element.isRequired, - /** - * The main content of your tooltip. - */ - content: PropTypes.node, - - /** - * 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), - - /** - * Delay before showing tooltip. Good for repeatable items. - */ - delay: PropTypes.oneOf(DELAY), - - /** - * Passes onto the tooltip itself, not the trigger. - */ - className: PropTypes.string, - - /** - * Passes onto the the trigger. - */ - anchorClassName: PropTypes.string, - - /** - * Unless you provide one, this will be randomly generated. - */ - id: PropTypes.string, -}; - -EuiToolTip.defaultProps = { - position: 'top', - delay: 'regular', -}; diff --git a/src/components/tool_tip/tool_tip_popover.tsx b/src/components/tool_tip/tool_tip_popover.tsx index 069ba4f21e6..f2d29da157d 100644 --- a/src/components/tool_tip/tool_tip_popover.tsx +++ b/src/components/tool_tip/tool_tip_popover.tsx @@ -1,16 +1,16 @@ import React, { HTMLAttributes, Component, ReactNode } from 'react'; import classNames from 'classnames'; -import { CommonProps } from '../common'; +import { CommonProps, Omit } from '../common'; -export type EuiToolTipPopoverProps = CommonProps & - HTMLAttributes & { +type Props = CommonProps & + Omit, 'title'> & { positionToolTip: (rect: ClientRect | DOMRect) => void; children?: ReactNode; title?: ReactNode; popoverRef?: (ref: HTMLDivElement) => void; }; -export class EuiToolTipPopover extends Component { +export class EuiToolTipPopover extends Component { private popover: HTMLDivElement | undefined; updateDimensions = () => { diff --git a/src/services/popover/popover_positioning.ts b/src/services/popover/popover_positioning.ts index 0465b9cb140..c9b099974b2 100644 --- a/src/services/popover/popover_positioning.ts +++ b/src/services/popover/popover_positioning.ts @@ -70,6 +70,15 @@ interface FindPopoverPositionArgs { returnBoundingBox?: boolean; } +interface FindPopoverPositionResult { + top: number; + left: number; + position: 'top' | 'right' | 'bottom' | 'left'; + fit: number; + arrow?: { left: number; top: number }; + anchorBoundingBox?: EuiClientRect; +} + /** * Calculates the absolute positioning (relative to document.body) to place a popover element * @@ -86,14 +95,9 @@ interface FindPopoverPositionArgs { * present, describes the size & constraints for an arrow element, and the * function return value will include an `arrow` param with position details * - * @returns {{ - * top: number, - * left: number, - * position: string, - * fit: number, - * arrow?: {left: number, top: number} - * } | null} absolute page coordinates for the popover, and the - * placements's relation to the anchor; if there's no room this returns null + * @returns {FindPopoverPositionResult} absolute page coordinates for the + * popover, and the placements's relation to the anchor or undefined + * there's no room. */ export function findPopoverPosition({ anchor, @@ -107,7 +111,7 @@ export function findPopoverPosition({ container, arrowConfig, returnBoundingBox, -}: FindPopoverPositionArgs) { +}: FindPopoverPositionArgs): FindPopoverPositionResult { // find the screen-relative bounding boxes of the anchor, popover, and container const anchorBoundingBox = getElementBoundingBox(anchor); const popoverBoundingBox = getElementBoundingBox(popover); @@ -179,7 +183,7 @@ export function findPopoverPosition({ } let bestFit = -Infinity; - let bestPosition = null; + let bestPosition: FindPopoverPositionResult | null = null; for (let idx = 0; idx < iterationPositions.length; idx++) { const iterationPosition = iterationPositions[idx]; @@ -216,9 +220,15 @@ export function findPopoverPosition({ // If we haven't improved the fit, then continue on and try a new position. } - return returnBoundingBox - ? { ...bestPosition, anchorBoundingBox: { ...anchorBoundingBox } } - : bestPosition; + if (bestPosition == null) { + throw new Error('Failed to calculate bestPosition'); + } + + if (returnBoundingBox) { + bestPosition.anchorBoundingBox = anchorBoundingBox; + } + + return bestPosition; } interface GetPopoverScreenCoordinatesArgs { @@ -233,6 +243,13 @@ interface GetPopoverScreenCoordinatesArgs { buffer?: number; } +interface GetPopoverScreenCoordinatesResult { + top: number; + left: number; + fit: number; + arrow: { top: number; left: number } | undefined; +} + /** * Given a target position and the popover's surrounding context, returns either an * object with {top, left} screen coordinates or `null` if it's not possible to show @@ -250,10 +267,9 @@ interface GetPopoverScreenCoordinatesArgs { * @param [buffer=0] {number} Minimum distance between the popover's * placement and the container edge * - * @returns {{top: number, left: number, relativePlacement: string, fit: - * number, arrow?: {top: number, left: number}}|null} + * @returns {GetPopoverScreenCoordinatesResult} * object with top/left coordinates, the popover's relative position to the anchor, and how well the - * popover fits in the location (0.0 -> 1.0) oordinates and the popover's relative position, if + * popover fits in the location (0.0 -> 1.0) coordinates and the popover's relative position, if * there is no room in this placement then null */ export function getPopoverScreenCoordinates({ @@ -266,7 +282,7 @@ export function getPopoverScreenCoordinates({ arrowConfig, offset = 0, buffer = 0, -}: GetPopoverScreenCoordinatesArgs) { +}: GetPopoverScreenCoordinatesArgs): GetPopoverScreenCoordinatesResult { /** * The goal is to find the best way to align the popover content * on the given side of the anchor element. The popover prefers @@ -377,7 +393,7 @@ export function getPopoverScreenCoordinates({ fit, top: popoverPlacement.top, left: popoverPlacement.left, - arrow, + arrow: arrow ? { left: arrow.left!, top: arrow.top! } : undefined, }; }