Skip to content

Commit

Permalink
Merge pull request #7779 from ckeditor/i/7743
Browse files Browse the repository at this point in the history
Other (ui): The clickOutsideHandler() function will take into consideration that the editor can be placed in a shadow root while detecting a click. Closes #7743.

Thanks to @ywsang.
  • Loading branch information
pomek committed Aug 5, 2020
2 parents fe3ac2d + f4a58c0 commit 2dc0264
Show file tree
Hide file tree
Showing 2 changed files with 86 additions and 3 deletions.
8 changes: 6 additions & 2 deletions packages/ckeditor5-ui/src/bindings/clickoutsidehandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,17 @@
* @param {Function} options.callback An action executed by the handler.
*/
export default function clickOutsideHandler( { emitter, activator, callback, contextElements } ) {
emitter.listenTo( document, 'mousedown', ( evt, { target } ) => {
emitter.listenTo( document, 'mousedown', ( evt, domEvt ) => {
if ( !activator() ) {
return;
}

// Check if `composedPath` is `undefined` in case the browser does not support native shadow DOM.
// Can be removed when all supported browsers support native shadow DOM.
const path = typeof domEvt.composedPath == 'function' ? domEvt.composedPath() : [];

for ( const contextElement of contextElements ) {
if ( contextElement.contains( target ) ) {
if ( contextElement.contains( domEvt.target ) || path.includes( contextElement ) ) {
return;
}
}
Expand Down
81 changes: 80 additions & 1 deletion packages/ckeditor5-ui/tests/bindings/clickoutsidehandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,38 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';

describe( 'clickOutsideHandler', () => {
let activator, actionSpy, contextElement1, contextElement2;
let shadowRootContainer, shadowContextElement1, shadowContextElement2;

testUtils.createSinonSandbox();

beforeEach( () => {
activator = testUtils.sinon.stub().returns( false );
contextElement1 = document.createElement( 'div' );
contextElement2 = document.createElement( 'div' );
shadowRootContainer = document.createElement( 'div' );
shadowRootContainer.attachShadow( { mode: 'open' } );
shadowContextElement1 = document.createElement( 'div' );
shadowContextElement2 = document.createElement( 'div' );
actionSpy = testUtils.sinon.spy();

document.body.appendChild( contextElement1 );
document.body.appendChild( contextElement2 );
shadowRootContainer.shadowRoot.appendChild( shadowContextElement1 );
shadowRootContainer.shadowRoot.appendChild( shadowContextElement2 );
document.body.appendChild( shadowRootContainer );

clickOutsideHandler( {
emitter: Object.create( DomEmitterMixin ),
activator,
contextElements: [ contextElement1, contextElement2 ],
contextElements: [ contextElement1, contextElement2, shadowContextElement1, shadowContextElement2 ],
callback: actionSpy
} );
} );

afterEach( () => {
document.body.removeChild( contextElement1 );
document.body.removeChild( contextElement2 );
document.body.removeChild( shadowRootContainer );
} );

it( 'should execute upon #mousedown outside of the contextElements (activator is active)', () => {
Expand All @@ -46,6 +55,25 @@ describe( 'clickOutsideHandler', () => {
sinon.assert.calledOnce( actionSpy );
} );

it( 'should execute upon #mousedown outside of the contextElements (activator is active, unsupported shadow DOM)', () => {
activator.returns( true );

const event = new Event( 'mousedown', { bubbles: true } );
event.composedPath = undefined;

document.body.dispatchEvent( event );

sinon.assert.calledOnce( actionSpy );
} );

it( 'should execute upon #mousedown in the shadow root but outside the contextElements (activator is active)', () => {
activator.returns( true );

shadowRootContainer.shadowRoot.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );

sinon.assert.notCalled( actionSpy );
} );

it( 'should not execute upon #mousedown outside of the contextElements (activator is inactive)', () => {
activator.returns( false );

Expand All @@ -54,6 +82,25 @@ describe( 'clickOutsideHandler', () => {
sinon.assert.notCalled( actionSpy );
} );

it( 'should not execute upon #mousedown outside of the contextElements (activator is inactive, unsupported shadow DOM)', () => {
activator.returns( false );

const event = new Event( 'mousedown', { bubbles: true } );
event.composedPath = undefined;

document.body.dispatchEvent( event );

sinon.assert.notCalled( actionSpy );
} );

it( 'should not execute upon #mousedown in the shadow root but outside of the contextElements (activator is inactive)', () => {
activator.returns( false );

shadowRootContainer.shadowRoot.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );

sinon.assert.notCalled( actionSpy );
} );

it( 'should not execute upon #mousedown from one of the contextElements (activator is active)', () => {
activator.returns( true );

Expand All @@ -62,6 +109,12 @@ describe( 'clickOutsideHandler', () => {

contextElement2.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
sinon.assert.notCalled( actionSpy );

shadowContextElement1.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
sinon.assert.notCalled( actionSpy );

shadowContextElement2.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
sinon.assert.notCalled( actionSpy );
} );

it( 'should not execute upon #mousedown from one of the contextElements (activator is inactive)', () => {
Expand All @@ -72,6 +125,12 @@ describe( 'clickOutsideHandler', () => {

contextElement2.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
sinon.assert.notCalled( actionSpy );

shadowContextElement1.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
sinon.assert.notCalled( actionSpy );

shadowContextElement2.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
sinon.assert.notCalled( actionSpy );
} );

it( 'should execute if the activator function returns `true`', () => {
Expand Down Expand Up @@ -139,4 +198,24 @@ describe( 'clickOutsideHandler', () => {

sinon.assert.notCalled( actionSpy );
} );

it( 'should not execute if one of contextElements in the shadow root contains the DOM event target', () => {
const target = document.createElement( 'div' );
activator.returns( true );

shadowContextElement1.appendChild( target );
target.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );

sinon.assert.notCalled( actionSpy );
} );

it( 'should not execute if one of contextElements in the shadow root is the DOM event target', () => {
const target = document.createElement( 'div' );
activator.returns( true );

shadowRootContainer.shadowRoot.appendChild( target );
target.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );

sinon.assert.notCalled( actionSpy );
} );
} );

0 comments on commit 2dc0264

Please sign in to comment.