Skip to content

Commit 68fd721

Browse files
author
Ruslan Farkhutdinov
committed
Popover: Support WCAG - Hoverable
1 parent 80deee7 commit 68fd721

4 files changed

Lines changed: 333 additions & 50 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: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ const POSITION_FLIP_MAP = {
4747
center: 'center',
4848
};
4949

50+
const HOVER_HIDE_EVENTS = ['mouseleave', 'mouseout'];
51+
const HOVER_HIDE_DELAY = 50;
52+
5053
const ESC_KEY_NAME = 'escape';
5154

5255
type PopoverTarget = string | dxElementWrapper | Element | undefined;
@@ -204,6 +207,8 @@ class Popover<
204207
super._render.apply(this, arguments);
205208
this._detachEvents(this.option('target'));
206209
this._attachEvents();
210+
this._detachHoverableOverlay();
211+
this._attachHoverableOverlay();
207212
}
208213

209214
_detachEvents(target): void {
@@ -216,11 +221,63 @@ class Popover<
216221
this._attachEvent('hide');
217222
}
218223

219-
_createEventHandler(name) {
224+
// eslint-disable-next-line class-methods-use-this
225+
_isHoverHideEventName(eventName: string): boolean {
226+
return HOVER_HIDE_EVENTS.some((hoverEvent) => eventName.split(/\s+/).includes(hoverEvent));
227+
}
228+
229+
_attachHoverableOverlay(): void {
230+
const hideEventName = this._getEventName('hideEvent');
231+
if (!hideEventName || !this._isHoverHideEventName(hideEventName)) {
232+
return;
233+
}
234+
const $overlayContent = this.$overlayContent();
235+
if (!$overlayContent.length) {
236+
return;
237+
}
238+
239+
const namespace = `${this.NAME as string}Hoverable`;
240+
const hoverInEventName = addNamespace('mouseenter', namespace);
241+
const hoverOutEventName = addNamespace('mouseleave', namespace);
242+
243+
eventsEngine.off($overlayContent, hoverInEventName);
244+
eventsEngine.on($overlayContent, hoverInEventName, () => {
245+
this._clearEventsTimeouts();
246+
});
247+
248+
eventsEngine.off($overlayContent, hoverOutEventName);
249+
eventsEngine.on($overlayContent, hoverOutEventName, (e) => {
250+
const { target } = this.option();
251+
252+
if (target && $(e.relatedTarget).closest(target).length) {
253+
return;
254+
}
255+
this._clearEventsTimeouts();
256+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
257+
this.hide();
258+
});
259+
}
260+
261+
_detachHoverableOverlay(): void {
262+
const $overlayContent = this.$overlayContent();
263+
if (!$overlayContent.length) {
264+
return;
265+
}
266+
const namespace = `${this.NAME as string}Hoverable`;
267+
eventsEngine.off($overlayContent, addNamespace('mouseenter', namespace));
268+
eventsEngine.off($overlayContent, addNamespace('mouseleave', namespace));
269+
}
270+
271+
_createEventHandler(name: string) {
220272
const action = this._createAction(() => {
221-
const delay = this._getEventDelay(`${name}Event`);
273+
const explicitDelay = this._getEventDelay(`${name}Event`);
222274
this._clearEventsTimeouts();
223275

276+
const hideEventName = name === 'hide' ? this._getEventName('hideEvent') : null;
277+
const isHoverHide = hideEventName && this._isHoverHideEventName(hideEventName);
278+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
279+
const delay = explicitDelay || (isHoverHide ? HOVER_HIDE_DELAY : 0);
280+
224281
if (delay) {
225282
this._timeouts[name] = setTimeout(() => {
226283
this[name]();
@@ -566,6 +623,7 @@ class Popover<
566623
_clean(): void {
567624
this._detachEscapeKeyHandler();
568625
this._detachEvents(this.option('target'));
626+
this._detachHoverableOverlay();
569627
// @ts-expect-error ts-error
570628
super._clean.apply(this, arguments);
571629
}
@@ -603,6 +661,11 @@ class Popover<
603661
const { target } = this.option();
604662
this._detachEvent(target, eventName, event);
605663
this._attachEvent(eventName);
664+
665+
if (name === 'hideEvent') {
666+
this._detachHoverableOverlay();
667+
this._attachHoverableOverlay();
668+
}
606669
break;
607670
}
608671
case 'visible':

packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js

Lines changed: 117 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2345,62 +2345,141 @@ QUnit.module('accessibility', {
23452345
assert.strictEqual($overlay.attr('role'), 'dialog');
23462346
});
23472347

2348-
QUnit.test('should hide visible popover on esc press', function(assert) {
2349-
const popover = new Popover($('#what'), {
2350-
target: '#where',
2351-
visible: true,
2352-
});
2353-
const $target = $('#where').attr('tabindex', 0);
2354-
const keyboard = keyboardMock($target);
2348+
QUnit.module('WCAG - dismissible', () => {
2349+
QUnit.test('should hide visible popover on esc press', function(assert) {
2350+
const popover = new Popover($('#what'), {
2351+
target: '#where',
2352+
visible: true,
2353+
});
2354+
const $target = $('#where').attr('tabindex', 0);
2355+
const keyboard = keyboardMock($target);
23552356

2356-
keyboard.keyDown('esc');
2357+
keyboard.keyDown('esc');
23572358

2358-
assert.strictEqual(popover.option('visible'), false, 'popover is hidden');
2359-
});
2359+
assert.strictEqual(popover.option('visible'), false, 'popover is hidden');
2360+
});
23602361

2361-
QUnit.test('should hide only topmost popover on esc press', function(assert) {
2362-
const $markup = $('<div id="popover1"></div>' +
2362+
QUnit.test('should hide only topmost popover on esc press', function(assert) {
2363+
const $markup = $('<div id="popover1"></div>' +
23632364
'<div id="popover2"></div>' +
23642365
'<div id="target1" tabindex="0"></div>' +
23652366
'<div id="target2" tabindex="0"></div>')
2366-
.appendTo('body');
2367+
.appendTo('body');
23672368

23682369

2369-
const bottomPopover = new Popover($('#popover1'), {
2370-
target: '#target1',
2371-
visible: true,
2372-
});
2373-
const topPopover = new Popover($('#popover2'), {
2374-
target: '#target2',
2375-
visible: true,
2370+
const bottomPopover = new Popover($('#popover1'), {
2371+
target: '#target1',
2372+
visible: true,
2373+
});
2374+
const topPopover = new Popover($('#popover2'), {
2375+
target: '#target2',
2376+
visible: true,
2377+
});
2378+
2379+
const keyboard = keyboardMock($('#target2'));
2380+
2381+
keyboard.keyDown('esc');
2382+
2383+
assert.strictEqual(topPopover.option('visible'), false, 'top popover is hidden');
2384+
assert.strictEqual(bottomPopover.option('visible'), true, 'bottom popover is still visible');
2385+
2386+
$markup.remove();
23762387
});
23772388

2378-
const keyboard = keyboardMock($('#target2'));
2389+
QUnit.test('should not call hide for hidden popover on esc press', function(assert) {
2390+
const popover = new Popover($('#what'), {
2391+
target: '#where',
2392+
visible: true,
2393+
animation: null,
2394+
});
2395+
const hideSpy = sinon.spy(popover, 'hide');
2396+
const $target = $('#where').attr('tabindex', 0);
2397+
const keyboard = keyboardMock($target);
23792398

2380-
keyboard.keyDown('esc');
2399+
popover.hide();
2400+
hideSpy.resetHistory();
23812401

2382-
assert.strictEqual(topPopover.option('visible'), false, 'top popover is hidden');
2383-
assert.strictEqual(bottomPopover.option('visible'), true, 'bottom popover is still visible');
2402+
keyboard.keyDown('esc');
23842403

2385-
$markup.remove();
2404+
assert.strictEqual(hideSpy.callCount, 0, 'hide is not called');
2405+
assert.strictEqual(popover.option('visible'), false, 'popover remains hidden');
2406+
});
23862407
});
23872408

2388-
QUnit.test('should not call hide for hidden popover on esc press', function(assert) {
2389-
const popover = new Popover($('#what'), {
2390-
target: '#where',
2391-
visible: true,
2392-
animation: null,
2409+
QUnit.module('WCAG - hoverable', {
2410+
beforeEach: function() {
2411+
this.clock = sinon.useFakeTimers();
2412+
},
2413+
afterEach: function() {
2414+
this.clock.restore();
2415+
}
2416+
}, () => {
2417+
QUnit.test('should stay visible when pointer moves from target to overlay content', function(assert) {
2418+
const instance = new Popover($('#what'), {
2419+
target: '#where',
2420+
showEvent: 'mouseenter',
2421+
hideEvent: 'mouseleave',
2422+
visible: true,
2423+
});
2424+
2425+
const $overlayContent = wrapper().find(`.${OVERLAY_CONTENT_CLASS}`);
2426+
2427+
$('#where').trigger('mouseleave');
2428+
$overlayContent.trigger('mouseenter');
2429+
this.clock.tick(200);
2430+
2431+
assert.ok(instance.option('visible'), 'popover remains visible after pointer moves to overlay content');
2432+
});
2433+
2434+
QUnit.test('should hide when pointer leaves overlay content', function(assert) {
2435+
const instance = new Popover($('#what'), {
2436+
target: '#where',
2437+
showEvent: 'mouseenter',
2438+
hideEvent: 'mouseleave',
2439+
visible: true,
2440+
});
2441+
2442+
const $overlayContent = wrapper().find(`.${OVERLAY_CONTENT_CLASS}`);
2443+
2444+
$overlayContent.trigger('mouseenter');
2445+
$overlayContent.trigger('mouseleave');
2446+
2447+
assert.notOk(instance.option('visible'), 'popover hides when pointer leaves overlay content');
2448+
});
2449+
2450+
QUnit.test('should stay visible when pointer moves from overlay back to target', function(assert) {
2451+
const instance = new Popover($('#what'), {
2452+
target: '#where',
2453+
showEvent: 'mouseenter',
2454+
hideEvent: 'mouseleave',
2455+
visible: true,
2456+
});
2457+
2458+
const $overlayContent = wrapper().find(`.${OVERLAY_CONTENT_CLASS}`);
2459+
2460+
$overlayContent.trigger('mouseenter');
2461+
2462+
const mouseLeaveEvent = $.Event('mouseleave');
2463+
mouseLeaveEvent.relatedTarget = $('#where')[0];
2464+
$overlayContent.trigger(mouseLeaveEvent);
2465+
2466+
this.clock.tick(200);
2467+
2468+
assert.ok(instance.option('visible'), 'popover stays visible when pointer moves from overlay back to target');
23932469
});
2394-
const hideSpy = sinon.spy(popover, 'hide');
2395-
const $target = $('#where').attr('tabindex', 0);
2396-
const keyboard = keyboardMock($target);
23972470

2398-
popover.hide();
2399-
hideSpy.resetHistory();
2471+
QUnit.test('should not apply hoverable behavior for non-hover hide events', function(assert) {
2472+
const instance = new Popover($('#what'), {
2473+
target: '#where',
2474+
showEvent: 'dxclick',
2475+
hideEvent: 'dxclick',
2476+
visible: true,
2477+
});
24002478

2401-
keyboard.keyDown('esc');
2479+
$('#where').trigger('dxclick');
2480+
this.clock.tick(0);
24022481

2403-
assert.strictEqual(hideSpy.callCount, 0, 'hide is not called');
2404-
assert.strictEqual(popover.option('visible'), false, 'popover remains hidden');
2482+
assert.notOk(instance.option('visible'), 'popover hides immediately for non-hover hide event');
2483+
});
24052484
});
24062485
});

0 commit comments

Comments
 (0)