Skip to content

Commit cf9d2f5

Browse files
r-farkhutdinovRuslan Farkhutdinov
andauthored
Popover: Support WCAG - Hoverable (#32934)
Co-authored-by: Ruslan Farkhutdinov <ruslan.farkhutdinov@devexpress.com>
1 parent 63f818a commit cf9d2f5

4 files changed

Lines changed: 413 additions & 54 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import React from 'react';
2+
import type { Meta, StoryObj } from '@storybook/react-webpack5';
3+
import { Tooltip } from 'devextreme-react/tooltip';
4+
5+
const meta: Meta<typeof Tooltip> = {
6+
title: 'Components/Tooltip',
7+
component: Tooltip,
8+
parameters: {
9+
layout: 'padded',
10+
},
11+
};
12+
13+
export default meta;
14+
15+
type Story = StoryObj<typeof Tooltip>;
16+
17+
const HoverableExample: Story['render'] = (args) => (
18+
<div style={{ padding: '80px 40px' }}>
19+
<p style={{ marginBottom: 16 }}>
20+
Hover the button to show the tooltip, then move the pointer onto
21+
the tooltip content — it must stay open.
22+
</p>
23+
<button id="tooltip-target">
24+
Hover me
25+
</button>
26+
<Tooltip
27+
{...args}
28+
target="#tooltip-target"
29+
showEvent="mouseenter"
30+
hideEvent="mouseleave"
31+
>
32+
<div style={{ padding: '8px 12px' }}>
33+
<strong>WCAG — Hoverable</strong>
34+
<p style={{ margin: '6px 0 0' }}>
35+
Move your pointer here from the button.
36+
<br />
37+
The tooltip stays open as long as the pointer
38+
<br />
39+
is over the target <em>or</em> this content.
40+
</p>
41+
</div>
42+
</Tooltip>
43+
</div>
44+
);
45+
46+
export const Hoverable: Story = {
47+
args: {
48+
position: 'bottom',
49+
},
50+
argTypes: {
51+
position: {
52+
control: 'select',
53+
options: ['top', 'bottom', 'left', 'right'],
54+
},
55+
},
56+
render: HoverableExample,
57+
};
58+
59+
const HoverableWithDelayExample: Story['render'] = (args) => (
60+
<div style={{ padding: '80px 40px' }}>
61+
<p style={{ marginBottom: 16 }}>
62+
Show delay: <strong>500 ms</strong> — Hide delay: <strong>300 ms</strong>.
63+
Moving onto the tooltip content cancels the hide timeout.
64+
</p>
65+
<button id="tooltip-target-delay">
66+
Hover me (with delay)
67+
</button>
68+
<Tooltip
69+
{...args}
70+
target="#tooltip-target-delay"
71+
showEvent={{ name: 'mouseenter', delay: 500 }}
72+
hideEvent={{ name: 'mouseleave', delay: 300 }}
73+
>
74+
<div style={{ padding: '8px 12px' }}>
75+
Move here to cancel the hide timeout.
76+
</div>
77+
</Tooltip>
78+
</div>
79+
);
80+
81+
export const HoverableWithDelay: Story = {
82+
args: {
83+
position: 'bottom',
84+
},
85+
argTypes: {
86+
position: {
87+
control: 'select',
88+
options: ['top', 'bottom', 'left', 'right'],
89+
},
90+
},
91+
render: HoverableWithDelayExample,
92+
};

packages/devextreme/js/__internal/ui/popover/m_popover.ts

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,24 @@ const POSITION_FLIP_MAP = {
4747
center: 'center',
4848
};
4949

50+
const HOVER_EVENT_PAIRS: Record<string, string> = {
51+
// eslint-disable-next-line spellcheck/spell-checker
52+
mouseleave: 'mouseenter',
53+
// eslint-disable-next-line spellcheck/spell-checker
54+
mouseout: 'mouseover',
55+
// eslint-disable-next-line spellcheck/spell-checker
56+
pointerleave: 'pointerenter',
57+
// eslint-disable-next-line spellcheck/spell-checker
58+
dxhoverend: 'dxhoverstart',
59+
};
60+
61+
const HOVER_HIDE_EVENTS = Object.keys(HOVER_EVENT_PAIRS);
62+
const HOVER_HIDE_DELAY = 50;
63+
5064
const ESC_KEY_NAME = 'escape';
5165

5266
type PopoverTarget = string | dxElementWrapper | Element | undefined;
67+
type PopoverEventOption = 'showEvent' | 'hideEvent';
5368

5469
export interface PopoverProperties extends Omit<Properties,
5570
'onTitleRendered' | 'onHidden' | 'onHiding' | 'onShowing' | 'onShown'
@@ -204,6 +219,8 @@ class Popover<
204219
super._render.apply(this, arguments);
205220
this._detachEvents(this.option('target'));
206221
this._attachEvents();
222+
this._detachHoverableOverlay();
223+
this._attachHoverableOverlay();
207224
}
208225

