Преглед изворни кода

Merge pull request #449 from ckeditor/t/123

Feature: Implemented configurable, smart `DropdownView` panel positioning. Closes #123.
Oskar Wróbel пре 7 година
родитељ
комит
8ba9527c34

+ 13 - 0
packages/ckeditor5-ui/src/dropdown/dropdownpanelview.js

@@ -33,6 +33,18 @@ export default class DropdownPanelView extends View {
 		 */
 		 */
 		this.set( 'isVisible', false );
 		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.
 		 * Collection of the child views in this panel.
 		 *
 		 *
@@ -53,6 +65,7 @@ export default class DropdownPanelView extends View {
 					'ck',
 					'ck',
 					'ck-reset',
 					'ck-reset',
 					'ck-dropdown__panel',
 					'ck-dropdown__panel',
+					bind.to( 'position', value => `ck-dropdown__panel_${ value }` ),
 					bind.if( 'isVisible', 'ck-dropdown__panel-visible' )
 					bind.if( 'isVisible', 'ck-dropdown__panel-visible' )
 				]
 				]
 			},
 			},

+ 126 - 0
packages/ckeditor5-ui/src/dropdown/dropdownview.js

@@ -13,6 +13,8 @@ import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
 
 
 import '../../theme/components/dropdown/dropdown.css';
 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.
  * The dropdown view class. It manages the dropdown button and dropdown panel.
  *
  *
@@ -128,6 +130,23 @@ export default class DropdownView extends View {
 		 */
 		 */
 		this.set( 'class' );
 		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.
 		 * Tracks information about DOM focus in the dropdown.
 		 *
 		 *
@@ -224,6 +243,34 @@ export default class DropdownView extends View {
 		// Toggle the visibility of the panel when the dropdown becomes open.
 		// Toggle the visibility of the panel when the dropdown becomes open.
 		this.panelView.bind( 'isVisible' ).to( this, 'isOpen' );
 		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.
 		// Listen for keystrokes coming from within #element.
 		this.keystrokes.listenTo( this.element );
 		this.keystrokes.listenTo( this.element );
 
 
@@ -266,3 +313,82 @@ export default class DropdownView extends View {
 		this.buttonView.focus();
 		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'
+		};
+	}
+};

+ 12 - 3
packages/ckeditor5-ui/src/dropdown/utils.js

