Skip to content

Commit

Permalink
Merge pull request #1034 from lumapps/fix/DSW-47-tooltip-hoverable
Browse files Browse the repository at this point in the history
fix(tooltip): fixed closing when mouse is hovering the tooltip text
  • Loading branch information
gcornut authored Dec 15, 2023
2 parents 8828124 + 0993434 commit 8150af1
Show file tree
Hide file tree
Showing 7 changed files with 105 additions and 50 deletions.
13 changes: 8 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Tooltip: fixed tooltip closing when mouse is hovering the tooltip text.
- Tooltip: fixed close on Escape key pressed (not only when anchor is focused).

## [3.6.0][] - 2023-12-05

### Changed
Expand All @@ -24,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Table row: selected states color update + documentation update
- Table row: selected states color update + documentation update
- `@lumx/core`: remove default line-height on custom title typography.

## [3.5.4][] - 2023-10-17
Expand Down Expand Up @@ -1840,9 +1845,7 @@ _Failed released_
[unreleased]: https://github.com/lumapps/design-system/compare/v3.5.3...HEAD
[3.5.3]: https://github.com/lumapps/design-system/compare/v3.5.2...v3.5.3
[3.5.2]: https://github.com/lumapps/design-system/tree/v3.5.2


[Unreleased]: https://github.com/lumapps/design-system/compare/v3.6.0...HEAD
[unreleased]: https://github.com/lumapps/design-system/compare/v3.6.0...HEAD
[3.6.0]: https://github.com/lumapps/design-system/compare/v3.5.5...v3.6.0
[3.5.5]: https://github.com/lumapps/design-system/compare/v3.5.4...v3.5.5
[3.5.4]: https://github.com/lumapps/design-system/tree/v3.5.4
[3.5.4]: https://github.com/lumapps/design-system/tree/v3.5.4
2 changes: 1 addition & 1 deletion packages/lumx-core/src/js/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const SLIDESHOW_TRANSITION_DURATION = 5000;
*/
export const TOOLTIP_HOVER_DELAY = {
open: 500,
close: 0,
close: 500,
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,13 @@ export const TooltipWithDropdown = (props: any) => {
const [isOpen, setOpen] = useState(false);
return (
<>
<Tooltip label={!isOpen && 'Tooltip'} {...props}>
<br />
<Tooltip label={!isOpen && 'Tooltip'} {...props} placement="top">
<Button ref={setButton} onClick={() => setOpen((o) => !o)}>
Anchor
</Button>
</Tooltip>
<Dropdown anchorRef={{ current: button }} isOpen={isOpen}>
<Dropdown anchorRef={{ current: button }} isOpen={isOpen} onClose={() => setOpen(false)}>
Dropdown
</Dropdown>
</>
Expand Down
32 changes: 32 additions & 0 deletions packages/lumx-react/src/components/tooltip/Tooltip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,38 @@ describe(`<${Tooltip.displayName}>`, () => {
});
});

it('should activate on hover anchor and then tooltip', async () => {
let { tooltip } = await setup({
label: 'Tooltip label',
children: <Button>Anchor</Button>,
forceOpen: false,
});

expect(tooltip).not.toBeInTheDocument();

// Hover anchor button
const button = getByClassName(document.body, Button.className as string);
await userEvent.hover(button);

// Tooltip opened
tooltip = await findByClassName(document.body, CLASSNAME);
expect(tooltip).toBeInTheDocument();
expect(button).toHaveAttribute('aria-describedby', tooltip?.id);

// Hover tooltip
await userEvent.hover(tooltip);
expect(tooltip).toBeInTheDocument();
expect(button).toHaveAttribute('aria-describedby', tooltip?.id);

// Un-hover tooltip
userEvent.unhover(tooltip);
await waitFor(() => {
expect(button).not.toHaveFocus();
// Tooltip closed
expect(tooltip).not.toBeInTheDocument();
});
});

it('should activate on anchor focus', async () => {
let { tooltip } = await setup({
label: 'Tooltip label',
Expand Down
7 changes: 4 additions & 3 deletions packages/lumx-react/src/components/tooltip/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,17 @@ export const Tooltip: Comp<TooltipProps, HTMLDivElement> = forwardRef((props, re
});

const position = attributes?.popper?.['data-popper-placement'] ?? placement;
const isOpen = useTooltipOpen(delay, anchorElement) || forceOpen;
const wrappedChildren = useInjectTooltipRef(children, setAnchorElement, isOpen as boolean, id);
const { isOpen: isActivated, onPopperMount } = useTooltipOpen(delay, anchorElement);
const isOpen = isActivated || forceOpen;
const wrappedChildren = useInjectTooltipRef(children, setAnchorElement, isOpen, id);

return (
<>
{wrappedChildren}
{isOpen &&
createPortal(
<div
ref={mergeRefs(ref, setPopperElement)}
ref={mergeRefs(ref, setPopperElement, onPopperMount)}
{...forwardedProps}
id={id}
role="tooltip"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { mergeRefs } from '@lumx/react/utils/mergeRefs';
export const useInjectTooltipRef = (
children: ReactNode,
setAnchorElement: (e: HTMLDivElement) => void,
isOpen: boolean,
isOpen: boolean | undefined,
id: string,
): ReactNode => {
return useMemo(() => {
Expand Down
94 changes: 56 additions & 38 deletions packages/lumx-react/src/components/tooltip/useTooltipOpen.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { onEscapePressed } from '@lumx/react/utils/event';
import { useEffect, useState } from 'react';
import { MutableRefObject, useEffect, useRef, useState } from 'react';
import { browserDoesNotSupportHover } from '@lumx/react/utils/browserDoesNotSupportHover';
import { TOOLTIP_HOVER_DELAY, TOOLTIP_LONG_PRESS_DELAY } from '@lumx/react/constants';
import { useCallbackOnEscape } from '@lumx/react/hooks/useCallbackOnEscape';

/**
* Hook controlling tooltip visibility using mouse hover the anchor and delay.
Expand All @@ -10,9 +10,15 @@ import { TOOLTIP_HOVER_DELAY, TOOLTIP_LONG_PRESS_DELAY } from '@lumx/react/const
* @param anchorElement Tooltip anchor element.
* @return whether or not to show the tooltip.
*/
export function useTooltipOpen(delay: number | undefined, anchorElement: HTMLElement | null): boolean {
export function useTooltipOpen(delay: number | undefined, anchorElement: HTMLElement | null) {
const [isOpen, setIsOpen] = useState(false);

const onPopperMount = useRef<any>(null) as MutableRefObject<(elem: HTMLElement | null) => void>;

// Global close on escape
const [closeCallback, setCloseCallback] = useState<undefined | (() => void)>(undefined);
useCallbackOnEscape(closeCallback);

useEffect(() => {
if (!anchorElement) {
return undefined;
Expand Down Expand Up @@ -45,53 +51,65 @@ export function useTooltipOpen(delay: number | undefined, anchorElement: HTMLEle
};

// Close or cancel opening of tooltip
const close = (overrideDelay = closeDelay) => {
const getClose = (overrideDelay = closeDelay) => {
if (!shouldOpen && !timer) return;
shouldOpen = false;
deferUpdate(overrideDelay);
};
const closeImmediately = () => close(0);

/**
* Handle touchend event
* If `touchend` comes before the open delay => cancel tooltip (close immediate).
* Else if `touchend` comes after the open delay => tooltip takes priority, the anchor's default touch end event is prevented.
*/
const touchEnd = (evt: Event) => {
if (!openStartTime) return;
if (Date.now() - openStartTime >= openDelay) {
// Tooltip take priority, event prevented.
evt.stopPropagation();
evt.preventDefault();
anchorElement.focus();
// Close with delay.
close();
} else {
// Close immediately.
closeImmediately();
}
};
const close = () => getClose(closeDelay);
const closeImmediately = () => getClose(0);
setCloseCallback(() => closeImmediately);

// Adapt event to browsers with or without `hover` support.
const events: Array<[Node, Event['type'], any]> = hoverNotSupported
? [
[anchorElement, hasTouch ? 'touchstart' : 'mousedown', open],
[anchorElement, hasTouch ? 'touchend' : 'mouseup', touchEnd],
]
: [
[anchorElement, 'mouseenter', open],
[anchorElement, 'mouseleave', close],
[anchorElement, 'mouseup', closeImmediately],
];
const events: Array<[Node, Event['type'], any]> = [];
if (hoverNotSupported) {
/**
* Handle touchend event
* If end comes before the open delay => cancel tooltip (close immediate).
* Else if end comes after the open delay => tooltip takes priority, the anchor's default touch end event is prevented.
*/
const longPressEnd = (evt: Event) => {
if (!openStartTime) return;
if (Date.now() - openStartTime >= openDelay) {
// Tooltip take priority, event prevented.
evt.stopPropagation();
evt.preventDefault();
anchorElement.focus();
// Close with delay.
close();
} else {
// Close immediately.
closeImmediately();
}
};

events.push(
[anchorElement, hasTouch ? 'touchstart' : 'mousedown', open],
[anchorElement, hasTouch ? 'touchend' : 'mouseup', longPressEnd],
);
} else {
events.push(
[anchorElement, 'mouseenter', open],
[anchorElement, 'mouseleave', close],
[anchorElement, 'mouseup', closeImmediately],
);

onPopperMount.current = (popperElement: HTMLElement | null) => {
if (!popperElement) return;
// Popper element hover
popperElement.addEventListener('mouseenter', open);
popperElement.addEventListener('mouseleave', close);
// Add to event list to remove on unmount
events.push([popperElement, 'mouseenter', open], [popperElement, 'mouseleave', close]);
};
}

// Events always applied no matter the browser:.
events.push(
// Open on focus.
[anchorElement, 'focusin', open],
// Close on lost focus.
[anchorElement, 'focusout', closeImmediately],
// Close on ESC keydown
[anchorElement, 'keydown', onEscapePressed(closeImmediately)],
);

// Attach events
Expand All @@ -109,5 +127,5 @@ export function useTooltipOpen(delay: number | undefined, anchorElement: HTMLEle
};
}, [anchorElement, delay]);

return isOpen;
return { isOpen, onPopperMount: onPopperMount.current };
}

0 comments on commit 8150af1

Please sign in to comment.