209226
_detachEvents(target): void {
@@ -216,11 +233,87 @@ class Popover<
216233
this._attachEvent('hide');
217234
}
218235

219-
_createEventHandler(name) {
236+
_scheduleHoverHide(): void {
237+
this._clearEventsTimeouts();
238+
const hideDelay = this._getEventDelay('hideEvent');
239+
240+
if (hideDelay) {
241+
// eslint-disable-next-line no-restricted-globals
242+
this._timeouts.hide = setTimeout(() => {
243+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
244+
this.hide();
245+
}, hideDelay);
246+
} else {
247+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
248+
this.hide();
249+
}
250+
}
251+
252+
// eslint-disable-next-line class-methods-use-this
253+
_isHoverHideEventName(eventName: string): boolean {
254+
return HOVER_HIDE_EVENTS.some((hoverEvent) => eventName.split(/\s+/).includes(hoverEvent));
255+
}
256+
257+
_attachHoverableOverlay(): void {
258+
const hideEventName = this._getEventName('hideEvent');
259+
if (!hideEventName || !this._isHoverHideEventName(hideEventName)) {
260+
return;
261+
}
262+
const $overlayContent = this.$overlayContent();
263+
if (!$overlayContent.length) {
264+
return;
265+
}
266+
267+
const namespace = `${this.NAME as string}Hoverable`;
268+
const activeHideEvents = hideEventName.split(/\s+/).filter((eventName: string) => eventName in HOVER_EVENT_PAIRS);
269+
270+
const hoverInEventName = activeHideEvents
271+
.map((eventName: string) => addNamespace(HOVER_EVENT_PAIRS[eventName], namespace))
272+
.join(' ');
273+
const hoverOutEventName = activeHideEvents
274+
.map((eventName: string) => addNamespace(eventName, namespace))
275+
.join(' ');
276+
277+
eventsEngine.off($overlayContent, hoverInEventName);
278+
eventsEngine.on($overlayContent, hoverInEventName, () => {
279+
this._clearEventsTimeouts();
280+
});
281+
282+
eventsEngine.off($overlayContent, hoverOutEventName);
283+
eventsEngine.on($overlayContent, hoverOutEventName, (e: PointerEvent | MouseEvent) => {
284+
const { target } = this.option();
285+
const { relatedTarget } = e;
286+
287+
if (target && relatedTarget instanceof Element && $(relatedTarget).closest(target).length) {
288+
return;
289+
}
290+
291+
this._scheduleHoverHide();
292+
});
293+
}
294+
295+
_detachHoverableOverlay(): void {
296+
const $overlayContent = this.$overlayContent();
297+
if (!$overlayContent.length) {
298+
return;
299+
}
300+
const namespace = `${this.NAME as string}Hoverable`;
301+
const allEventNames = [
302+
...Object.keys(HOVER_EVENT_PAIRS),
303+
...Object.values(HOVER_EVENT_PAIRS),
304+
].map((e) => addNamespace(e, namespace)).join(' ');
305+
eventsEngine.off($overlayContent, allEventNames);
306+
}
307+
308+
_createEventHandler(name: string) {
220309
const action = this._createAction(() => {
221-
const delay = this._getEventDelay(`${name}Event`);
310+
const explicitDelay = this._getEventDelay(`${name}Event` as PopoverEventOption);
222311
this._clearEventsTimeouts();
223312

313+
const hideEventName = name === 'hide' ? this._getEventName('hideEvent') : null;
314+
const isHoverHide = hideEventName && this._isHoverHideEventName(hideEventName);
315+
const delay = explicitDelay ?? (isHoverHide ? HOVER_HIDE_DELAY : 0);
316+
224317
if (delay) {
225318
this._timeouts[name] = setTimeout(() => {
226319
this[name]();
@@ -298,10 +391,10 @@ class Popover<
298391
return this._getEventNameByOption(optionValue);
299392
}
300393

301-
_getEventDelay(optionName) {
302-
const optionValue = this.option(optionName);
303-
// @ts-expect-error
304-
return isObject(optionValue) && optionValue.delay;
394+
_getEventDelay(optionName: PopoverEventOption): number | undefined {
395+
const { [optionName]: optionValue } = this.option();
396+
397+
return isObject(optionValue) ? (optionValue.delay) : undefined;
305398
}
306399

307400
_renderArrow(): void {
@@ -566,6 +659,7 @@ class Popover<
566659
_clean(): void {
567660
this._detachEscapeKeyHandler();
568661
this._detachEvents(this.option('target'));
662+
this._detachHoverableOverlay();
569663
// @ts-expect-error ts-error
570664
super._clean.apply(this, arguments);
571665
}
@@ -603,6 +697,11 @@ class Popover<
603697
const { target } = this.option();
604698
this._detachEvent(target, eventName, event);
605699
this._attachEvent(eventName);
700+
701+
if (name === 'hideEvent') {
702+
this._detachHoverableOverlay();
703+
this._attachHoverableOverlay();
704+
}
606705
break;
607706
}
608707
case 'visible':

0 commit comments

Comments
 (0)