Skip to content

Commit

Permalink
Merge pull request #16050 from ckeditor/ck/6039
Browse files Browse the repository at this point in the history
Fix (ui): Users should be able to move the mouse cursor to a UI tooltip without closing it.
Fix (ui): Users should be able to close UI tooltips using the Esc key.
  • Loading branch information
oleq authored Mar 26, 2024
2 parents 39a7791 + e275337 commit 7df13e9
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 17 deletions.
49 changes: 41 additions & 8 deletions packages/ckeditor5-ui/src/tooltipmanager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ export default class TooltipManager extends DomEmitterMixin() {
*/
private _pinTooltipDebounced!: DebouncedFunc<( targetDomElement: HTMLElement, data: TooltipData ) => void>;

/**
* A debounced version of {@link #_unpinTooltip}. Tooltips hide with a delay to allow hovering of their titles.
*/
private _unpinTooltipDebounced!: DebouncedFunc<VoidFunction>;

private readonly _watchdogExcluded!: true;

/**
Expand Down Expand Up @@ -189,7 +194,9 @@ export default class TooltipManager extends DomEmitterMixin() {
} );

this._pinTooltipDebounced = debounce( this._pinTooltip, 600 );
this._unpinTooltipDebounced = debounce( this._unpinTooltip, 400 );

this.listenTo( global.document, 'keydown', this._onKeyDown.bind( this ), { useCapture: true } );
this.listenTo( global.document, 'mouseenter', this._onEnterOrFocus.bind( this ), { useCapture: true } );
this.listenTo( global.document, 'mouseleave', this._onLeaveOrBlur.bind( this ), { useCapture: true } );

Expand Down Expand Up @@ -259,6 +266,19 @@ export default class TooltipManager extends DomEmitterMixin() {
}[ position ];
}

/**
* Handles hiding tooltips on `keydown` in DOM.
*
* @param evt An object containing information about the fired event.
* @param domEvent The DOM event.
*/
private _onKeyDown( evt: EventInfo, domEvent: KeyboardEvent ) {
if ( domEvent.key === 'Escape' && this._currentElementWithTooltip ) {
this._unpinTooltip();
domEvent.stopPropagation();
}
}

/**
* Handles displaying tooltips on `mouseenter` and `focus` in DOM.
*
Expand Down Expand Up @@ -298,24 +318,34 @@ export default class TooltipManager extends DomEmitterMixin() {
return;
}

const balloonElement = this.balloonPanelView.element;
const isEnteringBalloon = balloonElement && ( balloonElement === relatedTarget || balloonElement.contains( relatedTarget ) );
const isLeavingBalloon = !isEnteringBalloon && target === balloonElement;

// Do not hide the tooltip when the user moves the cursor over it.
if ( isEnteringBalloon ) {
this._unpinTooltipDebounced.cancel();
return;
}

// If a tooltip is currently visible, don't act for a targets other than the one it is attached to.
// The only exception is leaving balloon, in this scenario tooltip should be closed.
// For instance, a random mouseleave far away in the page should not unpin the tooltip that was pinned because
// of a previous focus. Only leaving the same element should hide the tooltip.
if ( this._currentElementWithTooltip && target !== this._currentElementWithTooltip ) {
if ( !isLeavingBalloon && this._currentElementWithTooltip && target !== this._currentElementWithTooltip ) {
return;
}

const descendantWithTooltip = getDescendantWithTooltip( target );
const relatedDescendantWithTooltip = getDescendantWithTooltip( relatedTarget );

// Unpin when the mouse was leaving element with a tooltip to a place which does not have or has a different tooltip.
// Note that this should happen whether the tooltip is already visible or not, for instance, it could be invisible but queued
// (debounced): it should get canceled.
if ( descendantWithTooltip && descendantWithTooltip !== relatedDescendantWithTooltip ) {
this._unpinTooltip();
// Note that this should happen whether the tooltip is already visible or not, for instance,
// it could be invisible but queued (debounced): it should get canceled.
if ( isLeavingBalloon || ( descendantWithTooltip && descendantWithTooltip !== relatedDescendantWithTooltip ) ) {
this._unpinTooltipDebounced();
}
}
else {
} else {
// If a tooltip is currently visible, don't act for a targets other than the one it is attached to.
// For instance, a random blur in the web page should not unpin the tooltip that was pinned because of a previous mouseenter.
if ( this._currentElementWithTooltip && target !== this._currentElementWithTooltip ) {
Expand All @@ -324,7 +354,7 @@ export default class TooltipManager extends DomEmitterMixin() {

// Note that unpinning should happen whether the tooltip is already visible or not, for instance, it could be invisible but
// queued (debounced): it should get canceled (e.g. quick focus then quick blur using the keyboard).
this._unpinTooltip();
this._unpinTooltipDebounced();
}
}

Expand Down Expand Up @@ -361,6 +391,8 @@ export default class TooltipManager extends DomEmitterMixin() {
targetDomElement: HTMLElement,
{ text, position, cssClass }: TooltipData
): void {
this._unpinTooltipDebounced.cancel();

// Use the body collection of the first editor.
const bodyViewCollection = first( TooltipManager._editors.values() )!.ui.view.body;

Expand Down Expand Up @@ -404,6 +436,7 @@ export default class TooltipManager extends DomEmitterMixin() {
* Unpins the tooltip and cancels all queued pinning.
*/
private _unpinTooltip() {
this._unpinTooltipDebounced.cancel();
this._pinTooltipDebounced.cancel();

this.balloonPanelView.unpin();
Expand Down
118 changes: 112 additions & 6 deletions packages/ckeditor5-ui/tests/tooltip/tooltipmanager.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/

/* global document, MouseEvent, Event */
/* global document, MouseEvent, Event, KeyboardEvent */

import EditorUI from '../../src/editorui/editorui.js';
import View from '../../src/view.js';
Expand Down Expand Up @@ -603,6 +603,45 @@ describe( 'TooltipManager', () => {
clock.restore();
} );

describe( 'on keydown', () => {
it( 'should work if `Escape` keyboard keydown event occurs and tooltip opened', () => {
utils.dispatchMouseEnter( elements.a );
utils.waitForTheTooltipToShow( clock );

sinon.assert.calledOnce( pinSpy );

unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );

const event = new KeyboardEvent( 'keydown', { key: 'Escape' } );
const stopPropagationSpy = sinon.spy( event, 'stopPropagation' );

element.dispatchEvent( event );
utils.waitForTheTooltipToHide( clock );

sinon.assert.calledOnce( stopPropagationSpy );
sinon.assert.calledOnce( unpinSpy );
} );

it( 'should not work if `A` keyboard keydown event occurs and tooltip opened', () => {
utils.dispatchMouseEnter( elements.a );
utils.waitForTheTooltipToShow( clock );

sinon.assert.calledOnce( pinSpy );

unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );
utils.dispatchKeydown( document, 'A' );

utils.waitForTheTooltipToHide( clock );
sinon.assert.notCalled( unpinSpy );
} );

it( 'should not throw exception if there is no opened tooltip and `Escape` keydown event occurs', () => {
expect( () => {
utils.dispatchKeydown( document, 'Escape' );
} ).not.to.throw();
} );
} );

