diff --git a/packages/design-system/src/components/Drawer/DrawerToggle.tsx b/packages/design-system/src/components/Drawer/DrawerToggle.tsx index cff5de15da..db69ac44a6 100644 --- a/packages/design-system/src/components/Drawer/DrawerToggle.tsx +++ b/packages/design-system/src/components/Drawer/DrawerToggle.tsx @@ -1,7 +1,7 @@ import Button, { ButtonProps } from '../Button/Button'; import React, { useEffect, useRef } from 'react'; import classNames from 'classnames'; -import usePrevious from './usePrevious'; +import usePrevious from '../utilities/usePrevious'; export type DrawerToggleProps = ButtonProps & { /** diff --git a/packages/design-system/src/components/Tooltip/Tooltip.stories.jsx b/packages/design-system/src/components/Tooltip/Tooltip.stories.jsx index 4ae471072f..229596218e 100644 --- a/packages/design-system/src/components/Tooltip/Tooltip.stories.jsx +++ b/packages/design-system/src/components/Tooltip/Tooltip.stories.jsx @@ -80,3 +80,18 @@ InteractiveContent.args = { ), children: 'Tooltip with interactive content', }; + +export const TooltipWithCloseButton = Template.bind({}); +TooltipWithCloseButton.args = { + dialog: true, + title: ( + <> + Entering your Social Security Number helps the plan confirm with your state that you have + Medicaid. + > + ), + children: 'Tooltip trigger', + contentHeading: 'Really long Heading for tooltip', + showCloseButton: true, + className: 'ds-c-button', +}; diff --git a/packages/design-system/src/components/Tooltip/Tooltip.test.jsx b/packages/design-system/src/components/Tooltip/Tooltip.test.jsx index a2de67a4a5..3b34f8a206 100644 --- a/packages/design-system/src/components/Tooltip/Tooltip.test.jsx +++ b/packages/design-system/src/components/Tooltip/Tooltip.test.jsx @@ -1,45 +1,129 @@ -import { mount, shallow } from 'enzyme'; +import { render, fireEvent } from '@testing-library/react'; import React from 'react'; import Tooltip from './Tooltip'; import TooltipIcon from './TooltipIcon'; jest.mock('@popperjs/core'); +const triggerAriaLabelText = 'tooltip trigger'; const defaultProps = { children: , className: 'ds-c-tooltip__trigger-icon', title: 'Tooltip body content', + ariaLabel: triggerAriaLabelText, }; -function render(customProps = {}, deep = false) { +function renderTooltip(customProps = {}) { const props = { ...defaultProps, ...customProps }; - const component = ; - return { - props: props, - wrapper: deep ? mount(component) : shallow(component), - }; + return render(); } describe('Tooltip', function () { it('renders default trigger icon', () => { - const tooltip = render(); - expect(tooltip.wrapper).toMatchSnapshot(); + const { queryByLabelText } = renderTooltip(); + const triggerEl = queryByLabelText(triggerAriaLabelText); + expect(triggerEl).toMatchSnapshot(); }); it('renders inverse tooltip', () => { - const tooltip = render({ inversed: true }); - expect(tooltip.wrapper).toMatchSnapshot(); + const { asFragment } = renderTooltip({ inversed: true }); + expect(asFragment()).toMatchSnapshot(); }); it('renders custom trigger component', () => { - const tooltip = render({ + const { queryByLabelText } = renderTooltip({ component: 'a', }); - expect(tooltip.wrapper).toMatchSnapshot(); + const triggerEl = queryByLabelText(triggerAriaLabelText); + expect(triggerEl).toMatchSnapshot(); }); it('renders dialog tooltip', () => { - const tooltip = render({ dialog: true }); - expect(tooltip.wrapper).toMatchSnapshot(); + const { queryByRole, getByLabelText } = renderTooltip({ dialog: true }); + const tooltipTrigger = getByLabelText(triggerAriaLabelText); + fireEvent.click(tooltipTrigger); + const contentEl = queryByRole('dialog'); + expect(contentEl).not.toBeNull(); + expect(contentEl).toMatchSnapshot(); + }); + + describe('tooltip with close', () => { + it('renders a close button', () => { + const { getByLabelText } = renderTooltip({ dialog: true, showCloseButton: true }); + const closeButton = getByLabelText('Close', { selector: 'button' }); + expect(closeButton).toBeDefined(); + }); + + it('renders heading element', () => { + const { queryByRole, getByLabelText } = renderTooltip({ + dialog: true, + contentHeading: 'Tooltip heading content', + }); + const tooltipTrigger = getByLabelText(triggerAriaLabelText); + fireEvent.click(tooltipTrigger); + const contentEl = queryByRole('dialog'); + expect(contentEl).toMatchSnapshot(); + }); + + it('renders heading element and close button', () => { + const { queryByRole, getByLabelText } = renderTooltip({ + dialog: true, + contentHeading: 'Tooltip heading content', + showCloseButton: true, + }); + const tooltipTrigger = getByLabelText(triggerAriaLabelText); + fireEvent.click(tooltipTrigger); + const contentEl = queryByRole('dialog'); + expect(contentEl).toMatchSnapshot(); + }); + + it('should call onClose when close button is clicked', () => { + const onClose = jest.fn(); + const { getByLabelText } = renderTooltip({ + dialog: true, + showCloseButton: true, + onClose, + }); + const tooltipTrigger = getByLabelText(triggerAriaLabelText); + fireEvent.click(tooltipTrigger); + const closeButton = getByLabelText('Close', { selector: 'button' }); + fireEvent.click(closeButton); + expect(onClose).toHaveBeenCalled(); + }); + + it('should close tooltip when onClose is clicked', () => { + const { getByLabelText, queryByRole } = renderTooltip({ + dialog: true, + showCloseButton: true, + }); + const tooltipTrigger = getByLabelText(triggerAriaLabelText); + fireEvent.click(tooltipTrigger); + const closeButton = getByLabelText('Close', { selector: 'button' }); + fireEvent.click(closeButton); + const tooltipContent = queryByRole('dialog'); + expect(tooltipContent).toBeNull(); + }); + + it('should return focus back to trigger when closed', () => { + const { getByLabelText } = renderTooltip({ + dialog: true, + showCloseButton: true, + }); + const tooltipTrigger = getByLabelText(triggerAriaLabelText); + fireEvent.click(tooltipTrigger); + const closeButton = getByLabelText('Close', { selector: 'button' }); + fireEvent.click(closeButton); + expect(tooltipTrigger).toEqual(document.activeElement); + }); + + it('close button should take custom aria label', () => { + const { queryByLabelText } = renderTooltip({ + dialog: true, + showCloseButton: true, + closeButtonLabel: 'custom close label text', + }); + const closeButton = queryByLabelText('custom close label text'); + expect(closeButton).not.toBeNull(); + }); }); }); diff --git a/packages/design-system/src/components/Tooltip/Tooltip.tsx b/packages/design-system/src/components/Tooltip/Tooltip.tsx index cf72a7f32d..a079afd8e1 100644 --- a/packages/design-system/src/components/Tooltip/Tooltip.tsx +++ b/packages/design-system/src/components/Tooltip/Tooltip.tsx @@ -12,6 +12,9 @@ import React, { useState, useRef, useEffect, useLayoutEffect } from 'react'; import classNames from 'classnames'; import { createPopper, Placement } from '@popperjs/core'; import uniqueId from 'lodash/uniqueId'; +import { Button } from '../Button'; +import { CloseIconThin } from '../Icons'; +import usePrevious from '../utilities/usePrevious'; export interface TooltipProps { /** @@ -26,14 +29,23 @@ export interface TooltipProps { * Tooltip trigger content */ children: React.ReactNode; + /** + * Classes applied to the tooltip trigger + */ + className?: string; + /** + * Configurable text for the aria-label of the tooltip's close button + */ + closeButtonLabel?: string; /** * When provided, will render the passed in component for the tooltip trigger. Typically will be a `button`, `a`, or rarely an `input` element. */ component?: React.ReactElement | any | ((...args: any[]) => any); /** - * Classes applied to the tooltip trigger + * Heading for the tooltip content. This will show above 'title' content and inline with 'closeButton' if closeButton is set */ - className?: string; + contentHeading?: React.ReactNode; + /** * Tooltip that behaves like a dialog, i.e. a tooltip that only appears on click, traps focus, and contains interactive content. For more information, see Deque's [tooltip dialog documentation](https://dequeuniversity.com/library/aria/tooltip-dialog) */ @@ -67,6 +79,10 @@ export interface TooltipProps { * `maxWidth` styling applied to the tooltip body */ maxWidth?: string; + /** + * Determines if close button is shown in tooltip. It is recommended that the close button is only used if `dialog=true` + */ + showCloseButton?: boolean; /** * Content inside the tooltip body or popover. If contains interactive elements use the `dialog` prop. */ @@ -97,6 +113,7 @@ export const Tooltip = (props: TooltipProps) => { const [active, setActive] = useState(false); const [isHover, setIsHover] = useState(false); const [isMobile, setIsMobile] = useState(false); + const prevActiveStateVar = usePrevious(active); const handleEscapeKey = (event: KeyboardEvent) => { const ESCAPE_KEY = 27; @@ -115,6 +132,12 @@ export const Tooltip = (props: TooltipProps) => { } }; + const handleCloseButtonClick = () => { + if (active && (props.dialog || isMobile)) { + setActive(false); + } + }; + const handleBlur = (event: MouseEvent) => { setTimeout(() => { const focusedInsideTrigger = triggerElement.current?.contains(event.target); @@ -160,6 +183,13 @@ export const Tooltip = (props: TooltipProps) => { props.onOpen && props.onOpen(); } else { props.onClose && props.onClose(); + + // if tooltip goes from active to inactive and is the dialog version, focus the trigger + if (prevActiveStateVar && (props.dialog || isMobile) && props.showCloseButton) { + if (triggerElement && triggerElement.current) { + triggerElement.current.focus(); + } + } } }, [active]); @@ -188,6 +218,9 @@ export const Tooltip = (props: TooltipProps) => { title, transitionDuration, zIndex, + showCloseButton, + closeButtonLabel, + contentHeading, ...others } = props; @@ -238,11 +271,14 @@ export const Tooltip = (props: TooltipProps) => { const renderContent = (props: TooltipProps): React.ReactElement => { const { + closeButtonLabel, dialog, + contentHeading, inversed, interactiveBorder, placement, maxWidth, + showCloseButton, title, transitionDuration, zIndex, @@ -270,7 +306,29 @@ export const Tooltip = (props: TooltipProps) => { {...eventHandlers} > - {title} + + {contentHeading || showCloseButton ? ( + + {contentHeading} + {showCloseButton && ( + + + + )} + + ) : null} + {title} + {!dialog && ( )} diff --git a/packages/design-system/src/components/Tooltip/__snapshots__/Tooltip.test.jsx.snap b/packages/design-system/src/components/Tooltip/__snapshots__/Tooltip.test.jsx.snap index 14f2dfa4bd..4e7c4e238d 100644 --- a/packages/design-system/src/components/Tooltip/__snapshots__/Tooltip.test.jsx.snap +++ b/packages/design-system/src/components/Tooltip/__snapshots__/Tooltip.test.jsx.snap @@ -1,245 +1,237 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Tooltip renders custom trigger component 1`] = ` - - - - - - - - - Tooltip body content - - + - - - + + + `; exports[`Tooltip renders default trigger icon 1`] = ` + + + + + Information + + + + + +`; + +exports[`Tooltip renders dialog tooltip 1`] = ` - + - - - + +`; + +exports[`Tooltip renders inverse tooltip 1`] = ` + + + + + + + Information + + + + + Tooltip body content - - + + `; -exports[`Tooltip renders dialog tooltip 1`] = ` +exports[`Tooltip tooltip with close renders heading element 1`] = ` - + - - - - - - - - Tooltip body content - - - - + Tooltip heading content + + Tooltip body content + `; -exports[`Tooltip renders inverse tooltip 1`] = ` +exports[`Tooltip tooltip with close renders heading element and close button 1`] = ` - - - - + - - - Tooltip body content - - + + + Close + + + + - + Tooltip body content + `; diff --git a/packages/design-system/src/components/Drawer/usePrevious.ts b/packages/design-system/src/components/utilities/usePrevious.ts similarity index 100% rename from packages/design-system/src/components/Drawer/usePrevious.ts rename to packages/design-system/src/components/utilities/usePrevious.ts diff --git a/packages/design-system/src/styles/components/_Tooltip.scss b/packages/design-system/src/styles/components/_Tooltip.scss index 65e9bb35d5..0dbca7fcd4 100644 --- a/packages/design-system/src/styles/components/_Tooltip.scss +++ b/packages/design-system/src/styles/components/_Tooltip.scss @@ -14,6 +14,8 @@ $tooltip-text-color: $color-base !default; $tooltip-font-size: $base-font-size !default; $tooltip-background-color: $color-background !default; +$tooltip-close-min-width: 32px !default; + %trigger-reset-styles { // override user agent button styles background: none; @@ -117,6 +119,22 @@ $tooltip-background-color: $color-background !default; } } +.ds-c-tooltip__header { + align-items: flex-start; + display: flex; + justify-content: space-between; + margin-bottom: $spacer-1; + + .ds-c-tooltip__close-button { + // IE11 fix + min-width: $tooltip-close-min-width; + } +} + +.ds-c-tooltip__header--right { + justify-content: flex-end; +} + // The invisible area around the tooltip container that keeps the tooltip visible on hover .ds-c-tooltip__interactive-border { height: 100%;