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

Commit 8094f19

Browse files
authored
Merge pull request #449 from ckeditor/t/123
Feature: Implemented configurable, smart `DropdownView` panel positioning. Closes #123.
2 parents 9cdcd4a + d5e702c commit 8094f19

File tree

10 files changed

+393
-9
lines changed

10 files changed

+393
-9
lines changed

src/dropdown/dropdownpanelview.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ export default class DropdownPanelView extends View {
3333
*/
3434
this.set( 'isVisible', false );
3535

36+
/**
37+
* The position of the panel, relative to the parent.
38+
*
39+
* This property is reflected in the CSS class set to {@link #element} that controls
40+
* the position of the panel.
41+
*
42+
* @observable
43+
* @default 'se'
44+
* @member {'se'|'sw'|'ne'|'nw'} #position
45+
*/
46+
this.set( 'position', 'se' );
47+
3648
/**
3749
* Collection of the child views in this panel.
3850
*
@@ -53,6 +65,7 @@ export default class DropdownPanelView extends View {
5365
'ck',
5466
'ck-reset',
5567
'ck-dropdown__panel',
68+
bind.to( 'position', value => `ck-dropdown__panel_${ value }` ),
5669
bind.if( 'isVisible', 'ck-dropdown__panel-visible' )
5770
]
5871
},

src/dropdown/dropdownview.js

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
1313

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

16+
import { getOptimalPosition } from '@ckeditor/ckeditor5-utils/src/dom/position';
17+
1618
/**
1719
* The dropdown view class. It manages the dropdown button and dropdown panel.
1820
*
@@ -128,6 +130,23 @@ export default class DropdownView extends View {
128130
*/
129131
this.set( 'class' );
130132

133+
/**
134+
* The position of the panel, relative to the dropdown.
135+
*
136+
* **Note**: When `'auto'`, the panel will use one of the remaining positions to stay
137+
* in the viewport, visible to the user. The positions correspond directly to
138+
* {@link module:ui/dropdown/dropdownview~DropdownView.defaultPanelPositions default panel positions}.
139+
*
140+
* **Note**: This value has an impact on the
141+
* {@link module:ui/dropdown/dropdownpanelview~DropdownPanelView#position} property
142+
* each time the panel becomes {@link #isOpen open}.
143+
*
144+
* @observable
145+
* @default 'auto'
146+
* @member {'auto'|'se'|'sw'|'ne'|'nw'} #panelPosition
147+
*/
148+
this.set( 'panelPosition', 'auto' );
149+
131150
/**
132151
* Tracks information about DOM focus in the dropdown.
133152
*
@@ -224,6 +243,34 @@ export default class DropdownView extends View {
224243
// Toggle the visibility of the panel when the dropdown becomes open.
225244
this.panelView.bind( 'isVisible' ).to( this, 'isOpen' );
226245

246+
// Let the dropdown control the position of the panel. The position must
247+
// be updated every time the dropdown is open.
248+
this.on( 'change:isOpen', () => {
249+
if ( !this.isOpen ) {
250+
return;
251+
}
252+
253+
// If "auto", find the best position of the panel to fit into the viewport.
254+
// Otherwise, simply assign the static position.
255+
if ( this.panelPosition === 'auto' ) {
256+
const defaultPanelPositions = DropdownView.defaultPanelPositions;
257+
258+
this.panelView.position = getOptimalPosition( {
259+
element: this.panelView.element,
260+
target: this.buttonView.element,
261+
fitInViewport: true,
262+
positions: [
263+
defaultPanelPositions.southEast,
264+
defaultPanelPositions.southWest,
265+
defaultPanelPositions.northEast,
266+
defaultPanelPositions.northWest
267+
]
268+
} ).name;
269+
} else {
270+
this.panelView.position = this.panelPosition;
271+
}
272+
} );
273+
227274
// Listen for keystrokes coming from within #element.
228275
this.keystrokes.listenTo( this.element );
229276

@@ -266,3 +313,82 @@ export default class DropdownView extends View {
266313
this.buttonView.focus();
267314
}
268315
}
316+
317+
/**
318+
* A set of positioning functions used by the dropdown view to determine
319+
* the optimal position (i.e. fitting into the browser viewport) of its
320+
* {@link module:ui/dropdown/dropdownview~DropdownView#panelView panel} when
321+
* {@link module:ui/dropdown/dropdownview~DropdownView#panelPosition} is set to 'auto'`.
322+
*
323+
* The available positioning functions are as follow:
324+
*
325+
* **South**
326+
*
327+
* * `southEast`
328+
*
329+
* [ Button ]
330+
* +-----------------+
331+
* | Panel |
332+
* +-----------------+
333+
*
334+
* * `southWest`
335+
*
336+
* [ Button ]
337+
* +-----------------+
338+
* | Panel |
339+
* +-----------------+
340+
*
341+
* **North**
342+
*
343+
* * `northEast`
344+
*
345+
* +-----------------+
346+
* | Panel |
347+
* +-----------------+
348+
* [ Button ]
349+
*
350+
* * `northWest`
351+
*
352+
* +-----------------+
353+
* | Panel |
354+
* +-----------------+
355+
* [ Button ]
356+
*
357+
* Positioning functions are compatible with {@link module:utils/dom/position~Position}.
358+
*
359+
* The name that position function returns will be reflected in dropdown panel's class that
360+
* controls its placement. See {@link module:ui/dropdown/dropdownview~DropdownView#panelPosition}
361+
* to learn more.
362+
*
363+
* @member {Object} module:ui/dropdown/dropdownview~DropdownView.defaultPanelPositions
364+
*/
365+
DropdownView.defaultPanelPositions = {
366+
southEast: buttonRect => {
367+
return {
368+
top: buttonRect.bottom,
369+
left: buttonRect.left,
370+
name: 'se'
371+
};
372+
},
373+
southWest: ( buttonRect, panelRect ) => {
374+
return {
375+
top: buttonRect.bottom,
376+
left: buttonRect.left - panelRect.width + buttonRect.width,
377+
name: 'sw'
378+
};
379+
},
380+
northEast: ( buttonRect, panelRect ) => {
381+
return {
382+
top: buttonRect.top - panelRect.height,
383+
left: buttonRect.left,
384+
name: 'ne'
385+
};
386+
},
387+
northWest: ( buttonRect, panelRect ) => {
388+
return {
389+
top: buttonRect.bottom - panelRect.height,
390+
left: buttonRect.left - panelRect.width + buttonRect.width,
391+
name: 'nw'
392+
};
393+
}
394+
};

src/dropdown/utils.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,22 @@ export function addToolbarToDropdown( dropdownView, buttons ) {
150150
*
151151
* items.add( {
152152
* type: 'button',
153-
* model: new Model( { label: 'First item', labelStyle: 'color: red' } )
153+
* model: new Model( {
154+
* withText: true,
155+
* label: 'First item',
156+
* labelStyle: 'color: red'
157+
* } )
154158
* } );
155159
*
156160
* items.add( {
157161
* type: 'button',
158-
* model: new Model( { label: 'Second item', labelStyle: 'color: green', class: 'foo' } )
159-
* } );
162+
* model: new Model( {
163+
* withText: true,
164+
* label: 'Second item',
165+
* labelStyle: 'color: green',
166+
* class: 'foo'
167+
* } )
168+
* } );
160169
*
161170
* const dropdown = createDropdown( locale );
162171
*