@@ -150,13 +150,22 @@ export function addToolbarToDropdown( dropdownView, buttons ) {
  *
  *
  *		items.add( {
  *		items.add( {
  *			type: 'button',
  *			type: 'button',
- *			model: new Model( { label: 'First item', labelStyle: 'color: red' } )
+ *			model: new Model( {
+ *				withText: true,
+ *				label: 'First item',
+ *				labelStyle: 'color: red'
+ *			} )
  *		} );
  *		} );
  *
  *
  *		items.add( {
  *		items.add( {
  *			 type: 'button',
  *			 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 );
  *		const dropdown = createDropdown( locale );
  *
  *

+ 8 - 0
packages/ckeditor5-ui/tests/dropdown/dropdownpanelview.js

@@ -50,6 +50,14 @@ describe( 'DropdownPanelView', () => {
 					view.isVisible = false;
 					view.isVisible = false;
 					expect( view.element.classList.contains( 'ck-dropdown__panel-visible' ) ).to.be.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', () => {
 			describe( 'listeners', () => {

+ 124 - 0
packages/ckeditor5-ui/tests/dropdown/dropdownview.js

@@ -9,10 +9,15 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import ButtonView from '../../src/button/buttonview';
 import ButtonView from '../../src/button/buttonview';
 import DropdownPanelView from '../../src/dropdown/dropdownpanelview';
 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', () => {
 describe( 'DropdownView', () => {
 	let view, buttonView, panelView, locale;
 	let view, buttonView, panelView, locale;
 
 
+	testUtils.createSinonSandbox();
+
 	beforeEach( () => {
 	beforeEach( () => {
 		locale = { t() {} };
 		locale = { t() {} };
 
 
@@ -21,6 +26,15 @@ describe( 'DropdownView', () => {
 
 
 		view = new DropdownView( locale, buttonView, panelView );
 		view = new DropdownView( locale, buttonView, panelView );
 		view.render();
 		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()', () => {
 	describe( 'constructor()', () => {
@@ -44,6 +58,10 @@ describe( 'DropdownView', () => {
 			expect( view.isEnabled ).to.be.true;
 			expect( view.isEnabled ).to.be.true;
 		} );
 		} );
 
 
+		it( 'sets view#panelPosition "auto"', () => {
+			expect( view.panelPosition ).to.equal( 'auto' );
+		} );
+
 		it( 'creates #focusTracker instance', () => {
 		it( 'creates #focusTracker instance', () => {
 			expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
 			expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
 		} );
 		} );
@@ -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( 'DOM element bindings', () => {
 				describe( 'class', () => {
 				describe( 'class', () => {
 					it( 'reacts on view#isEnabled', () => {
 					it( 'reacts on view#isEnabled', () => {
@@ -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 );
+}

+ 8 - 4
packages/ckeditor5-ui/tests/dropdown/manual/dropdown.js

@@ -45,10 +45,14 @@ function testList() {
 	const collection = new Collection( { idProperty: 'label' } );
 	const collection = new Collection( { idProperty: 'label' } );
 
 
 	[ '0.8em', '1em', '1.2em', '1.5em', '2.0em', '3.0em' ].forEach( font => {
 	[ '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( {} );
 	const dropdownView = createDropdown( {} );

+ 28 - 0
packages/ckeditor5-ui/tests/dropdown/manual/panelposition.html

@@ -0,0 +1,28 @@
+<style>
+	div[id^="dropdown-"].ck {
+		position: fixed;
+	}
+
+	#dropdown-nw {
+		top: 20px;
+	}
+
+	#dropdown-ne {
+		top: 20px;
+		right: 20px;
+	}
+
+	#dropdown-sw {
+		bottom: 20px;
+	}
+
+	#dropdown-se {
+		bottom: 20px;
+		right: 20px;
+	}
+</style>
+
+<div id="dropdown-nw" class="ck ck-reset_all"></div>
+<div id="dropdown-ne" class="ck ck-reset_all"></div>
+<div id="dropdown-se" class="ck ck-reset_all"></div>
+<div id="dropdown-sw" class="ck ck-reset_all"></div>

+ 49 - 0
packages/ckeditor5-ui/tests/dropdown/manual/panelposition.js

@@ -0,0 +1,49 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import Model from '../../../src/model';
+import Collection from '@ckeditor/ckeditor5-utils/src/collection';
+import testUtils from '../../_utils/utils';
+import { createDropdown, addListToDropdown } from '../../../src/dropdown/utils';
+
+const ui = testUtils.createTestUIView( {
+	dropdownNW: '#dropdown-nw',
+	dropdownNE: '#dropdown-ne',
+	dropdownSE: '#dropdown-se',
+	dropdownSW: '#dropdown-sw'
+} );
+
+function createPositionedDropdown( position ) {
+	const collection = new Collection( { idProperty: 'label' } );
+
+	[
+		'long label of a first item of the list',
+		'long label of a second item of the list',
+		'long label of a third item of the list'
+	].forEach( label => {
+		collection.add( {
+			type: 'button',
+			model: new Model( { label, withText: true } )
+		} );
+	} );
+
+	const dropdownView = createDropdown( {} );
+
+	dropdownView.buttonView.set( {
+		label: `Dropdown ${ position }`,
+		isEnabled: true,
+		isOn: false,
+		withText: true
+	} );
+
+	addListToDropdown( dropdownView, collection );
+
+	ui[ `dropdown${ position }` ].add( dropdownView );
+}
+
+createPositionedDropdown( 'NW' );
+createPositionedDropdown( 'NE' );
+createPositionedDropdown( 'SW' );
+createPositionedDropdown( 'SE' );

+ 5 - 0
packages/ckeditor5-ui/tests/dropdown/manual/panelposition.md

@@ -0,0 +1,5 @@
+## Automatic position of the dropdown panel
+
+1. Open a dropdown located in the corner of the page.
+2. Check if the panel shows up fully visible in the viewport.
+3. Repeat for the rest of dropdowns. Panels should appear above, below, right and left to the button.

+ 20 - 2
packages/ckeditor5-ui/theme/components/dropdown/dropdown.css

@@ -34,8 +34,6 @@
 		z-index: var(--ck-z-modal);
 		z-index: var(--ck-z-modal);
 
 
 		position: absolute;
 		position: absolute;
-		left: 0px;
-		transform: translate3d( 0, 100%, 0 );
 
 
 		&.ck-dropdown__panel-visible {
 		&.ck-dropdown__panel-visible {
 			display: inline-block;
 			display: inline-block;
@@ -43,5 +41,25 @@
 			/* This will prevent blurry icons in dropdown on Firefox. See #340. */
 			/* This will prevent blurry icons in dropdown on Firefox. See #340. */
 			will-change: transform;
 			will-change: transform;
 		}
 		}
+
+		&.ck-dropdown__panel_ne,
+		&.ck-dropdown__panel_nw {
+			bottom: 100%;
+		}
+
+		&.ck-dropdown__panel_se,
+		&.ck-dropdown__panel_sw {
+			transform: translate3d( 0, 100%, 0 );
+		}
+
+		&.ck-dropdown__panel_ne,
+		&.ck-dropdown__panel_se {
+			left: 0px;
+		}
+
+		&.ck-dropdown__panel_nw,
+		&.ck-dropdown__panel_sw {
+			right: 0px;
+		}
 	}
 	}
 }
 }