describe( 'on mouseleave', () => {
it( 'should not work for unrelated event targets such as DOM document', () => {
utils.dispatchMouseEnter( elements.a );
Expand All @@ -613,9 +652,39 @@ describe( 'TooltipManager', () => {
unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );
utils.dispatchMouseLeave( document );

utils.waitForTheTooltipToHide( clock );
sinon.assert.notCalled( unpinSpy );
} );

it( 'should not work if the tooltip is currently pinned and' +
'the event target is element and relatedTarget is balloon element', () => {
utils.dispatchMouseEnter( elements.a );
utils.waitForTheTooltipToShow( clock );

sinon.assert.calledOnce( pinSpy );

unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );

utils.dispatchMouseLeave( elements.a, tooltipManager.balloonPanelView.element );

utils.waitForTheTooltipToHide( clock );
sinon.assert.notCalled( unpinSpy );
} );

it( 'should work if the tooltip is currently pinned and' +
'the event target is balloon element and relatedTarget is something else', () => {
utils.dispatchMouseEnter( elements.a );
utils.waitForTheTooltipToShow( clock );

sinon.assert.calledOnce( pinSpy );

unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );
utils.dispatchMouseLeave( tooltipManager.balloonPanelView.element, elements.b );

utils.waitForTheTooltipToHide( clock );
sinon.assert.calledOnce( unpinSpy );
} );

it( 'should not work if the tooltip is currently pinned and the event target is different than the current element', () => {
utils.dispatchMouseEnter( elements.a );
utils.waitForTheTooltipToShow( clock );
Expand All @@ -625,30 +694,33 @@ describe( 'TooltipManager', () => {
unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );
utils.dispatchMouseLeave( elements.b );

utils.waitForTheTooltipToHide( clock );
sinon.assert.notCalled( unpinSpy );
} );

it( 'should not work if the tooltip is not visible and leaving an element that has nothing to do with tooltips', () => {
unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );
utils.dispatchMouseLeave( elements.unrelated );

utils.waitForTheTooltipToHide( clock );
sinon.assert.notCalled( unpinSpy );
} );

it( 'should unpin the tooltip when moving from one element with a tooltip to another element with a tooltip quickly' +
'before the tooltip shows for the first tooltip (cancellin the queued pinning)', () => {
'before the tooltip shows for the first tooltip (cancelling the queued pinning)', () => {
utils.dispatchMouseEnter( elements.a );

unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );
utils.dispatchMouseLeave( elements.childOfA, elements.a );

utils.waitForTheTooltipToHide( clock );
sinon.assert.calledOnce( unpinSpy );

utils.waitForTheTooltipToShow( clock );
sinon.assert.notCalled( pinSpy );
} );

it( 'should immediatelly unpin the tooltip otherwise', () => {
it( 'should unpin the tooltip otherwise', () => {
utils.dispatchMouseEnter( elements.a );
utils.waitForTheTooltipToShow( clock );

Expand All @@ -657,7 +729,27 @@ describe( 'TooltipManager', () => {
unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );
utils.dispatchMouseLeave( elements.a );

utils.waitForTheTooltipToHide( clock );
sinon.assert.calledOnce( unpinSpy );
} );

it( 'should cancel pending unpin when hovered another tooltip', () => {
utils.dispatchMouseEnter( elements.a );
utils.waitForTheTooltipToShow( clock );

sinon.assert.calledOnce( pinSpy );
utils.dispatchMouseLeave( elements.a );

unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );
const debounceCancelUnpinSpy = sinon.spy( tooltipManager._unpinTooltipDebounced, 'cancel' );

utils.dispatchMouseEnter( elements.b );

sinon.assert.calledOnce( debounceCancelUnpinSpy );
sinon.assert.calledOnce( unpinSpy );

utils.waitForTheTooltipToHide( clock );
sinon.assert.calledTwice( unpinSpy );
} );
} );

Expand All @@ -671,6 +763,7 @@ describe( 'TooltipManager', () => {
unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );
utils.dispatchBlur( elements.unrelated );

utils.waitForTheTooltipToHide( clock );
sinon.assert.notCalled( unpinSpy );
} );