tests/dropdown/dropdownpanelview.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ describe( 'DropdownPanelView', () => {
5050
view.isVisible = false;
5151
expect( view.element.classList.contains( 'ck-dropdown__panel-visible' ) ).to.be.false;
5252
} );
53+
54+
it( 'reacts on view#position', () => {
55+
expect( view.element.classList.contains( 'ck-dropdown__panel_se' ) ).to.be.true;
56+
57+
view.position = 'nw';
58+
expect( view.element.classList.contains( 'ck-dropdown__panel_se' ) ).to.be.false;
59+
expect( view.element.classList.contains( 'ck-dropdown__panel_nw' ) ).to.be.true;
60+
} );
5361
} );
5462

5563
describe( 'listeners', () => {

tests/dropdown/dropdownview.js

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker';
99
import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
1010
import ButtonView from '../../src/button/buttonview';
1111
import DropdownPanelView from '../../src/dropdown/dropdownpanelview';
12+
import global from '@ckeditor/ckeditor5-utils/src/dom/global';
13+
import Rect from '@ckeditor/ckeditor5-utils/src/dom/rect';
14+
import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
1215

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

19+
testUtils.createSinonSandbox();
20+
1621
beforeEach( () => {
1722
locale = { t() {} };
1823

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

2227
view = new DropdownView( locale, buttonView, panelView );
2328
view.render();
29+
30+
// The #panelView positioning depends on the utility that uses DOM Rects.
31+
// To avoid an avalanche of warnings (DOM Rects do not work until
32+
// the element is in DOM), let's allow the dropdown to render in DOM.
33+
global.document.body.appendChild( view.element );
34+
} );
35+
36+
afterEach( () => {
37+
view.element.remove();
2438
} );
2539

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

61+
it( 'sets view#panelPosition "auto"', () => {
62+
expect( view.panelPosition ).to.equal( 'auto' );
63+
} );
64+
4765
it( 'creates #focusTracker instance', () => {
4866
expect( view.focusTracker ).to.be.instanceOf( FocusTracker );
4967
} );
@@ -97,6 +115,103 @@ describe( 'DropdownView', () => {
97115
} );
98116
} );
99117

