diff --git a/apps/react-storybook/stories/tooltip/Tooltip.stories.tsx b/apps/react-storybook/stories/tooltip/Tooltip.stories.tsx new file mode 100644 index 000000000000..14d343df2fca --- /dev/null +++ b/apps/react-storybook/stories/tooltip/Tooltip.stories.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import { Tooltip } from 'devextreme-react/tooltip'; + +const meta: Meta = { + title: 'Components/Tooltip', + component: Tooltip, + parameters: { + layout: 'padded', + }, +}; + +export default meta; + +type Story = StoryObj; + +const HoverableExample: Story['render'] = (args) => ( +
+

+ Hover the button to show the tooltip, then move the pointer onto + the tooltip content — it must stay open. +

+ + +
+ WCAG — Hoverable +

+ Move your pointer here from the button. +
+ The tooltip stays open as long as the pointer +
+ is over the target or this content. +

+
+
+
+); + +export const Hoverable: Story = { + args: { + position: 'bottom', + }, + argTypes: { + position: { + control: 'select', + options: ['top', 'bottom', 'left', 'right'], + }, + }, + render: HoverableExample, +}; + +const HoverableWithDelayExample: Story['render'] = (args) => ( +
+

+ Show delay: 500 ms — Hide delay: 300 ms. + Moving onto the tooltip content cancels the hide timeout. +

+ + +
+ Move here to cancel the hide timeout. +
+
+
+); + +export const HoverableWithDelay: Story = { + args: { + position: 'bottom', + }, + argTypes: { + position: { + control: 'select', + options: ['top', 'bottom', 'left', 'right'], + }, + }, + render: HoverableWithDelayExample, +}; diff --git a/packages/devextreme/js/__internal/ui/popover/m_popover.ts b/packages/devextreme/js/__internal/ui/popover/m_popover.ts index 60cdca1c40b0..908801798f29 100644 --- a/packages/devextreme/js/__internal/ui/popover/m_popover.ts +++ b/packages/devextreme/js/__internal/ui/popover/m_popover.ts @@ -47,9 +47,24 @@ const POSITION_FLIP_MAP = { center: 'center', }; +const HOVER_EVENT_PAIRS: Record = { + // eslint-disable-next-line spellcheck/spell-checker + mouseleave: 'mouseenter', + // eslint-disable-next-line spellcheck/spell-checker + mouseout: 'mouseover', + // eslint-disable-next-line spellcheck/spell-checker + pointerleave: 'pointerenter', + // eslint-disable-next-line spellcheck/spell-checker + dxhoverend: 'dxhoverstart', +}; + +const HOVER_HIDE_EVENTS = Object.keys(HOVER_EVENT_PAIRS); +const HOVER_HIDE_DELAY = 50; + const ESC_KEY_NAME = 'escape'; type PopoverTarget = string | dxElementWrapper | Element | undefined; +type PopoverEventOption = 'showEvent' | 'hideEvent'; export interface PopoverProperties extends Omit { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.hide(); + }, hideDelay); + } else { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.hide(); + } + } + + // eslint-disable-next-line class-methods-use-this + _isHoverHideEventName(eventName: string): boolean { + return HOVER_HIDE_EVENTS.some((hoverEvent) => eventName.split(/\s+/).includes(hoverEvent)); + } + + _attachHoverableOverlay(): void { + const hideEventName = this._getEventName('hideEvent'); + if (!hideEventName || !this._isHoverHideEventName(hideEventName)) { + return; + } + const $overlayContent = this.$overlayContent(); + if (!$overlayContent.length) { + return; + } + + const namespace = `${this.NAME as string}Hoverable`; + const activeHideEvents = hideEventName.split(/\s+/).filter((eventName: string) => eventName in HOVER_EVENT_PAIRS); + + const hoverInEventName = activeHideEvents + .map((eventName: string) => addNamespace(HOVER_EVENT_PAIRS[eventName], namespace)) + .join(' '); + const hoverOutEventName = activeHideEvents + .map((eventName: string) => addNamespace(eventName, namespace)) + .join(' '); + + eventsEngine.off($overlayContent, hoverInEventName); + eventsEngine.on($overlayContent, hoverInEventName, () => { + this._clearEventsTimeouts(); + }); + + eventsEngine.off($overlayContent, hoverOutEventName); + eventsEngine.on($overlayContent, hoverOutEventName, (e: PointerEvent | MouseEvent) => { + const { target } = this.option(); + const { relatedTarget } = e; + + if (target && relatedTarget instanceof Element && $(relatedTarget).closest(target).length) { + return; + } + + this._scheduleHoverHide(); + }); + } + + _detachHoverableOverlay(): void { + const $overlayContent = this.$overlayContent(); + if (!$overlayContent.length) { + return; + } + const namespace = `${this.NAME as string}Hoverable`; + const allEventNames = [ + ...Object.keys(HOVER_EVENT_PAIRS), + ...Object.values(HOVER_EVENT_PAIRS), + ].map((e) => addNamespace(e, namespace)).join(' '); + eventsEngine.off($overlayContent, allEventNames); + } + + _createEventHandler(name: string) { const action = this._createAction(() => { - const delay = this._getEventDelay(`${name}Event`); + const explicitDelay = this._getEventDelay(`${name}Event` as PopoverEventOption); this._clearEventsTimeouts(); + const hideEventName = name === 'hide' ? this._getEventName('hideEvent') : null; + const isHoverHide = hideEventName && this._isHoverHideEventName(hideEventName); + const delay = explicitDelay ?? (isHoverHide ? HOVER_HIDE_DELAY : 0); + if (delay) { this._timeouts[name] = setTimeout(() => { this[name](); @@ -298,10 +391,10 @@ class Popover< return this._getEventNameByOption(optionValue); } - _getEventDelay(optionName) { - const optionValue = this.option(optionName); - // @ts-expect-error - return isObject(optionValue) && optionValue.delay; + _getEventDelay(optionName: PopoverEventOption): number | undefined { + const { [optionName]: optionValue } = this.option(); + + return isObject(optionValue) ? (optionValue.delay) : undefined; } _renderArrow(): void { @@ -566,6 +659,7 @@ class Popover< _clean(): void { this._detachEscapeKeyHandler(); this._detachEvents(this.option('target')); + this._detachHoverableOverlay(); // @ts-expect-error ts-error super._clean.apply(this, arguments); } @@ -603,6 +697,11 @@ class Popover< const { target } = this.option(); this._detachEvent(target, eventName, event); this._attachEvent(eventName); + + if (name === 'hideEvent') { + this._detachHoverableOverlay(); + this._attachHoverableOverlay(); + } break; } case 'visible': diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js index e640571dac81..fab230bd20cf 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/popover.tests.js @@ -2345,62 +2345,181 @@ QUnit.module('accessibility', { assert.strictEqual($overlay.attr('role'), 'dialog'); }); - QUnit.test('should hide visible popover on esc press', function(assert) { - const popover = new Popover($('#what'), { - target: '#where', - visible: true, - }); - const $target = $('#where').attr('tabindex', 0); - const keyboard = keyboardMock($target); + QUnit.module('WCAG - dismissible', () => { + QUnit.test('should hide visible popover on esc press', function(assert) { + const popover = new Popover($('#what'), { + target: '#where', + visible: true, + }); + const $target = $('#where').attr('tabindex', 0); + const keyboard = keyboardMock($target); - keyboard.keyDown('esc'); + keyboard.keyDown('esc'); - assert.strictEqual(popover.option('visible'), false, 'popover is hidden'); - }); + assert.strictEqual(popover.option('visible'), false, 'popover is hidden'); + }); - QUnit.test('should hide only topmost popover on esc press', function(assert) { - const $markup = $('
' + + QUnit.test('should hide only topmost popover on esc press', function(assert) { + const $markup = $('
' + '
' + '
' + '
') - .appendTo('body'); + .appendTo('body'); - const bottomPopover = new Popover($('#popover1'), { - target: '#target1', - visible: true, - }); - const topPopover = new Popover($('#popover2'), { - target: '#target2', - visible: true, + const bottomPopover = new Popover($('#popover1'), { + target: '#target1', + visible: true, + }); + const topPopover = new Popover($('#popover2'), { + target: '#target2', + visible: true, + }); + + const keyboard = keyboardMock($('#target2')); + + keyboard.keyDown('esc'); + + assert.strictEqual(topPopover.option('visible'), false, 'top popover is hidden'); + assert.strictEqual(bottomPopover.option('visible'), true, 'bottom popover is still visible'); + + $markup.remove(); }); - const keyboard = keyboardMock($('#target2')); + QUnit.test('should not call hide for hidden popover on esc press', function(assert) { + const popover = new Popover($('#what'), { + target: '#where', + visible: true, + animation: null, + }); + const hideSpy = sinon.spy(popover, 'hide'); + const $target = $('#where').attr('tabindex', 0); + const keyboard = keyboardMock($target); - keyboard.keyDown('esc'); + popover.hide(); + hideSpy.resetHistory(); - assert.strictEqual(topPopover.option('visible'), false, 'top popover is hidden'); - assert.strictEqual(bottomPopover.option('visible'), true, 'bottom popover is still visible'); + keyboard.keyDown('esc'); - $markup.remove(); + assert.strictEqual(hideSpy.callCount, 0, 'hide is not called'); + assert.strictEqual(popover.option('visible'), false, 'popover remains hidden'); + }); }); - QUnit.test('should not call hide for hidden popover on esc press', function(assert) { - const popover = new Popover($('#what'), { - target: '#where', - visible: true, - animation: null, + QUnit.module('WCAG - hoverable', { + beforeEach: function() { + this.clock = sinon.useFakeTimers(); + }, + afterEach: function() { + this.clock.restore(); + } + }, () => { + QUnit.test('should stay visible when pointer moves from target to overlay content', function(assert) { + const instance = new Popover($('#what'), { + target: '#where', + showEvent: 'mouseenter', + hideEvent: 'mouseleave', + visible: true, + }); + + const $overlayContent = wrapper().find(`.${OVERLAY_CONTENT_CLASS}`); + + $('#where').trigger('mouseleave'); + $overlayContent.trigger('mouseenter'); + this.clock.tick(200); + + assert.ok(instance.option('visible'), 'popover remains visible after pointer moves to overlay content'); }); - const hideSpy = sinon.spy(popover, 'hide'); - const $target = $('#where').attr('tabindex', 0); - const keyboard = keyboardMock($target); - popover.hide(); - hideSpy.resetHistory(); + QUnit.test('should hide when pointer leaves overlay content', function(assert) { + const instance = new Popover($('#what'), { + target: '#where', + showEvent: 'mouseenter', + hideEvent: 'mouseleave', + visible: true, + }); + + const $overlayContent = wrapper().find(`.${OVERLAY_CONTENT_CLASS}`); + + $overlayContent.trigger('mouseenter'); + $overlayContent.trigger('mouseleave'); + + assert.notOk(instance.option('visible'), 'popover hides when pointer leaves overlay content'); + }); + + QUnit.test('should stay visible when pointer moves from overlay back to target', function(assert) { + const instance = new Popover($('#what'), { + target: '#where', + showEvent: 'mouseenter', + hideEvent: 'mouseleave', + visible: true, + }); + + const $overlayContent = wrapper().find(`.${OVERLAY_CONTENT_CLASS}`); + + $overlayContent.trigger('mouseenter'); + + const mouseLeaveEvent = $.Event('mouseleave'); + mouseLeaveEvent.relatedTarget = $('#where')[0]; + $overlayContent.trigger(mouseLeaveEvent); - keyboard.keyDown('esc'); + this.clock.tick(200); + + assert.ok(instance.option('visible'), 'popover stays visible when pointer moves from overlay back to target'); + }); + + QUnit.test('should not apply hoverable behavior for non-hover hide events', function(assert) { + const instance = new Popover($('#what'), { + target: '#where', + showEvent: 'dxclick', + hideEvent: 'dxclick', + visible: true, + }); - assert.strictEqual(hideSpy.callCount, 0, 'hide is not called'); - assert.strictEqual(popover.option('visible'), false, 'popover remains hidden'); + $('#where').trigger('dxclick'); + this.clock.tick(0); + + assert.notOk(instance.option('visible'), 'popover hides immediately for non-hover hide event'); + }); + + QUnit.test('should respect hideEvent.delay when pointer leaves overlay content', function(assert) { + const instance = new Popover($('#what'), { + target: '#where', + showEvent: 'mouseenter', + hideEvent: { name: 'mouseleave', delay: 300 }, + visible: true, + }); + + const $overlayContent = wrapper().find(`.${OVERLAY_CONTENT_CLASS}`); + + $overlayContent.trigger('mouseenter'); + $overlayContent.trigger('mouseleave'); + + assert.ok(instance.option('visible'), 'popover is still visible during delay period'); + + this.clock.tick(300); + + assert.notOk(instance.option('visible'), 'popover hides after the configured delay'); + }); + + QUnit.test('should cancel scheduled overlay hide when pointer re-enters overlay content before delay expires', function(assert) { + const instance = new Popover($('#what'), { + target: '#where', + showEvent: 'mouseenter', + hideEvent: { name: 'mouseleave', delay: 300 }, + visible: true, + }); + + const $overlayContent = wrapper().find(`.${OVERLAY_CONTENT_CLASS}`); + + $overlayContent.trigger('mouseenter'); + $overlayContent.trigger('mouseleave'); + + this.clock.tick(150); + $overlayContent.trigger('mouseenter'); + this.clock.tick(300); + + assert.ok(instance.option('visible'), 'popover remains visible when pointer re-enters overlay before delay expires'); + }); }); }); diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tooltip.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tooltip.tests.js index 9ac02a4ac2be..b117b7e50137 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/tooltip.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/tooltip.tests.js @@ -8,6 +8,7 @@ import keyboardMock from '../../helpers/keyboardMock.js'; const TOOLTIP_CLASS = 'dx-tooltip'; const TOOLTIP_WRAPPER_CLASS = 'dx-tooltip-wrapper'; const DX_INVISIBILITY_CLASS = 'dx-state-invisible'; +const OVERLAY_CONTENT_CLASS = 'dx-overlay-content'; const wrapper = function() { return $('body').find('.' + TOOLTIP_WRAPPER_CLASS); @@ -167,7 +168,7 @@ QUnit.module('accessibility', () => { QUnit.test('role="tooltip" attribute should be added to tooltip', function(assert) { const $tooltip = $('#tooltip'); new Tooltip($tooltip); - const $overlayContent = $tooltip.find('.dx-overlay-content'); + const $overlayContent = $tooltip.find(`.${OVERLAY_CONTENT_CLASS}`); assert.equal($overlayContent.attr('role'), 'tooltip'); }); @@ -176,23 +177,71 @@ QUnit.module('accessibility', () => { const $target = $('#target'); const $element = $('#tooltip'); new Tooltip($element, { target: $target, visible: false }); - const $overlay = $element.find('.dx-overlay-content'); + const $overlay = $element.find(`.${OVERLAY_CONTENT_CLASS}`); assert.notEqual($target.attr('aria-describedby'), undefined, 'aria-describedby exists on target'); assert.equal($target.attr('aria-describedby'), $overlay.attr('id'), 'aria-describedby and overlay\'s id are equal'); }); - QUnit.test('visible tooltip should be hidden on Escape key press', function(assert) { - const tooltip = new Tooltip($('#tooltip'), { - target: '#target', - visible: true, + QUnit.module('WCAG - dismissible', () => { + QUnit.test('should hide visible tooltip on Escape key press', function(assert) { + const tooltip = new Tooltip($('#tooltip'), { + target: '#target', + visible: true, + }); + const $target = $('#target').attr('tabindex', 0); + const keyboard = keyboardMock($target); + + keyboard.keyDown('esc'); + + assert.strictEqual(tooltip.option('visible'), false, 'tooltip is hidden'); + }); + }); + + QUnit.module('WCAG - hoverable', { + beforeEach: function() { + this.clock = sinon.useFakeTimers(); + fx.off = true; + }, + afterEach: function() { + this.clock.restore(); + fx.off = false; + } + }, () => { + QUnit.test('should stay visible when pointer moves from target to tooltip content', function(assert) { + const instance = new Tooltip($('#tooltip'), { + target: '#target', + showEvent: 'mouseenter', + hideEvent: 'mouseleave', + visible: true, + animation: null, + }); + + const $overlayContent = wrapper().find(`.${OVERLAY_CONTENT_CLASS}`); + + $('#target').trigger('mouseleave'); + $overlayContent.trigger('mouseenter'); + this.clock.tick(200); + + assert.ok(instance.option('visible'), 'tooltip remains visible after pointer moves to tooltip content'); }); - const $target = $('#target').attr('tabindex', 0); - const keyboard = keyboardMock($target); - keyboard.keyDown('esc'); + QUnit.test('should hide when pointer leaves tooltip content', function(assert) { + const instance = new Tooltip($('#tooltip'), { + target: '#target', + showEvent: 'mouseenter', + hideEvent: 'mouseleave', + visible: true, + animation: null, + }); + + const $overlayContent = wrapper().find(`.${OVERLAY_CONTENT_CLASS}`); - assert.strictEqual(tooltip.option('visible'), false, 'tooltip is hidden'); + $overlayContent.trigger('mouseenter'); + $overlayContent.trigger('mouseleave'); + + assert.notOk(instance.option('visible'), 'tooltip hides when pointer leaves tooltip content'); + }); }); });