diff --git a/src/dropdown/dropdownpanelview.js b/src/dropdown/dropdownpanelview.js index f1ba9534..a49fe22b 100644 --- a/src/dropdown/dropdownpanelview.js +++ b/src/dropdown/dropdownpanelview.js @@ -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. * @@ -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' ) ] }, diff --git a/src/dropdown/dropdownview.js b/src/dropdown/dropdownview.js index 52a33e13..40b68da2 100644 --- a/src/dropdown/dropdownview.js +++ b/src/dropdown/dropdownview.js @@ -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. * @@ -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. * @@ -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 ); @@ -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' + }; + } +}; diff --git a/src/dropdown/utils.js b/src/dropdown/utils.js index aa8fd7f1..8bbe70bd 100644 --- a/src/dropdown/utils.js +++ b/src/dropdown/utils.js @@ -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 ); * diff --git a/tests/dropdown/dropdownpanelview.js b/tests/dropdown/dropdownpanelview.js index a843d561..d5384ed1 100644 --- a/tests/dropdown/dropdownpanelview.js +++ b/tests/dropdown/dropdownpanelview.js @@ -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', () => { diff --git a/tests/dropdown/dropdownview.js b/tests/dropdown/dropdownview.js index e9f1ff38..eb555c18 100644 --- a/tests/dropdown/dropdownview.js +++ b/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 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() {} }; @@ -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()', () => { @@ -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 ); } ); @@ -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', () => { @@ -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 ); +} diff --git a/tests/dropdown/manual/dropdown.js b/tests/dropdown/manual/dropdown.js index a04c3770..f602014c 100644 --- a/tests/dropdown/manual/dropdown.js +++ b/tests/dropdown/manual/dropdown.js @@ -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( {} ); diff --git a/tests/dropdown/manual/panelposition.html b/tests/dropdown/manual/panelposition.html new file mode 100644 index 00000000..13e6ea99 --- /dev/null +++ b/tests/dropdown/manual/panelposition.html @@ -0,0 +1,28 @@ + + +
+ + + diff --git a/tests/dropdown/manual/panelposition.js b/tests/dropdown/manual/panelposition.js new file mode 100644 index 00000000..2c848a99 --- /dev/null +++ b/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' ); diff --git a/tests/dropdown/manual/panelposition.md b/tests/dropdown/manual/panelposition.md new file mode 100644 index 00000000..89db518a --- /dev/null +++ b/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. diff --git a/theme/components/dropdown/dropdown.css b/theme/components/dropdown/dropdown.css index adab9948..8589dcc9 100644 --- a/theme/components/dropdown/dropdown.css +++ b/theme/components/dropdown/dropdown.css @@ -34,8 +34,6 @@ z-index: var(--ck-z-modal); position: absolute; - left: 0px; - transform: translate3d( 0, 100%, 0 ); &.ck-dropdown__panel-visible { display: inline-block; @@ -43,5 +41,25 @@ /* This will prevent blurry icons in dropdown on Firefox. See #340. */ 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; + } } }