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-router-dom": "^6.3.0",
"react-sortable-hoc": "^2.0.0",
"react-stately": "^3.17.0",
"react-aria": "^3.19.0",
"react-tabs": "^3.2.3",
"react-use": "^17.2.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 { useSelector } from 'react-redux';
import styled from 'styled-components';
@ -6,6 +6,7 @@ import styled from 'styled-components';
import { selectSettings } from '../../redux/selectors';
import { Hotkey } from '../hotkey';
import { showSettingsModal } from '../modals/settings-modal';
import { Tooltip } from '../tooltip';
const Wrapper = styled.div({
marginLeft: 'var(--padding-md)',

View File

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

View File

@ -1,20 +1,20 @@
import { describe, expect, it } from '@jest/globals';
import { render } from '@testing-library/react';
import user from '@testing-library/user-event';
import React from 'react';
import { GrpcMethodDropdownButton } from '../grpc-method-dropdown-button';
describe('<GrpcMethodDropdownButton />', () => {
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(queryByRole('tooltip')).toBeNull();
});
it('should show path if selection exists', () => {
const { getByRole } = render(<GrpcMethodDropdownButton fullPath={'/pkg.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', () => {

View File

@ -1,6 +1,7 @@
import { createBuilder } from '@develohpanda/fluent-builder';
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 { grpcMethodDefinitionSchema } from '../../../../context/grpc/__schemas__';
@ -65,10 +66,10 @@ describe('<GrpcMethodDropdown />', () => {
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 method = builder.path('/service/method').build();
const { getByRole, queryAllByText } = render(
const { findByRole, findByText } = render(
<GrpcMethodDropdown
methods={[method]}
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
fireEvent.click(getByRole('button'));
// Should find two items - a dropdown item and a tooltip with the same text
const [dropdownButton, tooltip] = queryAllByText(method.path);
expect(tooltip).toBeTruthy();
expect(tooltip).toHaveAttribute('role', 'tooltip');
expect(tooltip).toHaveAttribute('aria-hidden', 'true');
fireEvent.click(dropdownButton);
await userEvent.click(dropdownTrigger);
// Select method from the dropdown
const dropdownItem = await findByText(method.path);
await userEvent.click(dropdownItem);
expect(handleChange).toHaveBeenCalledWith(method.path, expect.anything());
});
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 styled from 'styled-components';
import { getGrpcPathSegments, getShortGrpcPath } from '../../../../common/grpc-paths';
import { Tooltip } from '../../tooltip';
const FlexSpaceBetween = styled.span`
width: 100%;
@ -26,15 +27,13 @@ const useLabel = (fullPath?: string) =>
return 'Select Method';
}, [fullPath]);
const buttonProps: ButtonProps = {
className: 'tall wide',
variant: 'text',
size: 'medium',
radius: '0',
};
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}>
<FlexSpaceBetween>
{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 styled from 'styled-components';
@ -10,6 +10,7 @@ import {
} from '../../../../common/grpc-paths';
import type { GrpcMethodDefinition } from '../../../../network/grpc/method';
import { GrpcMethodTag } from '../../tags/grpc-method-tag';
import { Tooltip } from '../../tooltip';
import { GrpcMethodDropdownButton } from './grpc-method-dropdown-button';
interface Props {
@ -31,14 +32,14 @@ export const GrpcMethodDropdown: FunctionComponent<Props> = ({
handleChange,
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]);
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 */}
<DropdownItem onClick={handleChangeProtoFile}>
<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 React, { FC, useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
@ -13,6 +13,7 @@ import { createProject } from '../../redux/modules/project';
import { selectActiveProject, selectProjects } from '../../redux/selectors';
import { showModal } from '../modals';
import ProjectSettingsModal from '../modals/project-settings-modal';
import { Tooltip } from '../tooltip';
import { svgPlacementHack, tooltipIconPlacementHack } from './dropdown-placement-hacks';
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 { useSelector } from 'react-redux';
import styled from 'styled-components';
@ -10,6 +10,7 @@ import { VCS } from '../../../sync/vcs/vcs';
import { useRemoteWorkspaces } from '../../hooks/workspace';
import { selectActiveProject } from '../../redux/selectors';
import { HelpTooltip } from '../help-tooltip';
import { Tooltip } from '../tooltip';
interface Props {
vcs?: VCS | null;

View File

@ -1,5 +1,4 @@
import React, { FC, ReactNode } from 'react';
import { CSSProperties } from 'styled-components';
import { Tooltip } from './tooltip';
@ -7,24 +6,14 @@ interface Props {
children: ReactNode;
position?: string;
className?: string;
style?: CSSProperties;
info?: boolean;
}
export const HelpTooltip: FC<Props> = props => {
const {
children,
className,
style,
info,
} = 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>;
const { children, className, info } = props;
return (
<Tooltip position="top" className={className} message={children}>
<i className={'fa ' + (info ? 'fa-info-circle' : 'fa-question-circle')} />
</Tooltip>
);
};

View File

@ -35,9 +35,4 @@ describe('<BooleanSetting />', () => {
const { getByLabelText } = render(booleanSetting, container);
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 { 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 React from 'react';
import configureMockStore from 'redux-mock-store';
@ -21,7 +22,7 @@ describe('<EnumSetting />', () => {
{ value: UpdateChannel.stable, name: 'Release (Recommended)' },
{ value: UpdateChannel.beta, name: 'Early Access (Beta)' },
];
let container = {};
let renderOptions = {};
const enumSetting = (
<EnumSetting<UpdateChannel>
help={help}
@ -34,21 +35,26 @@ describe('<EnumSetting />', () => {
beforeEach(async () => {
await globalBeforeEach();
const store = mockStore(await reduxStateForTest());
container = { wrapper: withReduxStore(store) };
renderOptions = { wrapper: withReduxStore(store) };
});
it('should render label text', async () => {
const { getByLabelText } = render(enumSetting, container);
const { getByLabelText } = render(enumSetting, renderOptions);
expect(getByLabelText(label)).toBeInTheDocument();
});
it('should render help text', async () => {
const { getByText } = render(enumSetting, container);
expect(getByText(help)).toBeInTheDocument();
const { container, findByRole } = render(enumSetting, renderOptions);
await userEvent.hover(container);
act(async () => {
const helpText = await findByRole(/tooltip/);
expect(helpText).toBeInTheDocument();
});
});
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[1].name)).toBeInTheDocument();
});

View File

@ -1,5 +1,6 @@
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 configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
@ -17,7 +18,7 @@ describe('<TextSetting />', () => {
const label = 'label text';
const placeholder = 'placeholder text';
const help = 'help text';
let container = {};
let renderOptions = {};
const textSetting = (
<TextSetting
disabled
@ -31,26 +32,30 @@ describe('<TextSetting />', () => {
beforeEach(async () => {
await globalBeforeEach();
const store = mockStore(await reduxStateForTest());
container = { wrapper: withReduxStore(store) };
renderOptions = { wrapper: withReduxStore(store) };
});
it('should render label text', async () => {
const { getByLabelText } = render(textSetting, container);
const { getByLabelText } = render(textSetting, renderOptions);
expect(getByLabelText(label)).toBeInTheDocument();
});
it('should render placeholder text', async () => {
const { getByPlaceholderText } = render(textSetting, container);
const { getByPlaceholderText } = render(textSetting, renderOptions);
expect(getByPlaceholderText(placeholder)).toBeInTheDocument();
});
it('should render help text', async () => {
const { getByText } = render(textSetting, container);
expect(getByText(help)).toBeInTheDocument();
const { container, findByRole } = render(textSetting, renderOptions);
userEvent.hover(container);
act(async () => {
const helpText = await findByRole(/tooltip/);
expect(helpText).toBeInTheDocument();
});
});
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');
expect(input).toHaveAttribute('disabled');
});

View File

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

View File

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

View File

@ -1,209 +1,72 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import classnames from 'classnames';
import React, { PureComponent, ReactNode } from 'react';
import ReactDOM from 'react-dom';
import { AUTOBIND_CFG } from '../../common/constants';
import React, {
ReactNode,
} from 'react';
import {
mergeProps,
OverlayContainer,
useOverlayPosition,
useTooltip,
useTooltipTrigger,
} from 'react-aria';
import { useTooltipTriggerState } from 'react-stately';
interface Props {
children: ReactNode;
message: ReactNode;
position?: 'bottom' | 'top' | 'right' | 'left';
className?: string;
delay?: number;
selectable?: boolean;
delay?: number;
wide?: boolean;
onClick?: () => void;
}
interface State {
left: number | null;
top: number | null;
bottom: number | null;
right: number | null;
maxWidth: number | null;
maxHeight: number | null;
visible: boolean;
}
export const Tooltip = (props: Props) => {
const { children, message, className, wide, selectable, delay = 400, position } = props;
const triggerRef = React.useRef(null);
const overlayRef = React.useRef(null);
@autoBindMethodsForReact(AUTOBIND_CFG)
export class Tooltip extends PureComponent<Props, State> {
_showTimeout: NodeJS.Timeout | null = null;
_hideTimeout: NodeJS.Timeout | null = null;
_tooltip: HTMLDivElement | null = null;
_bubble: HTMLDivElement | null = null;
_id = String(Math.random());
const state = useTooltipTriggerState({ delay });
const trigger = useTooltipTrigger(props, state, triggerRef);
const tooltip = useTooltip(trigger.tooltipProps, state);
state: State = {
left: null,
top: null,
bottom: null,
right: null,
maxWidth: null,
maxHeight: null,
visible: false,
};
const { overlayProps: positionProps } = useOverlayPosition({
targetRef: triggerRef,
overlayRef,
placement: position,
offset: 5,
isOpen: state.isOpen,
});
_setTooltipRef(tooltip: HTMLDivElement) {
this._tooltip = tooltip;
}
const tooltipClasses = classnames(className, 'tooltip');
const bubbleClasses = classnames('tooltip__bubble theme--tooltip', {
'tooltip__bubble--visible': state.isOpen,
'tooltip__bubble--wide': wide,
selectable,
});
_setBubbleRef(bubble: HTMLDivElement) {
this._bubble = bubble;
}
_handleStopClick(event: React.MouseEvent<HTMLDivElement>) {
event.stopPropagation();
}
_handleMouseEnter() {
if (this._showTimeout !== null) {
clearTimeout(this._showTimeout);
}
if (this._hideTimeout !== null) {
clearTimeout(this._hideTimeout);
}
this._showTimeout = setTimeout((): void => {
const tooltip = this._tooltip;
const bubble = this._bubble;
if (!tooltip) {
return;
}
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>
);
}
}
return (
<div
ref={triggerRef}
className={tooltipClasses}
style={{ position: 'relative' }}
{...trigger.triggerProps}
onClick={props.onClick}
>
{children}
{state.isOpen && (
<OverlayContainer>
<div
ref={overlayRef}
onClick={e => e.stopPropagation()}
{...mergeProps(tooltip.tooltipProps, positionProps)}
className={bubbleClasses}
>
{message}
</div>
</OverlayContainer>
)}
</div>
);
};