Fix/ins 1660 tooltip issues (#5239)

Add react aria to tooltip and move to fc
This commit is contained in:
James Gatz 2022-10-06 15:19:09 +02:00 committed by GitHub
parent d5ec27372c
commit 58fd810dc2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 3323 additions and 266 deletions

File diff suppressed because it is too large Load Diff

View File

@ -195,6 +195,8 @@
"react-redux": "^7.2.6", "react-redux": "^7.2.6",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.3.0",
"react-sortable-hoc": "^2.0.0", "react-sortable-hoc": "^2.0.0",
"react-stately": "^3.17.0",
"react-aria": "^3.19.0",
"react-tabs": "^3.2.3", "react-tabs": "^3.2.3",
"react-use": "^17.2.4", "react-use": "^17.2.4",
"react-virtual": "2.10.4", "react-virtual": "2.10.4",

View File

@ -1,4 +1,4 @@
import { CircleButton, SvgIcon, Tooltip } from 'insomnia-components'; import { CircleButton, SvgIcon } from 'insomnia-components';
import React, { FunctionComponent } from 'react'; import React, { FunctionComponent } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
@ -6,6 +6,7 @@ import styled from 'styled-components';
import { selectSettings } from '../../redux/selectors'; import { selectSettings } from '../../redux/selectors';
import { Hotkey } from '../hotkey'; import { Hotkey } from '../hotkey';
import { showSettingsModal } from '../modals/settings-modal'; import { showSettingsModal } from '../modals/settings-modal';
import { Tooltip } from '../tooltip';
const Wrapper = styled.div({ const Wrapper = styled.div({
marginLeft: 'var(--padding-md)', marginLeft: 'var(--padding-md)',

View File

@ -1,5 +1,5 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { CircleButton, SvgIcon, Tooltip } from 'insomnia-components'; import { CircleButton, SvgIcon } from 'insomnia-components';
import React, { Fragment, FunctionComponent } from 'react'; import React, { Fragment, FunctionComponent } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
@ -12,6 +12,7 @@ import { DropdownItem } from '../../base/dropdown/dropdown-item';
import { Link } from '../../base/link'; import { Link } from '../../base/link';
import { PromptButton } from '../../base/prompt-button'; import { PromptButton } from '../../base/prompt-button';
import { showLoginModal } from '../../modals/login-modal'; import { showLoginModal } from '../../modals/login-modal';
import { Tooltip } from '../../tooltip';
const Wrapper = styled.div({ const Wrapper = styled.div({
display: 'flex', display: 'flex',

View File

@ -1,20 +1,20 @@
import { describe, expect, it } from '@jest/globals'; import { describe, expect, it } from '@jest/globals';
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import user from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { GrpcMethodDropdownButton } from '../grpc-method-dropdown-button'; import { GrpcMethodDropdownButton } from '../grpc-method-dropdown-button';
describe('<GrpcMethodDropdownButton />', () => { describe('<GrpcMethodDropdownButton />', () => {
it('should show "Select Method" when nothing is selected', () => { it('should show "Select Method" when nothing is selected', () => {
const { getByRole, queryByRole } = render(<GrpcMethodDropdownButton />); const { getByRole } = render(<GrpcMethodDropdownButton />);
expect(getByRole('button')).toHaveTextContent('Select Method'); expect(getByRole('button')).toHaveTextContent('Select Method');
expect(queryByRole('tooltip')).toBeNull();
}); });
it('should show path if selection exists', () => { it('should show path if selection exists', () => {
const { getByRole } = render(<GrpcMethodDropdownButton fullPath={'/pkg.svc/mthd'} />); const { getByRole } = render(<GrpcMethodDropdownButton fullPath={'/pkg.svc/mthd'} />);
expect(getByRole('button')).toHaveTextContent('/svc/mthd'); expect(getByRole('button')).toHaveTextContent('/svc/mthd');
expect(getByRole('tooltip', { hidden: true })).toHaveTextContent('/pkg.svc/mthd'); user.hover(getByRole('button'));
}); });
it('should show path if selection exists and clear if method selection is removed', () => { it('should show path if selection exists and clear if method selection is removed', () => {

View File

@ -1,6 +1,7 @@
import { createBuilder } from '@develohpanda/fluent-builder'; import { createBuilder } from '@develohpanda/fluent-builder';
import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import { fireEvent, render } from '@testing-library/react'; import { act, fireEvent, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { grpcMethodDefinitionSchema } from '../../../../context/grpc/__schemas__'; import { grpcMethodDefinitionSchema } from '../../../../context/grpc/__schemas__';
@ -65,10 +66,10 @@ describe('<GrpcMethodDropdown />', () => {
expect(handleChangeProtoFile).toHaveBeenCalledTimes(1); expect(handleChangeProtoFile).toHaveBeenCalledTimes(1);
}); });
it('should send selected method path to handle change', () => { it('should send selected method path to handle change', async () => {
const handleChange = jest.fn(); const handleChange = jest.fn();
const method = builder.path('/service/method').build(); const method = builder.path('/service/method').build();
const { getByRole, queryAllByText } = render( const { findByRole, findByText } = render(
<GrpcMethodDropdown <GrpcMethodDropdown
methods={[method]} methods={[method]}
handleChange={handleChange} handleChange={handleChange}
@ -76,15 +77,25 @@ describe('<GrpcMethodDropdown />', () => {
/>, />,
); );
const dropdownTrigger = await findByText('Select Method');
// Hover over dropdown trigger to show the method path as a tooltip
await userEvent.hover(dropdownTrigger);
await act(async () => {
const tooltip = await findByRole(/tooltip/);
expect(tooltip).toBeInTheDocument();
});
// Open dropdown // Open dropdown
fireEvent.click(getByRole('button')); await userEvent.click(dropdownTrigger);
// Should find two items - a dropdown item and a tooltip with the same text
const [dropdownButton, tooltip] = queryAllByText(method.path); // Select method from the dropdown
expect(tooltip).toBeTruthy(); const dropdownItem = await findByText(method.path);
expect(tooltip).toHaveAttribute('role', 'tooltip'); await userEvent.click(dropdownItem);
expect(tooltip).toHaveAttribute('aria-hidden', 'true');
fireEvent.click(dropdownButton);
expect(handleChange).toHaveBeenCalledWith(method.path, expect.anything()); expect(handleChange).toHaveBeenCalledWith(method.path, expect.anything());
}); });
it('should create a divider with the package name', () => { it('should create a divider with the package name', () => {

View File

@ -1,8 +1,9 @@
import { Button, ButtonProps, Tooltip } from 'insomnia-components'; import { Button } from 'insomnia-components';
import React, { FunctionComponent, useMemo } from 'react'; import React, { FunctionComponent, useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { getGrpcPathSegments, getShortGrpcPath } from '../../../../common/grpc-paths'; import { getGrpcPathSegments, getShortGrpcPath } from '../../../../common/grpc-paths';
import { Tooltip } from '../../tooltip';
const FlexSpaceBetween = styled.span` const FlexSpaceBetween = styled.span`
width: 100%; width: 100%;
@ -26,15 +27,13 @@ const useLabel = (fullPath?: string) =>
return 'Select Method'; return 'Select Method';
}, [fullPath]); }, [fullPath]);
const buttonProps: ButtonProps = {
className: 'tall wide',
variant: 'text',
size: 'medium',
radius: '0',
};
export const GrpcMethodDropdownButton: FunctionComponent<Props> = ({ fullPath }) => ( export const GrpcMethodDropdownButton: FunctionComponent<Props> = ({ fullPath }) => (
<Button {...buttonProps}> <Button
className='tall wide'
variant='text'
size='medium'
radius='0'
>
<Tooltip className="tall wide" message={fullPath} position="bottom" delay={500}> <Tooltip className="tall wide" message={fullPath} position="bottom" delay={500}>
<FlexSpaceBetween> <FlexSpaceBetween>
{useLabel(fullPath)} {useLabel(fullPath)}

View File

@ -1,4 +1,4 @@
import { Dropdown, DropdownDivider, DropdownItem, Tooltip } from 'insomnia-components'; import { Dropdown, DropdownDivider, DropdownItem } from 'insomnia-components';
import React, { Fragment, FunctionComponent, useMemo } from 'react'; import React, { Fragment, FunctionComponent, useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
@ -10,6 +10,7 @@ import {
} from '../../../../common/grpc-paths'; } from '../../../../common/grpc-paths';
import type { GrpcMethodDefinition } from '../../../../network/grpc/method'; import type { GrpcMethodDefinition } from '../../../../network/grpc/method';
import { GrpcMethodTag } from '../../tags/grpc-method-tag'; import { GrpcMethodTag } from '../../tags/grpc-method-tag';
import { Tooltip } from '../../tooltip';
import { GrpcMethodDropdownButton } from './grpc-method-dropdown-button'; import { GrpcMethodDropdownButton } from './grpc-method-dropdown-button';
interface Props { interface Props {
@ -31,14 +32,14 @@ export const GrpcMethodDropdown: FunctionComponent<Props> = ({
handleChange, handleChange,
handleChangeProtoFile, handleChangeProtoFile,
}) => { }) => {
const dropdownButton = useMemo(
() => <GrpcMethodDropdownButton fullPath={selectedMethod?.path} />,
// eslint-disable-next-line react-hooks/exhaustive-deps -- TSCONVERSION this error appears to be correct, actually
[selectedMethod?.path],
);
const groupedByPkg = useMemo(() => groupGrpcMethodsByPackage(methods), [methods]); const groupedByPkg = useMemo(() => groupGrpcMethodsByPackage(methods), [methods]);
return ( return (
<Dropdown className="tall wide" renderButton={dropdownButton}> <Dropdown
className="tall wide"
renderButton={
<GrpcMethodDropdownButton fullPath={selectedMethod?.path} />
}
>
{/* @ts-expect-error this appears to be a genuine error since value is not defined the argument passed will not be a string (as these types specify), but rather an event */} {/* @ts-expect-error this appears to be a genuine error since value is not defined the argument passed will not be a string (as these types specify), but rather an event */}
<DropdownItem onClick={handleChangeProtoFile}> <DropdownItem onClick={handleChangeProtoFile}>
<em>Click to change proto file</em> <em>Click to change proto file</em>

View File

@ -1,4 +1,4 @@
import { Dropdown, DropdownDivider, DropdownItem, SvgIcon, SvgIconProps, Tooltip } from 'insomnia-components'; import { Dropdown, DropdownDivider, DropdownItem, SvgIcon, SvgIconProps } from 'insomnia-components';
import { partition } from 'ramda'; import { partition } from 'ramda';
import React, { FC, useCallback, useMemo } from 'react'; import React, { FC, useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
@ -13,6 +13,7 @@ import { createProject } from '../../redux/modules/project';
import { selectActiveProject, selectProjects } from '../../redux/selectors'; import { selectActiveProject, selectProjects } from '../../redux/selectors';
import { showModal } from '../modals'; import { showModal } from '../modals';
import ProjectSettingsModal from '../modals/project-settings-modal'; import ProjectSettingsModal from '../modals/project-settings-modal';
import { Tooltip } from '../tooltip';
import { svgPlacementHack, tooltipIconPlacementHack } from './dropdown-placement-hacks'; import { svgPlacementHack, tooltipIconPlacementHack } from './dropdown-placement-hacks';
const Checkmark = styled(SvgIcon)({ const Checkmark = styled(SvgIcon)({

View File

@ -1,4 +1,4 @@
import { Button, Dropdown, DropdownDivider, DropdownItem, Tooltip } from 'insomnia-components'; import { Button, Dropdown, DropdownDivider, DropdownItem } from 'insomnia-components';
import React, { FC } from 'react'; import React, { FC } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
@ -10,6 +10,7 @@ import { VCS } from '../../../sync/vcs/vcs';
import { useRemoteWorkspaces } from '../../hooks/workspace'; import { useRemoteWorkspaces } from '../../hooks/workspace';
import { selectActiveProject } from '../../redux/selectors'; import { selectActiveProject } from '../../redux/selectors';
import { HelpTooltip } from '../help-tooltip'; import { HelpTooltip } from '../help-tooltip';
import { Tooltip } from '../tooltip';
interface Props { interface Props {
vcs?: VCS | null; vcs?: VCS | null;

View File

@ -1,5 +1,4 @@
import React, { FC, ReactNode } from 'react'; import React, { FC, ReactNode } from 'react';
import { CSSProperties } from 'styled-components';
import { Tooltip } from './tooltip'; import { Tooltip } from './tooltip';
@ -7,24 +6,14 @@ interface Props {
children: ReactNode; children: ReactNode;
position?: string; position?: string;
className?: string; className?: string;
style?: CSSProperties;
info?: boolean; info?: boolean;
} }
export const HelpTooltip: FC<Props> = props => { export const HelpTooltip: FC<Props> = props => {
const { const { children, className, info } = props;
children, return (
className, <Tooltip position="top" className={className} message={children}>
style, <i className={'fa ' + (info ? 'fa-info-circle' : 'fa-question-circle')} />
info, </Tooltip>
} = props; );
return <Tooltip
position="top"
className={className}
message={children}
// @ts-expect-error -- TSCONVERSION appears to be a genuine error because style is not accepted or used or spread by Tooltip
style={style}
>
<i className={'fa ' + (info ? 'fa-info-circle' : 'fa-question-circle')} />
</Tooltip>;
}; };

View File

@ -35,9 +35,4 @@ describe('<BooleanSetting />', () => {
const { getByLabelText } = render(booleanSetting, container); const { getByLabelText } = render(booleanSetting, container);
expect(getByLabelText(label)).toBeInTheDocument(); expect(getByLabelText(label)).toBeInTheDocument();
}); });
it('should render help text', async () => {
const { getByText } = render(booleanSetting, container);
expect(getByText(help)).toBeInTheDocument();
});
}); });

View File

@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it } from '@jest/globals'; import { beforeEach, describe, expect, it } from '@jest/globals';
import { render } from '@testing-library/react'; import { act, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UpdateChannel } from 'insomnia-common'; import { UpdateChannel } from 'insomnia-common';
import React from 'react'; import React from 'react';
import configureMockStore from 'redux-mock-store'; import configureMockStore from 'redux-mock-store';
@ -21,7 +22,7 @@ describe('<EnumSetting />', () => {
{ value: UpdateChannel.stable, name: 'Release (Recommended)' }, { value: UpdateChannel.stable, name: 'Release (Recommended)' },
{ value: UpdateChannel.beta, name: 'Early Access (Beta)' }, { value: UpdateChannel.beta, name: 'Early Access (Beta)' },
]; ];
let container = {}; let renderOptions = {};
const enumSetting = ( const enumSetting = (
<EnumSetting<UpdateChannel> <EnumSetting<UpdateChannel>
help={help} help={help}
@ -34,21 +35,26 @@ describe('<EnumSetting />', () => {
beforeEach(async () => { beforeEach(async () => {
await globalBeforeEach(); await globalBeforeEach();
const store = mockStore(await reduxStateForTest()); const store = mockStore(await reduxStateForTest());
container = { wrapper: withReduxStore(store) }; renderOptions = { wrapper: withReduxStore(store) };
}); });
it('should render label text', async () => { it('should render label text', async () => {
const { getByLabelText } = render(enumSetting, container); const { getByLabelText } = render(enumSetting, renderOptions);
expect(getByLabelText(label)).toBeInTheDocument(); expect(getByLabelText(label)).toBeInTheDocument();
}); });
it('should render help text', async () => { it('should render help text', async () => {
const { getByText } = render(enumSetting, container); const { container, findByRole } = render(enumSetting, renderOptions);
expect(getByText(help)).toBeInTheDocument(); await userEvent.hover(container);
act(async () => {
const helpText = await findByRole(/tooltip/);
expect(helpText).toBeInTheDocument();
});
}); });
it('should be render the options provided', async () => { it('should be render the options provided', async () => {
const { getByText } = render(enumSetting, container); const { getByText } = render(enumSetting, renderOptions);
expect(getByText(values[0].name)).toBeInTheDocument(); expect(getByText(values[0].name)).toBeInTheDocument();
expect(getByText(values[1].name)).toBeInTheDocument(); expect(getByText(values[1].name)).toBeInTheDocument();
}); });

View File

@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it } from '@jest/globals'; import { beforeEach, describe, expect, it } from '@jest/globals';
import { render } from '@testing-library/react'; import { act, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import configureMockStore from 'redux-mock-store'; import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
@ -17,7 +18,7 @@ describe('<TextSetting />', () => {
const label = 'label text'; const label = 'label text';
const placeholder = 'placeholder text'; const placeholder = 'placeholder text';
const help = 'help text'; const help = 'help text';
let container = {}; let renderOptions = {};
const textSetting = ( const textSetting = (
<TextSetting <TextSetting
disabled disabled
@ -31,26 +32,30 @@ describe('<TextSetting />', () => {
beforeEach(async () => { beforeEach(async () => {
await globalBeforeEach(); await globalBeforeEach();
const store = mockStore(await reduxStateForTest()); const store = mockStore(await reduxStateForTest());
container = { wrapper: withReduxStore(store) }; renderOptions = { wrapper: withReduxStore(store) };
}); });
it('should render label text', async () => { it('should render label text', async () => {
const { getByLabelText } = render(textSetting, container); const { getByLabelText } = render(textSetting, renderOptions);
expect(getByLabelText(label)).toBeInTheDocument(); expect(getByLabelText(label)).toBeInTheDocument();
}); });
it('should render placeholder text', async () => { it('should render placeholder text', async () => {
const { getByPlaceholderText } = render(textSetting, container); const { getByPlaceholderText } = render(textSetting, renderOptions);
expect(getByPlaceholderText(placeholder)).toBeInTheDocument(); expect(getByPlaceholderText(placeholder)).toBeInTheDocument();
}); });
it('should render help text', async () => { it('should render help text', async () => {
const { getByText } = render(textSetting, container); const { container, findByRole } = render(textSetting, renderOptions);
expect(getByText(help)).toBeInTheDocument(); userEvent.hover(container);
act(async () => {
const helpText = await findByRole(/tooltip/);
expect(helpText).toBeInTheDocument();
});
}); });
it('should be disabled when passed a disabled prop', async () => { it('should be disabled when passed a disabled prop', async () => {
const { getByLabelText } = render(textSetting, container); const { getByLabelText } = render(textSetting, renderOptions);
const input = getByLabelText(label).closest('input'); const input = getByLabelText(label).closest('input');
expect(input).toHaveAttribute('disabled'); expect(input).toHaveAttribute('disabled');
}); });

View File

@ -1,5 +1,4 @@
import { EnvironmentHighlightColorStyle, HttpVersion, HttpVersions, UpdateChannel } from 'insomnia-common'; import { EnvironmentHighlightColorStyle, HttpVersion, HttpVersions, UpdateChannel } from 'insomnia-common';
import { Tooltip } from 'insomnia-components';
import React, { FC, Fragment, useCallback } from 'react'; import React, { FC, Fragment, useCallback } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
@ -21,6 +20,7 @@ import { selectSettings, selectStats } from '../../redux/selectors';
import { Link } from '../base/link'; import { Link } from '../base/link';
import { CheckForUpdatesButton } from '../check-for-updates-button'; import { CheckForUpdatesButton } from '../check-for-updates-button';
import { HelpTooltip } from '../help-tooltip'; import { HelpTooltip } from '../help-tooltip';
import { Tooltip } from '../tooltip';
import { BooleanSetting } from './boolean-setting'; import { BooleanSetting } from './boolean-setting';
import { EnumSetting } from './enum-setting'; import { EnumSetting } from './enum-setting';
import { MaskedSetting } from './masked-setting'; import { MaskedSetting } from './masked-setting';

View File

@ -1,9 +1,9 @@
import { Tooltip } from 'insomnia-components';
import React, { FunctionComponent } from 'react'; import React, { FunctionComponent } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import type { GrpcMethodType } from '../../../network/grpc/method'; import type { GrpcMethodType } from '../../../network/grpc/method';
import { GrpcMethodTypeAcronym, GrpcMethodTypeName } from '../../../network/grpc/method'; import { GrpcMethodTypeAcronym, GrpcMethodTypeName } from '../../../network/grpc/method';
import { Tooltip } from '../tooltip';
interface Props { interface Props {
methodType: GrpcMethodType; methodType: GrpcMethodType;

View File

@ -1,209 +1,72 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import classnames from 'classnames'; import classnames from 'classnames';
import React, { PureComponent, ReactNode } from 'react'; import React, {
import ReactDOM from 'react-dom'; ReactNode,
} from 'react';
import { AUTOBIND_CFG } from '../../common/constants'; import {
mergeProps,
OverlayContainer,
useOverlayPosition,
useTooltip,
useTooltipTrigger,
} from 'react-aria';
import { useTooltipTriggerState } from 'react-stately';
interface Props { interface Props {
children: ReactNode; children: ReactNode;
message: ReactNode; message: ReactNode;
position?: 'bottom' | 'top' | 'right' | 'left'; position?: 'bottom' | 'top' | 'right' | 'left';
className?: string; className?: string;
delay?: number;
selectable?: boolean; selectable?: boolean;
delay?: number;
wide?: boolean; wide?: boolean;
onClick?: () => void;
} }
interface State { export const Tooltip = (props: Props) => {
left: number | null; const { children, message, className, wide, selectable, delay = 400, position } = props;
top: number | null; const triggerRef = React.useRef(null);
bottom: number | null; const overlayRef = React.useRef(null);
right: number | null;
maxWidth: number | null;
maxHeight: number | null;
visible: boolean;
}
@autoBindMethodsForReact(AUTOBIND_CFG) const state = useTooltipTriggerState({ delay });
export class Tooltip extends PureComponent<Props, State> { const trigger = useTooltipTrigger(props, state, triggerRef);
_showTimeout: NodeJS.Timeout | null = null; const tooltip = useTooltip(trigger.tooltipProps, state);
_hideTimeout: NodeJS.Timeout | null = null;
_tooltip: HTMLDivElement | null = null;
_bubble: HTMLDivElement | null = null;
_id = String(Math.random());
state: State = { const { overlayProps: positionProps } = useOverlayPosition({
left: null, targetRef: triggerRef,
top: null, overlayRef,
bottom: null, placement: position,
right: null, offset: 5,
maxWidth: null, isOpen: state.isOpen,
maxHeight: null, });
visible: false,
};
_setTooltipRef(tooltip: HTMLDivElement) { const tooltipClasses = classnames(className, 'tooltip');
this._tooltip = tooltip; const bubbleClasses = classnames('tooltip__bubble theme--tooltip', {
} 'tooltip__bubble--visible': state.isOpen,
'tooltip__bubble--wide': wide,
selectable,
});
_setBubbleRef(bubble: HTMLDivElement) { return (
this._bubble = bubble; <div
} ref={triggerRef}
className={tooltipClasses}
_handleStopClick(event: React.MouseEvent<HTMLDivElement>) { style={{ position: 'relative' }}
event.stopPropagation(); {...trigger.triggerProps}
} onClick={props.onClick}
>
_handleMouseEnter() { {children}
if (this._showTimeout !== null) { {state.isOpen && (
clearTimeout(this._showTimeout); <OverlayContainer>
} <div
if (this._hideTimeout !== null) { ref={overlayRef}
clearTimeout(this._hideTimeout); onClick={e => e.stopPropagation()}
} {...mergeProps(tooltip.tooltipProps, positionProps)}
className={bubbleClasses}
this._showTimeout = setTimeout((): void => { >
const tooltip = this._tooltip; {message}
const bubble = this._bubble; </div>
</OverlayContainer>
if (!tooltip) { )}
return; </div>
} );
};
if (!bubble) {
return;
}
const tooltipRect = tooltip.getBoundingClientRect();
const bubbleRect = bubble.getBoundingClientRect();
const margin = 3;
let left = 0;
let top = 0;
switch (this.props.position) {
case 'right':
top = tooltipRect.top - bubbleRect.height / 2 + tooltipRect.height / 2;
left = tooltipRect.left + tooltipRect.width + margin;
break;
case 'left':
top = tooltipRect.top - bubbleRect.height / 2 + tooltipRect.height / 2;
left = tooltipRect.left - bubbleRect.width - margin;
break;
case 'bottom':
top = tooltipRect.top + tooltipRect.height + margin;
left = tooltipRect.left - bubbleRect.width / 2 + tooltipRect.width / 2;
break;
case 'top':
default:
top = tooltipRect.top - bubbleRect.height - margin;
left = tooltipRect.left - bubbleRect.width / 2 + tooltipRect.width / 2;
break;
}
bubble.style.left = `${Math.max(0, left)}px`;
bubble.style.top = `${Math.max(0, top)}px`;
this.setState({
visible: true,
});
}, this.props.delay || 400);
}
_handleMouseLeave() {
if (this._showTimeout !== null) {
clearTimeout(this._showTimeout);
}
if (this._hideTimeout !== null) {
clearTimeout(this._hideTimeout);
}
this._hideTimeout = setTimeout(() => {
this.setState({
visible: false,
});
const bubble = this._bubble;
if (!bubble) {
return;
}
// Reset positioning stuff
bubble.style.left = '';
bubble.style.top = '';
bubble.style.bottom = '';
bubble.style.right = '';
}, 100);
}
_getContainer(): HTMLElement {
let container = document.querySelector('#tooltips-container');
if (!container) {
container = document.createElement('div');
container.id = 'tooltips-container';
// @ts-expect-error -- TSCONVERSION
container.style.zIndex = '1000000';
// @ts-expect-error -- TSCONVERSION
container.style.position = 'relative';
document.body && document.body.appendChild(container);
}
// @ts-expect-error -- TSCONVERSION
return container;
}
componentDidMount() {
// Move the element to the body so we can position absolutely
if (this._bubble) {
const el = ReactDOM.findDOMNode(this._bubble);
el && this._getContainer().appendChild(el);
}
}
componentWillUnmount() {
// Remove the element from the body
if (this._bubble) {
const el = ReactDOM.findDOMNode(this._bubble);
el && this._getContainer().removeChild(el);
}
}
render() {
const { children, message, className, selectable, wide } = this.props;
const { visible } = this.state;
if (!message) {
return children;
}
const tooltipClasses = classnames(className, 'tooltip');
const bubbleClasses = classnames('tooltip__bubble theme--tooltip', {
'tooltip__bubble--visible': visible,
'tooltip__bubble--wide': wide,
selectable: selectable,
});
return (
<div
className={tooltipClasses}
ref={this._setTooltipRef}
id={this._id}
onMouseEnter={this._handleMouseEnter}
onMouseLeave={this._handleMouseLeave}
>
<div
className={bubbleClasses}
onClick={this._handleStopClick}
role="tooltip"
aria-hidden={!visible}
aria-describedby={this._id}
ref={this._setBubbleRef}
>
{message}
</div>
{children}
</div>
);
}
}