Expand All @@ -683,13 +776,15 @@ describe( 'TooltipManager', () => {
unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );
utils.dispatchBlur( elements.a );

utils.waitForTheTooltipToHide( clock );
sinon.assert.calledOnce( unpinSpy );
} );

it( 'should unpin if the tooltip was not pinned (cancels the queued pinning)', () => {
unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );
utils.dispatchBlur( elements.unrelated );

utils.waitForTheTooltipToHide( clock );
sinon.assert.calledOnce( unpinSpy );
} );
} );
Expand All @@ -699,6 +794,7 @@ describe( 'TooltipManager', () => {
unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );
utils.dispatchScroll( elements.a );

utils.waitForTheTooltipToHide( clock );
sinon.assert.notCalled( unpinSpy );
} );

Expand All @@ -711,7 +807,7 @@ describe( 'TooltipManager', () => {

unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );
utils.dispatchScroll( document );

utils.waitForTheTooltipToHide( clock );
sinon.assert.notCalled( unpinSpy );
} );

Expand All @@ -723,7 +819,7 @@ describe( 'TooltipManager', () => {

unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );
utils.dispatchScroll( elements.a );

utils.waitForTheTooltipToHide( clock );
sinon.assert.calledOnce( unpinSpy );
} );

Expand All @@ -735,7 +831,7 @@ describe( 'TooltipManager', () => {

unpinSpy = sinon.spy( tooltipManager.balloonPanelView, 'unpin' );
utils.dispatchScroll( elements.unrelated );

utils.waitForTheTooltipToHide( clock );
sinon.assert.calledOnce( unpinSpy );
} );
} );
Expand Down Expand Up @@ -883,8 +979,10 @@ describe( 'TooltipManager', () => {
sinon.assert.calledTwice( pinSpy );

utils.dispatchMouseLeave( elements.a );
utils.waitForTheTooltipToHide( clock );

editor.ui.update();

sinon.assert.calledTwice( pinSpy );
} );

Expand Down Expand Up @@ -970,10 +1068,18 @@ function getUtils() {
clock.tick( 650 );
},

waitForTheTooltipToHide: clock => {
clock.tick( 650 );
},

dispatchMouseEnter: element => {
element.dispatchEvent( new MouseEvent( 'mouseenter' ) );
},

dispatchKeydown: ( element, key ) => {
element.dispatchEvent( new KeyboardEvent( 'keydown', { key } ) );
},

dispatchMouseLeave: ( element, relatedTarget ) => {
element.dispatchEvent( new MouseEvent( 'mouseleave', { relatedTarget } ) );
},
Expand Down
3 changes: 0 additions & 3 deletions packages/ckeditor5-ui/theme/components/tooltip/tooltip.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,5 @@
*/

.ck.ck-balloon-panel.ck-tooltip {
/* Keep tooltips transparent for any interactions. */
pointer-events: none;

z-index: calc( var(--ck-z-dialog) + 100 );
}
1 change: 1 addition & 0 deletions packages/ckeditor5-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export { default as uid } from './uid.js';
export { default as delay, type DelayedFunc } from './delay.js';
export { default as verifyLicense } from './verifylicense.js';
export { default as wait } from './wait.js';

export * from './unicode.js';

export { default as version, releaseDate } from './version.js';

0 comments on commit 7df13e9

Please sign in to comment.