118+
describe( 'view.panelView#position to view#panelPosition', () => {
119+
it( 'does not update until the dropdown is opened', () => {
120+
view.isOpen = false;
121+
view.panelPosition = 'nw';
122+
123+
expect( panelView.position ).to.equal( 'se' );
124+
125+
view.isOpen = true;
126+
127+
expect( panelView.position ).to.equal( 'nw' );
128+
} );
129+
130+
describe( 'in "auto" mode', () => {
131+
beforeEach( () => {
132+
// Bloat the panel a little to give the positioning algorithm something to
133+
// work with. If the panel was empty, any smart positioning is pointless.
134+
// Placing an empty element in the viewport isn't that hard, right?
135+
panelView.element.style.width = '200px';
136+
panelView.element.style.height = '200px';
137+
} );
138+
139+
it( 'defaults to "south-east" when there is a plenty of space around', () => {
140+
const windowRect = new Rect( global.window );
141+
142+
// "Put" the dropdown in the middle of the viewport.
143+
stubElementClientRect( view.buttonView.element, {
144+
top: windowRect.height / 2,
145+
left: windowRect.width / 2,
146+
width: 10,
147+
height: 10
148+
} );
149+
150+
view.isOpen = true;
151+
152+
expect( panelView.position ).to.equal( 'se' );
153+
} );
154+
155+
it( 'when the dropdown in the north-west corner of the viewport', () => {
156+
stubElementClientRect( view.buttonView.element, {
157+
top: 0,
158+
left: 0,
159+
width: 100,
160+
height: 10
161+
} );
162+
163+
view.isOpen = true;
164+
165+
expect( panelView.position ).to.equal( 'se' );
166+
} );
167+
168+
it( 'when the dropdown in the north-east corner of the viewport', () => {
169+
const windowRect = new Rect( global.window );
170+
171+
stubElementClientRect( view.buttonView.element, {
172+
top: 0,
173+
left: windowRect.right - 100,
174+
width: 100,
175+
height: 10
176+
} );
177+
178+
view.isOpen = true;
179+
180+
expect( panelView.position ).to.equal( 'sw' );
181+
} );
182+
183+
it( 'when the dropdown in the south-west corner of the viewport', () => {
184+
const windowRect = new Rect( global.window );
185+
186+
stubElementClientRect( view.buttonView.element, {
187+
top: windowRect.bottom - 10,
188+
left: 0,
189+
width: 100,
190+
height: 10
191+
} );
192+
193+
view.isOpen = true;
194+
195+
expect( panelView.position ).to.equal( 'ne' );
196+
} );
197+
198+
it( 'when the dropdown in the south-east corner of the viewport', () => {
199+
const windowRect = new Rect( global.window );
200+
201+
stubElementClientRect( view.buttonView.element, {
202+
top: windowRect.bottom - 10,
203+
left: windowRect.right - 100,
204+
width: 100,
205+
height: 10
206+
} );
207+
208+
view.isOpen = true;
209+
210+
expect( panelView.position ).to.equal( 'nw' );
211+
} );
212+
} );
213+
} );
214+
100215
describe( 'DOM element bindings', () => {
101216
describe( 'class', () => {
102217
it( 'reacts on view#isEnabled', () => {
@@ -258,3 +373,12 @@ describe( 'DropdownView', () => {
258373
} );
259374
} );
260375
} );
376+
377+
function stubElementClientRect( element, data ) {
378+
const clientRect = Object.assign( {}, data );
379+
380+
clientRect.right = clientRect.left + clientRect.width;
381+
clientRect.bottom = clientRect.top + clientRect.height;
382+
383+
testUtils.sinon.stub( element, 'getBoundingClientRect' ).returns( clientRect );
384+
}

tests/dropdown/manual/dropdown.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,14 @@ function testList() {
4545
const collection = new Collection( { idProperty: 'label' } );
4646

4747
[ '0.8em', '1em', '1.2em', '1.5em', '2.0em', '3.0em' ].forEach( font => {
48-
collection.add( new Model( {
49-
label: font,
50-
style: `font-size: ${ font }`
51-
} ) );
48+
collection.add( {
49+
type: 'button',
50+
model: new Model( {
51+
label: font,
52+
labelStyle: `font-size: ${ font }`,
53+
withText: true
54+
} )
55+
} );
5256
} );
5357

5458
const dropdownView = createDropdown( {} );

0 commit comments

Comments
 (0)