Skip to content

Commit

Permalink
[WNMGDS-1443] updating tooltip to support close button (#1625)
Browse files Browse the repository at this point in the history
* updating tooltip to support close button

* updated tooltip story

* unit tests for new tooltip functionality

* updated tooltip tests

* updated snapshot tests
  • Loading branch information
scheul93 authored Mar 3, 2022
1 parent def96d4 commit b367138
Show file tree
Hide file tree
Showing 7 changed files with 385 additions and 218 deletions.
Original file line number Diff line number Diff line change
@@ -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 & {
/**
Expand Down
15 changes: 15 additions & 0 deletions packages/design-system/src/components/Tooltip/Tooltip.stories.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
114 changes: 99 additions & 15 deletions packages/design-system/src/components/Tooltip/Tooltip.test.jsx
Original file line number Diff line number Diff line change
@@ -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: <TooltipIcon />,
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 = <Tooltip {...props} />;
return {
props: props,
wrapper: deep ? mount(component) : shallow(component),
};
return render(<Tooltip {...props} />);
}

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();
});
});
});
64 changes: 61 additions & 3 deletions packages/design-system/src/components/Tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand All @@ -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> | 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)
*/
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -97,6 +113,7 @@ export const Tooltip = (props: TooltipProps) => {
const [active, setActive] = useState<boolean>(false);
const [isHover, setIsHover] = useState<boolean>(false);
const [isMobile, setIsMobile] = useState<boolean>(false);
const prevActiveStateVar = usePrevious(active);

const handleEscapeKey = (event: KeyboardEvent) => {
const ESCAPE_KEY = 27;
Expand All @@ -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);
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -188,6 +218,9 @@ export const Tooltip = (props: TooltipProps) => {
title,
transitionDuration,
zIndex,
showCloseButton,
closeButtonLabel,
contentHeading,
...others
} = props;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -270,7 +306,29 @@ export const Tooltip = (props: TooltipProps) => {
{...eventHandlers}
>
<span className="ds-c-tooltip__arrow" data-popper-arrow />
<div className="ds-c-tooltip__content ds-base">{title}</div>
<div className="ds-c-tooltip__content ds-base">
{contentHeading || showCloseButton ? (
<div
className={classNames('ds-c-tooltip__header', {
'ds-c-tooltip__header--right': !contentHeading,
})}
>
{contentHeading}
{showCloseButton && (
<Button
variation="transparent"
size="small"
className="ds-c-tooltip__close-button"
onClick={handleCloseButtonClick}
aria-label={closeButtonLabel || 'Close'}
>
<CloseIconThin />
</Button>
)}
</div>
) : null}
{title}
</div>
{!dialog && (
<span className="ds-c-tooltip__interactive-border" style={interactiveBorderStyle} />
)}
Expand Down
Loading

0 comments on commit b367138

Please sign in to comment.