Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

Commit

Permalink
Merge pull request #449 from ckeditor/t/123
Browse files Browse the repository at this point in the history
Feature: Implemented configurable, smart `DropdownView` panel positioning. Closes #123.
  • Loading branch information
oskarwrobel authored Oct 16, 2018
2 parents 9cdcd4a + d5e702c commit 8094f19
Show file tree
Hide file tree
Showing 10 changed files with 393 additions and 9 deletions.
13 changes: 13 additions & 0 deletions src/dropdown/dropdownpanelview.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ export default class DropdownPanelView extends View {
*/
this.set( 'isVisible', false );

/**
* The position of the panel, relative to the parent.
*
* This property is reflected in the CSS class set to {@link #element} that controls
* the position of the panel.
*
* @observable
* @default 'se'
* @member {'se'|'sw'|'ne'|'nw'} #position
*/
this.set( 'position', 'se' );

/**
* Collection of the child views in this panel.
*
Expand All @@ -53,6 +65,7 @@ export default class DropdownPanelView extends View {
'ck',
'ck-reset',
'ck-dropdown__panel',
bind.to( 'position', value => `ck-dropdown__panel_${ value }` ),
bind.if( 'isVisible', 'ck-dropdown__panel-visible' )
]
},
Expand Down
126 changes: 126 additions & 0 deletions src/dropdown/dropdownview.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';

import '../../theme/components/dropdown/dropdown.css';

import { getOptimalPosition } from '@ckeditor/ckeditor5-utils/src/dom/position';

/**
* The dropdown view class. It manages the dropdown button and dropdown panel.
*
Expand Down Expand Up @@ -128,6 +130,23 @@ export default class DropdownView extends View {
*/
this.set( 'class' );

/**
* The position of the panel, relative to the dropdown.
*
* **Note**: When `'auto'`, the panel will use one of the remaining positions to stay
* in the viewport, visible to the user. The positions correspond directly to
* {@link module:ui/dropdown/dropdownview~DropdownView.defaultPanelPositions default panel positions}.
*
* **Note**: This value has an impact on the
* {@link module:ui/dropdown/dropdownpanelview~DropdownPanelView#position} property
* each time the panel becomes {@link #isOpen open}.
*
* @observable
* @default 'auto'
* @member {'auto'|'se'|'sw'|'ne'|'nw'} #panelPosition
*/
this.set( 'panelPosition', 'auto' );

/**
* Tracks information about DOM focus in the dropdown.
*
Expand Down Expand Up @@ -224,6 +243,34 @@ export default class DropdownView extends View {
// Toggle the visibility of the panel when the dropdown becomes open.
this.panelView.bind( 'isVisible' ).to( this, 'isOpen' );

// Let the dropdown control the position of the panel. The position must
// be updated every time the dropdown is open.
this.on( 'change:isOpen', () => {
if ( !this.isOpen ) {
return;
}

// If "auto", find the best position of the panel to fit into the viewport.
// Otherwise, simply assign the static position.
if ( this.panelPosition === 'auto' ) {
const defaultPanelPositions = DropdownView.defaultPanelPositions;

this.panelView.position = getOptimalPosition( {
element: this.panelView.element,
target: this.buttonView.element,
fitInViewport: true,
positions: [
defaultPanelPositions.southEast,
defaultPanelPositions.southWest,
defaultPanelPositions.northEast,
defaultPanelPositions.northWest
]
} ).name;
} else {
this.panelView.position = this.panelPosition;
}
} );

// Listen for keystrokes coming from within #element.
this.keystrokes.listenTo( this.element );

Expand Down Expand Up @@ -266,3 +313,82 @@ export default class DropdownView extends View {
this.buttonView.focus();
}
}

/**
* A set of positioning functions used by the dropdown view to determine
* the optimal position (i.e. fitting into the browser viewport) of its
* {@link module:ui/dropdown/dropdownview~DropdownView#panelView panel} when
* {@link module:ui/dropdown/dropdownview~DropdownView#panelPosition} is set to 'auto'`.
*
* The available positioning functions are as follow:
*
* **South**
*
* * `southEast`
*
* [ Button ]
* +-----------------+
* | Panel |
* +-----------------+
*
* * `southWest`
*
* [ Button ]
* +-----------------+
* | Panel |
* +-----------------+
*
* **North**
*
* * `northEast`
*
* +-----------------+
* | Panel |
* +-----------------+
* [ Button ]
*
* * `northWest`
*
* +-----------------+
* | Panel |
* +-----------------+
* [ Button ]
*
* Positioning functions are compatible with {@link module:utils/dom/position~Position}.
*
* The name that position function returns will be reflected in dropdown panel's class that
* controls its placement. See {@link module:ui/dropdown/dropdownview~DropdownView#panelPosition}
* to learn more.
*
* @member {Object} module:ui/dropdown/dropdownview~DropdownView.defaultPanelPositions
*/
DropdownView.defaultPanelPositions = {
southEast: buttonRect => {
return {
top: buttonRect.bottom,
left: buttonRect.left,
name: 'se'
};
},
southWest: ( buttonRect, panelRect ) => {
return {
top: buttonRect.bottom,
left: buttonRect.left - panelRect.width + buttonRect.width,
name: 'sw'
};
},
northEast: ( buttonRect, panelRect ) => {
return {
top: buttonRect.top - panelRect.height,
left: buttonRect.left,
name: 'ne'
};
},
northWest: ( buttonRect, panelRect ) => {
return {
top: buttonRect.bottom - panelRect.height,
left: buttonRect.left - panelRect.width + buttonRect.width,
name: 'nw'
};
}
};
15 changes: 12 additions & 3 deletions src/dropdown/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,13 +150,22 @@ export function addToolbarToDropdown( dropdownView, buttons ) {
*
* items.add( {
* type: 'button',
* model: new Model( { label: 'First item', labelStyle: 'color: red' } )
* model: new Model( {
* withText: true,
* label: 'First item',
* labelStyle: 'color: red'
* } )
* } );
*
* items.add( {
* type: 'button',
* model: new Model( { label: 'Second item', labelStyle: 'color: green', class: 'foo' } )
* } );
* model: new Model( {
* withText: true,
* label: 'Second item',
* labelStyle: 'color: green',
* class: 'foo'
* } )
* } );
*
* const dropdown = createDropdown( locale );
*
Expand Down
8 changes: 8 additions & 0 deletions tests/dropdown/dropdownpanelview.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ describe( 'DropdownPanelView', () => {
view.isVisible = false;
expect( view.element.classList.contains( 'ck-dropdown__panel-visible' ) ).to.be.false;
} );

