toolbar: work around tooltip bugs

This works around several bugs where tooltips don't close when expected.

Fixes: https://github.com/pybricks/pybricks-code/issues/275
This commit is contained in:
David Lechner
2021-12-28 18:55:20 -06:00
committed by David Lechner
parent 5825f429ee
commit fd09783a45
5 changed files with 179 additions and 13 deletions
+7
View File
@@ -2,6 +2,13 @@
# Changelog
## [Unreleased]
### Fixed
- Fix tooltips not closing when expected ([pybricks-code#275]).
[pybricks-code#275]: https://github.com/pybricks/pybricks-code/issues/275
## [1.2.0-beta.1] - 2021-12-27
### Added
+14 -6
View File
@@ -13,6 +13,7 @@ import { Tooltip2 } from '@blueprintjs/popover2';
import { useI18n } from '@shopify/react-i18n';
import React, { useEffect, useMemo, useState } from 'react';
import { tooltipDelay } from '../app/constants';
import { closeTooltip2, useTooltip2MonkeyPatch } from '../utils/monkey-patch';
import { TooltipId } from './i18n';
import en from './i18n.en.json';
@@ -90,22 +91,29 @@ const ActionButton: React.FC<ActionButtonProps> = (props) => {
useHotkeys(hotkeys);
const tooltipRef = useTooltip2MonkeyPatch();
return (
<Tooltip2
ref={tooltipRef}
content={tooltipText}
placement="bottom"
hoverOpenDelay={tooltipDelay}
renderTarget={({
ref: tooltipRef,
ref: tooltipTargetRef,
isOpen: _tooltipIsOpen,
...tooltipProps
...tooltipTargetProps
}) => (
<Button
elementRef={tooltipRef as IRef<HTMLButtonElement>}
{...tooltipProps}
elementRef={tooltipTargetRef as IRef<HTMLButtonElement>}
{...tooltipTargetProps}
intent={Intent.PRIMARY}
// prevent focus from mouse click
onMouseDown={(e) => e.preventDefault()}
onMouseDown={(e) => {
// prevent focus from mouse click
e.preventDefault();
// close/prevent tooltip
closeTooltip2(tooltipRef);
}}
onClick={() => props.onAction()}
disabled={props.enabled === false}
style={
+14 -7
View File
@@ -7,11 +7,11 @@ import { useI18n } from '@shopify/react-i18n';
import React, { useEffect, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { tooltipDelay } from '../app/constants';
import { closeTooltip2, useTooltip2MonkeyPatch } from '../utils/monkey-patch';
import { TooltipId } from './i18n';
import en from './i18n.en.json';
const smallScreenThreshold = 700;
export interface OpenFileButtonProps {
/** A unique id for each instance. */
readonly id: string;
@@ -91,8 +91,11 @@ const OpenFileButton: React.FC<OpenFileButtonProps> = (props) => {
},
});
const tooltipRef = useTooltip2MonkeyPatch();
return (
<Tooltip2
ref={tooltipRef}
content={i18n.translate(
props.tooltip,
props.tooltip === TooltipId.FlashProgress
@@ -107,23 +110,27 @@ const OpenFileButton: React.FC<OpenFileButtonProps> = (props) => {
placement="bottom"
hoverOpenDelay={tooltipDelay}
renderTarget={({
ref: tooltipRef,
ref: tooltipTargetRef,
isOpen: _tooltipIsOpen,
...tooltipProps
...tooltipTargetProps
}) => (
<Button
{...getRootProps({
refKey: 'elementRef',
elementRef: tooltipRef as IRef<HTMLButtonElement>,
...tooltipProps,
elementRef: tooltipTargetRef as IRef<HTMLButtonElement>,
...tooltipTargetProps,
intent: Intent.PRIMARY,
disabled: props.enabled === false,
style:
props.enabled === false
? { pointerEvents: 'none' }
: undefined,
// prevent focus from mouse click
onMouseDown: (e) => e.preventDefault(),
onMouseDown: (e) => {
// prevent focus from mouse click
e.preventDefault();
// close/prevent tooltip
closeTooltip2(tooltipRef);
},
onClick: props.onClick,
})}
>
+76
View File
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
import { Button, IRef } from '@blueprintjs/core';
import { Tooltip2 } from '@blueprintjs/popover2';
import {
fireEvent,
render,
screen,
waitFor,
waitForElementToBeRemoved,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { useTooltip2MonkeyPatch } from './monkey-patch';
const testTooltipText = 'Test tooltip.';
const testButtonId = 'test-button';
type TestComponentProps = { monkeyPatch: boolean };
const TestComponent: React.FC<TestComponentProps> = (props) => {
const tooltipRef = props.monkeyPatch ? useTooltip2MonkeyPatch() : undefined;
return (
<Tooltip2
ref={tooltipRef}
content={testTooltipText}
renderTarget={({
ref: tooltipTargetRef,
isOpen: _tooltipIsOpen,
...tooltipTargetProps
}) => (
<Button
data-testid={testButtonId}
elementRef={tooltipTargetRef as IRef<HTMLButtonElement>}
{...tooltipTargetProps}
>
Press Me!
</Button>
)}
/>
);
};
it.each([false, true])(
'should work around https://github.com/palantir/blueprint/issues/4503',
async (monkeyPatch) => {
render(<TestComponent monkeyPatch={monkeyPatch} />);
expect(screen.queryByText(testTooltipText)).not.toBeInTheDocument();
// pressing the tab key should focus the button and open the tooltip
userEvent.tab();
await waitFor(() => {
expect(screen.getByText(testTooltipText)).toBeInTheDocument();
});
// a blur event with renderTarget=null should trigger the bug and workaround
fireEvent.blur(screen.getByTestId(testButtonId));
if (monkeyPatch) {
// if the patch was applied, there should not be any error
await waitForElementToBeRemoved(screen.getByText(testTooltipText));
} else {
// if the patch was not applied, the bug should be triggered
try {
await waitForElementToBeRemoved(screen.getByText(testTooltipText));
fail('bug was not triggered');
} catch (err) {
// error was expected
}
}
},
);
+68
View File
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
import { Tooltip2 } from '@blueprintjs/popover2';
import React, { useEffect, useRef } from 'react';
/** Hack to access private members of Popover2. */
interface Popover2Private {
handleTargetBlur: (e: React.FocusEvent<HTMLElement>) => void;
handleMouseLeave: (e: React.MouseEvent<HTMLElement>) => void;
}
/** Hack to access private members of Tooltip2. */
interface Tooltip2Private {
popover: Popover2Private;
}
/**
* Monkey patch Popover2 blur event handler to make tooltips close when
* focus is lost.
*
* https://github.com/palantir/blueprint/issues/4503
*
* @returns A React ref to be attached to the Tooltip2 component.
*/
export function useTooltip2MonkeyPatch<T>(): React.RefObject<Tooltip2<T>> {
const tooltipRef = useRef<Tooltip2<T>>(null);
useEffect(() => {
if (!tooltipRef.current) {
return;
}
const tooltip = tooltipRef.current as unknown as Tooltip2Private;
const oldHandleTargetBlur = tooltip.popover.handleTargetBlur;
tooltip.popover.handleTargetBlur = (e) => {
if (e.relatedTarget) {
oldHandleTargetBlur(e);
} else {
closeTooltip2(tooltipRef);
}
};
return () => {
tooltip.popover.handleTargetBlur = oldHandleTargetBlur;
};
}, [tooltipRef]);
return tooltipRef;
}
/**
* Hack to access private members of Tooltip2 to programmatically close the tooltip.
* @param tooltipRef The reference returned from useTooltip2MonkeyPatch()
*/
export function closeTooltip2<T>(tooltipRef: React.RefObject<Tooltip2<T>>): void {
if (!tooltipRef.current) {
return;
}
const tooltip = tooltipRef.current as unknown as Tooltip2Private;
// Currently, the event arg is not used so it should be safe to pass undefined.
tooltip.popover.handleMouseLeave(
undefined as unknown as React.MouseEvent<HTMLElement>,
);
}