UNPKG

@salesforce/design-system-react

Version:

Salesforce Lightning Design System for React

480 lines (434 loc) 13.6 kB
/* Copyright (c) 2015-present, salesforce.com, inc. All rights reserved */ /* Licensed under BSD 3-Clause - see LICENSE.txt or git.io/sfdc-license */ // # Tooltip import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import EventUtil from '../../utilities/event'; import { POPOVER_TOOLTIP } from '../../utilities/constants'; import generateId from '../../utilities/generate-id'; import Dialog from '../utilities/dialog'; import Icon from '../icon'; // eslint-disable-next-line import/no-cycle import Button from '../button'; // This component's `checkProps` which issues warnings to developers about properties when in development mode (similar to React's built in development tools) import checkProps from './check-props'; import componentDoc from './component.json'; import { IconSettingsContext } from '../icon-settings'; // ### Display Name // Always use the canonical component name as the React display name. const displayName = POPOVER_TOOLTIP; const propTypes = { /** * Alignment of the Tooltip relative to the element that triggers it. */ align: PropTypes.oneOf([ 'top', 'top left', 'top right', 'right', 'right top', 'right bottom', 'bottom', 'bottom left', 'bottom right', 'left', 'left top', 'left bottom', ]).isRequired, /** * **Assistive text for accessibility** * This object is merged with the default props object on every render. * * `tooltipTipLearnMoreIcon`: This text is inside the info icon within the tooltip content and exists to "complete the sentence" for assistive tech users. * * `triggerLearnMoreIcon`: This text is inside the info icon that triggers the tooltip in order to have text within the link. */ assistiveText: PropTypes.shape({ tooltipTipLearnMoreIcon: PropTypes.string, triggerLearnMoreIcon: PropTypes.string, }), /** * Pass the one element that triggers the Tooltip as a child. It must be an element with `tabIndex` or an element that already has a `tabIndex` set such as an anchor or a button, so that keyboard users can tab to it. */ children: PropTypes.node, /** * Content inside Tooltip. */ content: PropTypes.node.isRequired, /** * CSS classes to be added to the popover dialog. That is the element with `.slds-popover` on it. */ dialogClassName: PropTypes.oneOfType([ PropTypes.array, PropTypes.object, PropTypes.string, ]), /** * Enabling this hides the default nubbin, replacing it with one attached directly to the tooltip trigger. Note: `hasStaticAlignment` should be set to `true` if using this feature as auto-flipping anchored nubbins are not currently supported. */ hasAnchoredNubbin: PropTypes.bool, /** * By default, dialogs will flip their alignment (such as bottom to top) if they extend beyond a boundary element such as a scrolling parent or a window/viewpoint. `hasStaticAlignment` disables this behavior and allows this component to extend beyond boundary elements. _Not tested._ */ hasStaticAlignment: PropTypes.bool, /** * Delay on Tooltip closing in milliseconds. Defaults to 50 */ hoverCloseDelay: PropTypes.number, /** * Delay on Tooltip opening in milliseconds. Defaults to 0 */ hoverOpenDelay: PropTypes.number, /** * A unique ID is needed in order to support keyboard navigation, ARIA support, and connect the popover to the triggering element. */ id: PropTypes.string, /** * **Text labels for internationalization** * This object is merged with the default props object on every render. * * `learnMoreAfter`: This label appears in the tooltip after the info icon. * * `learnMoreBefore`: This label appears in the tooltip before the info icon. */ labels: PropTypes.shape({ learnMoreAfter: PropTypes.string, learnMoreBefore: PropTypes.string, }), /** * Forces tooltip to be open. A value of `false` will disable any interaction with the tooltip. */ isOpen: PropTypes.bool, /** * Callback that returns an element or React `ref` to align the Tooltip with. */ onRequestTargetElement: PropTypes.func, /** * CSS classes to be added to tag with `slds-tooltip-trigger`. */ triggerClassName: PropTypes.oneOfType([ PropTypes.array, PropTypes.object, PropTypes.string, ]), /** * Please select one of the following: * * `absolute` - (default) The dialog will use `position: absolute` and style attributes to position itself. This allows inverted placement or flipping of the dialog. * * `overflowBoundaryElement` - The dialog will overflow scrolling parents. Use on elements that are aligned to the left or right of their target and don't care about the target being within a scrolling parent. Typically this is a popover or tooltip. Dropdown menus can usually open up and down if no room exists. In order to achieve this a portal element will be created and attached to `body`. This element will render into that detached render tree. * * `relative` - No styling or portals will be used. Menus will be positioned relative to their triggers. This is a great choice for HTML snapshot testing. */ position: PropTypes.oneOf([ 'absolute', 'overflowBoundaryElement', 'relative', ]), /** * Custom styles to be added to wrapping triggering `div`. */ triggerStyle: PropTypes.object, /** * Determines the theme of tooltip: for informative purpose (blue background) or warning purpose (red background). This used to be `variant`. */ theme: PropTypes.oneOf(['info', 'error']), /** * Determines the type of the tooltip. */ variant: PropTypes.oneOf(['base', 'learnMore', 'list-item']), }; const defaultProps = { assistiveText: { tooltipTipLearnMoreIcon: 'this link', triggerLearnMoreIcon: 'Help', }, align: 'top', // eslint-disable-next-line react/jsx-curly-brace-presence content: <span>{'Tooltip'}</span>, labels: { learnMoreAfter: 'to learn more.', learnMoreBefore: 'Click', }, hoverCloseDelay: 50, hoverOpenDelay: 0, position: 'absolute', theme: 'info', variant: 'base', }; /** * The PopoverTooltip component is variant of the Lightning Design System Popover component. This component wraps an element that triggers it to open. It must be a focusable child element (either a button or an anchor), so that keyboard users can navigate to it. */ class Tooltip extends React.Component { constructor(props) { super(props); this.state = { isOpen: false, }; this.tooltipTimeout = {}; // `checkProps` issues warnings to developers about properties (similar to React's built in development tools) checkProps(POPOVER_TOOLTIP, props, componentDoc); this.generatedId = generateId(); } componentWillUnmount() { this.isUnmounting = true; clearTimeout(this.tooltipTimeout); } getAnchoredNubbinStyles() { if (this.props.hasAnchoredNubbin) { const alignment = this.props.align.split(' ')[0]; const nubbinContainerStyles = { height: '0', position: 'relative', width: '0', }; const nubbinStyles = { backgroundColor: '#16325c', content: '', height: '1rem', position: 'absolute', transform: 'rotate(45deg)', width: '1rem', }; const triggerDimensions = { height: this.trigger ? this.trigger.getBoundingClientRect().height : 0, width: this.trigger ? this.trigger.getBoundingClientRect().width : 0, }; switch (alignment) { case 'bottom': { nubbinContainerStyles.left = `${triggerDimensions.width / 2}px`; nubbinContainerStyles.top = `${triggerDimensions.height}px`; nubbinStyles.left = '-8px'; nubbinStyles.top = '3px'; break; } case 'left': { nubbinContainerStyles.left = '0'; nubbinContainerStyles.top = `${triggerDimensions.height / 2}px`; nubbinStyles.left = '-19px'; nubbinStyles.top = '-9px'; break; } case 'right': { nubbinContainerStyles.left = `${triggerDimensions.width}px`; nubbinContainerStyles.top = `${triggerDimensions.height / 2}px`; nubbinStyles.left = '3px'; nubbinStyles.top = '-9px'; break; } default: { nubbinContainerStyles.left = `${triggerDimensions.width / 2}px`; nubbinContainerStyles.top = '0'; nubbinStyles.left = '-8px'; nubbinStyles.top = '-19px'; } } return ( <React.Fragment> <style>{`#${this.getId()}:after, #${this.getId()}:before { display: none; }`}</style> {this.getIsOpen() ? ( <div style={nubbinContainerStyles}> <div style={nubbinStyles} /> </div> ) : null} </React.Fragment> ); } return null; } getContent() { let children; const noChildrenProvided = React.Children.count(this.props.children) === 0; if (noChildrenProvided && this.props.onClickTrigger) { children = [ <a href="#" onClick={EventUtil.trappedHandler(this.props.onClickTrigger)} > <Icon category="utility" name="info" assistiveText={{ label: this.props.assistiveText.triggerLearnMoreIcon, }} size="x-small" /> </a>, ]; } else if (noChildrenProvided) { children = [ <Button aria-disabled assistiveText={{ icon: this.props.assistiveText.triggerLearnMoreIcon, }} iconCategory="utility" iconName="info" variant="icon" />, ]; } else { // eslint-disable-next-line prefer-destructuring children = this.props.children; } return React.Children.map(children, (child, i) => React.cloneElement(child, { key: i, // eslint-disable-line react/no-array-index-key 'aria-describedby': this.getIsOpen() ? this.getId() : undefined, onBlur: this.handleMouseLeave, onFocus: this.handleMouseEnter, onMouseEnter: this.handleMouseEnter, onMouseLeave: this.handleMouseLeave, onKeyDown: this.handleKeyDown, }) ); } getId() { return this.props.id || this.generatedId; } getIsOpen() { return this.props.isOpen === undefined ? this.state.isOpen : this.props.isOpen; } getTooltip() { const isOpen = this.getIsOpen(); const { align } = this.props; // REMOVE AT NEXT BREAKING CHANGE (v1.0 or v0.9) const deprecatedWay = this.props.variant === 'error'; return isOpen ? ( <Dialog closeOnTabKey hasNubbin contentsClassName={classNames( 'slds-popover', 'slds-popover_tooltip', { 'slds-theme_error': this.props.theme === 'error' || deprecatedWay, }, this.props.dialogClassName )} align={align} context={this.context} hasStaticAlignment={this.props.hasStaticAlignment} onClose={this.handleCancel} onRequestTargetElement={() => this.getTooltipTarget()} onMouseLeave={this.handleCancel} position={this.props.position} variant="tooltip" containerProps={{ id: this.getId(), }} > {this.getTooltipContent()} </Dialog> ) : ( <span /> ); } getTooltipContent() { return ( <div className="slds-popover__body"> {this.props.content} {this.props.variant === 'learnMore' && this.props.onClickTrigger ? ( <div className="slds-m-top_x-small" aria-hidden="true"> {this.props.labels.learnMoreBefore}{' '} <Icon assistiveText={{ label: this.props.assistiveText.tooltipTipLearnMoreIcon, }} category="utility" inverse name="info" size="x-small" />{' '} {this.props.labels.learnMoreAfter}{' '} </div> ) : null} </div> ); } getTooltipTarget() { if (this.props.onRequestTargetElement) { return this.props.onRequestTargetElement(); } // for backwards compatibility if (this.props.target) { return this.props.target; } return this.trigger; } handleCancel = () => { clearTimeout(this.tooltipTimeout); this.setState({ isOpen: false, }); }; handleMouseEnter = () => { clearTimeout(this.tooltipTimeout); this.tooltipTimeout = setTimeout(() => { if (!this.isUnmounting) { this.setState({ isOpen: true, }); } }, this.props.hoverOpenDelay); }; handleMouseLeave = (e) => { e.stopPropagation(); clearTimeout(this.tooltipTimeout); this.tooltipTimeout = setTimeout(() => { const isHoveringTooltip = e.relatedTarget?.classList?.contains('slds-popover_tooltip') || e.relatedTarget?.classList?.contains('slds-popover__body'); if (!this.isUnmounting && !isHoveringTooltip) { this.setState({ isOpen: false, }); } }, this.props.hoverCloseDelay); }; handleKeyDown = (e) => { e.stopPropagation(); this.tooltipTimeout = setTimeout(() => { if (!this.isUnmounting && e.key === 'Escape') { this.setState({ isOpen: false, }); } }, this.props.hoverCloseDelay); }; saveTriggerRef = (component) => { this.trigger = component; // yes, this is a re-render triggered by a render. // Dialog/Popper.js cannot place the popover until // the trigger/target DOM node is mounted. This // way `findDOMNode` is not called and parent // DOM nodes are not queried. if (!this.state.triggerRendered) { this.setState({ triggerRendered: true }); } }; render() { const containerStyles = { display: 'inline-block', lineHeight: '1', ...this.props.triggerStyle, }; return ( <div className={classNames( 'slds-tooltip-trigger', this.props.triggerClassName )} style={containerStyles} ref={this.saveTriggerRef} > {this.getAnchoredNubbinStyles()} {this.getContent()} {this.getTooltip()} </div> ); } } Tooltip.contextType = IconSettingsContext; Tooltip.displayName = displayName; Tooltip.propTypes = propTypes; Tooltip.defaultProps = defaultProps; export default Tooltip;