it( 'reacts on view#position', () => {
expect( view.element.classList.contains( 'ck-dropdown__panel_se' ) ).to.be.true;

view.position = 'nw';
expect( view.element.classList.contains( 'ck-dropdown__panel_se' ) ).to.be.false;
expect( view.element.classList.contains( 'ck-dropdown__panel_nw' ) ).to.be.true;
} );
} );

describe( 'listeners', () => {
Expand Down
124 changes: 124 additions & 0 deletions tests/dropdown/dropdownview.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
import ButtonView from '../../src/button/buttonview';
import DropdownPanelView from '../../src/dropdown/dropdownpanelview';
import global from '@ckeditor/ckeditor5-utils/src/dom/global';
import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';

describe( 'DropdownView', () => {
let view, buttonView, panelView, locale;

testUtils.createSinonSandbox();

beforeEach( () => {
locale = { t() {} };

Expand All @@ -21,6 +26,15 @@ describe( 'DropdownView', () => {

view = new DropdownView( locale, buttonView, panelView );
view.render();

// The #panelView positioning depends on the utility that uses DOM Rects.
// To avoid an avalanche of warnings (DOM Rects do not work until
// the element is in DOM), let's allow the dropdown to render in DOM.
global.document.body.appendChild( view.element );
} );

afterEach( () => {
view.element.remove();
} );

describe( 'constructor()', () => {
Expand All @@ -44,6 +58,10 @@ describe( 'DropdownView', () => {
expect( view.isEnabled ).to.be.true;
} );

it( 'sets view#panelPosition "auto"', () => {
expect( view.panelPosition ).to.equal( 'auto' );
} );

it( 'creates #focusTracker instance', () => {
expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
} );
Expand Down Expand Up @@ -97,6 +115,103 @@ describe( 'DropdownView', () => {
} );
} );

describe( 'view.panelView#position to view#panelPosition', () => {
it( 'does not update until the dropdown is opened', () => {
view.isOpen = false;
view.panelPosition = 'nw';

expect( panelView.position ).to.equal( 'se' );

view.isOpen = true;

expect( panelView.position ).to.equal( 'nw' );
} );

describe( 'in "auto" mode', () => {
beforeEach( () => {
// Bloat the panel a little to give the positioning algorithm something to
// work with. If the panel was empty, any smart positioning is pointless.
// Placing an empty element in the viewport isn't that hard, right?
panelView.element.style.width = '200px';
panelView.element.style.height = '200px';
} );

it( 'defaults to "south-east" when there is a plenty of space around', () => {
const windowRect = new Rect( global.window );

// "Put" the dropdown in the middle of the viewport.
stubElementClientRect( view.buttonView.element, {
top: windowRect.height / 2,
left: windowRect.width / 2,
width: 10,
height: 10
} );

view.isOpen = true;

expect( panelView.position ).to.equal( 'se' );
} );

it( 'when the dropdown in the north-west corner of the viewport', () => {
stubElementClientRect( view.buttonView.element, {
top: 0,
left: 0,
width: 100,
height: 10
} );

view.isOpen = true;

expect( panelView.position ).to.equal( 'se' );
} );

it( 'when the dropdown in the north-east corner of the viewport', () => {
const windowRect = new Rect( global.window );

stubElementClientRect( view.buttonView.element, {
top: 0,
left: windowRect.right - 100,
width: 100,
height: 10
} );

view.isOpen = true;

expect( panelView.position ).to.equal( 'sw' );
} );

it( 'when the dropdown in the south-west corner of the viewport', () => {
const windowRect = new Rect( global.window );

stubElementClientRect( view.buttonView.element, {
top: windowRect.bottom - 10,
left: 0,
width: 100,
height: 10
} );

view.isOpen = true;

expect( panelView.position ).to.equal( 'ne' );
} );

it( 'when the dropdown in the south-east corner of the viewport', () => {
const windowRect = new Rect( global.window );

stubElementClientRect( view.buttonView.element, {
top: windowRect.bottom - 10,
left: windowRect.right - 100,
width: 100,
height: 10
} );

view.isOpen = true;

expect( panelView.position ).to.equal( 'nw' );
} );
} );
} );

describe( 'DOM element bindings', () => {
describe( 'class', () => {
it( 'reacts on view#isEnabled', () => {
Expand Down Expand Up @@ -258,3 +373,12 @@ describe( 'DropdownView', () => {
} );
} );
} );

function stubElementClientRect( element, data ) {
const clientRect = Object.assign( {}, data );

clientRect.right = clientRect.left + clientRect.width;
clientRect.bottom = clientRect.top + clientRect.height;

testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( clientRect );
}
12 changes: 8 additions & 4 deletions tests/dropdown/manual/dropdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,14 @@ function testList() {
const collection = new Collection( { idProperty: 'label' } );

[ '0.8em', '1em', '1.2em', '1.5em', '2.0em', '3.0em' ].forEach( font => {
collection.add( new Model( {
label: font,
style: `font-size: ${ font }`
} ) );
collection.add( {
type: 'button',
model: new Model( {
label: font,
labelStyle: `font-size: ${ font }`,
withText: true
} )
} );
} );

const dropdownView = createDropdown( {} );
Expand Down
Loading

0 comments on commit 8094f19

Please sign in to comment.