| 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447 |
1×
4228×
4228×
4228×
4228×
4228×
4228×
4228×
4228×
4228×
4228×
4228×
4228×
4228×
4228×
1×
4228×
4228×
4228×
1×
4228×
4228×
1×
4228×
4228×
4228×
4228×
4228×
1×
4228×
4228×
4228×
4228×
4228×
1×
1×
1×
1×
14019×
14019×
1×
| /**
* Element that will stick adjacent to a specified container, even when it is inserted elsewhere
* in the document (for example, in an OO.ui.Window's $overlay).
*
* The elements's position is automatically calculated and maintained when window is resized or the
* page is scrolled. If you reposition the container manually, you have to call #position to make
* sure the element is still placed correctly.
*
* As positioning is only possible when both the element and the container are attached to the DOM
* and visible, it's only done after you call #togglePositioning. You might want to do this inside
* the #toggle method to display a floating popup, for example.
*
* @abstract
* @class
*
* @constructor
* @param {Object} [config] Configuration options
* @param {jQuery} [config.$floatable] Node to position, assigned to #$floatable, omit to use #$element
* @param {jQuery} [config.$floatableContainer] Node to position adjacent to
* @param {string} [config.verticalPosition='below'] Where to position $floatable vertically:
* 'below': Directly below $floatableContainer, aligning f's top edge with fC's bottom edge
* 'above': Directly above $floatableContainer, aligning f's bottom edge with fC's top edge
* 'top': Align the top edge with $floatableContainer's top edge
* 'bottom': Align the bottom edge with $floatableContainer's bottom edge
* 'center': Vertically align the center with $floatableContainer's center
* @param {string} [config.horizontalPosition='start'] Where to position $floatable horizontally:
* 'before': Directly before $floatableContainer, aligning f's end edge with fC's start edge
* 'after': Directly after $floatableContainer, aligning f's start edge with fC's end edge
* 'start': Align the start (left in LTR, right in RTL) edge with $floatableContainer's start edge
* 'end': Align the end (right in LTR, left in RTL) edge with $floatableContainer's end edge
* 'center': Horizontally align the center with $floatableContainer's center
* @param {boolean} [config.hideWhenOutOfView=true] Whether to hide the floatable element if the
* container is out of view
* @param {number} [config.spacing=0] Spacing from $floatableContainer, when $floatable is
* positioned outside the container (i.e. below/above/before/after).
*/
OO.ui.mixin.FloatableElement = function OoUiMixinFloatableElement( config ) {
// Configuration initialization
config = config || {};
// Properties
this.$floatable = null;
this.$floatableContainer = null;
this.$floatableWindow = null;
this.$floatableClosestScrollable = null;
this.floatableOutOfView = false;
this.onFloatableScrollHandler = this.position.bind( this );
this.onFloatableWindowResizeHandler = this.position.bind( this );
// Initialization
this.setFloatableContainer( config.$floatableContainer );
this.setFloatableElement( config.$floatable || this.$element );
this.setVerticalPosition( config.verticalPosition || 'below' );
this.setHorizontalPosition( config.horizontalPosition || 'start' );
this.spacing = config.spacing || 0;
this.hideWhenOutOfView = config.hideWhenOutOfView === undefined ?
true : !!config.hideWhenOutOfView;
};
/* Methods */
/**
* Set floatable element.
*
* If an element is already set, it will be cleaned up before setting up the new element.
*
* @param {jQuery} $floatable Element to make floatable
*/
OO.ui.mixin.FloatableElement.prototype.setFloatableElement = function ( $floatable ) {
Iif ( this.$floatable ) {
this.$floatable.removeClass( 'oo-ui-floatableElement-floatable' );
this.$floatable.css( { top: '', left: '', bottom: '', right: '' } );
}
this.$floatable = $floatable.addClass( 'oo-ui-floatableElement-floatable' );
this.position();
};
/**
* Set floatable container.
*
* The element will be positioned relative to the specified container.
*
* @param {jQuery|null} $floatableContainer Container to keep visible, or null to unset
*/
OO.ui.mixin.FloatableElement.prototype.setFloatableContainer = function ( $floatableContainer ) {
this.$floatableContainer = $floatableContainer;
Iif ( this.$floatable ) {
this.position();
}
};
/**
* Change how the element is positioned vertically.
*
* @param {string} position 'below', 'above', 'top', 'bottom' or 'center'
*/
OO.ui.mixin.FloatableElement.prototype.setVerticalPosition = function ( position ) {
Iif ( ![ 'below', 'above', 'top', 'bottom', 'center' ].includes( position ) ) {
throw new Error( 'Invalid value for vertical position: ' + position );
}
Eif ( this.verticalPosition !== position ) {
this.verticalPosition = position;
Eif ( this.$floatable ) {
this.position();
}
}
};
/**
* Change how the element is positioned horizontally.
*
* @param {string} position 'before', 'after', 'start', 'end' or 'center'
*/
OO.ui.mixin.FloatableElement.prototype.setHorizontalPosition = function ( position ) {
Iif ( ![ 'before', 'after', 'start', 'end', 'center' ].includes( position ) ) {
throw new Error( 'Invalid value for horizontal position: ' + position );
}
Eif ( this.horizontalPosition !== position ) {
this.horizontalPosition = position;
Eif ( this.$floatable ) {
this.position();
}
}
};
/**
* Toggle positioning.
*
* Do not turn positioning on until after the element is attached to the DOM and visible.
*
* @param {boolean} [positioning] Enable positioning, omit to toggle
* @chainable
* @return {OO.ui.Element} The element, for chaining
*/
OO.ui.mixin.FloatableElement.prototype.togglePositioning = function ( positioning ) {
if ( !this.$floatable || !this.$floatableContainer ) {
return this;
}
positioning = positioning === undefined ? !this.positioning : !!positioning;
if ( positioning && !this.warnedUnattached && !this.isElementAttached() ) {
OO.ui.warnDeprecation( 'FloatableElement#togglePositioning: Before calling this method, the element must be attached to the DOM.' );
this.warnedUnattached = true;
}
if ( this.positioning !== positioning ) {
this.positioning = positioning;
let closestScrollableOfContainer = OO.ui.Element.static.getClosestScrollableContainer(
this.$floatableContainer[ 0 ]
);
// If the scrollable is the root, we have to listen to scroll events
// on the window because of browser inconsistencies.
if ( $( closestScrollableOfContainer ).is( 'html, body' ) ) {
closestScrollableOfContainer = OO.ui.Element.static.getWindow(
closestScrollableOfContainer
);
}
if ( positioning ) {
this.$floatableWindow = $( this.getElementWindow() );
this.$floatableWindow.on( 'resize', this.onFloatableWindowResizeHandler );
this.$floatableClosestScrollable = $( closestScrollableOfContainer );
this.$floatableClosestScrollable.on( 'scroll', this.onFloatableScrollHandler );
// Initial position after visible
this.position();
} else {
if ( this.$floatableWindow ) {
this.$floatableWindow.off( 'resize', this.onFloatableWindowResizeHandler );
this.$floatableWindow = null;
}
if ( this.$floatableClosestScrollable ) {
this.$floatableClosestScrollable.off( 'scroll', this.onFloatableScrollHandler );
this.$floatableClosestScrollable = null;
}
this.$floatable.css( { top: '', left: '', bottom: '', right: '' } );
}
}
return this;
};
/**
* Check whether the bottom edge of the given element is within the viewport of the given
* container.
*
* @private
* @param {jQuery} $element
* @param {jQuery} $container
* @return {boolean}
*/
OO.ui.mixin.FloatableElement.prototype.isElementInViewport = function ( $element, $container ) {
const direction = $element.css( 'direction' );
const elemRect = $element[ 0 ].getBoundingClientRect();
let contRect;
if ( $container[ 0 ] === window ) {
const viewportSpacing = OO.ui.getViewportSpacing();
contRect = {
top: 0,
left: 0,
right: document.documentElement.clientWidth,
bottom: document.documentElement.clientHeight
};
contRect.top += viewportSpacing.top;
contRect.left += viewportSpacing.left;
contRect.right -= viewportSpacing.right;
contRect.bottom -= viewportSpacing.bottom;
} else {
contRect = $container[ 0 ].getBoundingClientRect();
}
const topEdgeInBounds = elemRect.top >= contRect.top && elemRect.top <= contRect.bottom;
const bottomEdgeInBounds = elemRect.bottom >= contRect.top && elemRect.bottom <= contRect.bottom;
const leftEdgeInBounds = elemRect.left >= contRect.left && elemRect.left <= contRect.right;
const rightEdgeInBounds = elemRect.right >= contRect.left && elemRect.right <= contRect.right;
let startEdgeInBounds, endEdgeInBounds;
if ( direction === 'rtl' ) {
startEdgeInBounds = rightEdgeInBounds;
endEdgeInBounds = leftEdgeInBounds;
} else {
startEdgeInBounds = leftEdgeInBounds;
endEdgeInBounds = rightEdgeInBounds;
}
if ( this.verticalPosition === 'below' && !bottomEdgeInBounds ) {
return false;
}
if ( this.verticalPosition === 'above' && !topEdgeInBounds ) {
return false;
}
if ( this.horizontalPosition === 'before' && !startEdgeInBounds ) {
return false;
}
if ( this.horizontalPosition === 'after' && !endEdgeInBounds ) {
return false;
}
// The other positioning values are all about being inside the container,
// so in those cases all we care about is that any part of the container is visible.
return elemRect.top <= contRect.bottom && elemRect.bottom >= contRect.top &&
elemRect.left <= contRect.right && elemRect.right >= contRect.left;
};
/**
* Check if the floatable is hidden to the user because it was offscreen.
*
* @return {boolean} Floatable is out of view
*/
OO.ui.mixin.FloatableElement.prototype.isFloatableOutOfView = function () {
return this.floatableOutOfView;
};
/**
* Position the floatable below its container.
*
* This should only be done when both of them are attached to the DOM and visible.
*
* @chainable
* @return {OO.ui.Element} The element, for chaining
*/
OO.ui.mixin.FloatableElement.prototype.position = function () {
Eif ( !this.positioning ) {
return this;
}
if ( !(
// To continue, some things need to be true:
// The element must actually be in the DOM
this.isElementAttached() && (
// The closest scrollable is the current window
this.$floatableClosestScrollable[ 0 ] === this.getElementWindow() ||
// OR is an element in the element's DOM
$.contains( this.getElementDocument(), this.$floatableClosestScrollable[ 0 ] )
)
) ) {
// Abort early if important parts of the widget are no longer attached to the DOM
return this;
}
this.floatableOutOfView = this.hideWhenOutOfView &&
!this.isElementInViewport( this.$floatableContainer, this.$floatableClosestScrollable );
this.$floatable.toggleClass( 'oo-ui-element-hidden', this.floatableOutOfView );
if ( this.floatableOutOfView ) {
return this;
}
this.$floatable.css( this.computePosition() );
// We updated the position, so re-evaluate the clipping state.
// (ClippableElement does not listen to 'scroll' events on $floatableContainer's parent, and so
// will not notice the need to update itself.)
// TODO: This is terrible, we shouldn't need to know about ClippableElement at all here.
// Why does it not listen to the right events in the right places?
if ( this.clip ) {
this.clip();
}
return this;
};
/**
* Compute how #$floatable should be positioned based on the position of #$floatableContainer
* and the positioning settings. This is a helper for #position that shouldn't be called directly,
* but may be overridden by subclasses if they want to change or add to the positioning logic.
*
* @return {Object} New position to apply with .css(). Keys are 'top', 'left', 'bottom' and 'right'.
*/
OO.ui.mixin.FloatableElement.prototype.computePosition = function () {
const newPos = { top: '', left: '', bottom: '', right: '' };
const direction = this.$floatableContainer.css( 'direction' );
const viewportSpacing = OO.ui.getViewportSpacing();
let $offsetParent = this.$floatable.offsetParent();
if ( $offsetParent.is( 'html' ) ) {
// The innerHeight/Width and clientHeight/Width calculations don't work well on the
// <html> element, but they do work on the <body>
$offsetParent = $( $offsetParent[ 0 ].ownerDocument.body );
}
const isBody = $offsetParent.is( 'body' );
const scrollableX = $offsetParent.css( 'overflow-x' ) === 'scroll' ||
$offsetParent.css( 'overflow-x' ) === 'auto';
const scrollableY = $offsetParent.css( 'overflow-y' ) === 'scroll' ||
$offsetParent.css( 'overflow-y' ) === 'auto';
const vertScrollbarWidth = $offsetParent.innerWidth() - $offsetParent.prop( 'clientWidth' );
const horizScrollbarHeight = $offsetParent.innerHeight() - $offsetParent.prop( 'clientHeight' );
// We don't need to compute and add scrollTop and scrollLeft if the scrollable container
// is the body, or if it isn't scrollable
const scrollTop = scrollableY && !isBody ?
$offsetParent.scrollTop() : 0;
const scrollLeft = scrollableX && !isBody ?
OO.ui.Element.static.getScrollLeft( $offsetParent[ 0 ] ) : 0;
// Avoid passing the <body> to getRelativePosition(), because it won't return what we expect
// if the <body> has a margin
const containerPos = isBody ?
this.$floatableContainer.offset() :
OO.ui.Element.static.getRelativePosition( this.$floatableContainer, $offsetParent );
containerPos.bottom = containerPos.top + this.$floatableContainer.outerHeight();
containerPos.right = containerPos.left + this.$floatableContainer.outerWidth();
containerPos.start = direction === 'rtl' ? containerPos.right : containerPos.left;
containerPos.end = direction === 'rtl' ? containerPos.left : containerPos.right;
if ( this.verticalPosition === 'below' ) {
newPos.top = containerPos.bottom + this.spacing;
// Adjust for viewport spacing (e.g. sticky headers) when attached to body
if ( isBody ) {
newPos.top += viewportSpacing.top;
}
} else if ( this.verticalPosition === 'above' ) {
newPos.bottom = $offsetParent.outerHeight() - containerPos.top + this.spacing;
// Adjust for viewport spacing (e.g. sticky footers) when attached to body
if ( isBody ) {
newPos.bottom += viewportSpacing.bottom;
}
} else if ( this.verticalPosition === 'top' ) {
newPos.top = containerPos.top;
// Adjust for viewport spacing when attached to body
if ( isBody ) {
newPos.top += viewportSpacing.top;
}
} else if ( this.verticalPosition === 'bottom' ) {
newPos.bottom = $offsetParent.outerHeight() - containerPos.bottom;
// Adjust for viewport spacing when attached to body
if ( isBody ) {
newPos.bottom += viewportSpacing.bottom;
}
} else if ( this.verticalPosition === 'center' ) {
newPos.top = containerPos.top +
( this.$floatableContainer.height() - this.$floatable.height() ) / 2;
// Adjust for viewport spacing when attached to body
if ( isBody ) {
newPos.top += viewportSpacing.top;
}
}
if ( this.horizontalPosition === 'before' ) {
newPos.end = containerPos.start - this.spacing;
} else if ( this.horizontalPosition === 'after' ) {
newPos.start = containerPos.end + this.spacing;
} else if ( this.horizontalPosition === 'start' ) {
newPos.start = containerPos.start;
} else if ( this.horizontalPosition === 'end' ) {
newPos.end = containerPos.end;
} else if ( this.horizontalPosition === 'center' ) {
newPos.left = containerPos.left +
( this.$floatableContainer.width() - this.$floatable.width() ) / 2;
}
if ( newPos.start !== undefined ) {
if ( direction === 'rtl' ) {
newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) :
$offsetParent ).outerWidth() - newPos.start;
} else {
newPos.left = newPos.start;
}
delete newPos.start;
}
if ( newPos.end !== undefined ) {
if ( direction === 'rtl' ) {
newPos.left = newPos.end;
} else {
newPos.right = ( isBody ? $( $offsetParent[ 0 ].ownerDocument.documentElement ) :
$offsetParent ).outerWidth() - newPos.end;
}
delete newPos.end;
}
// Account for scroll position
if ( newPos.top !== '' ) {
newPos.top += scrollTop;
}
if ( newPos.bottom !== '' ) {
newPos.bottom -= scrollTop;
}
if ( newPos.left !== '' ) {
newPos.left += scrollLeft;
}
if ( newPos.right !== '' ) {
newPos.right -= scrollLeft;
}
// Account for scrollbar gutter
if ( newPos.bottom !== '' ) {
newPos.bottom -= horizScrollbarHeight;
}
if ( direction === 'rtl' ) {
if ( newPos.left !== '' ) {
newPos.left -= vertScrollbarWidth;
}
} else {
if ( newPos.right !== '' ) {
newPos.right -= vertScrollbarWidth;
}
}
return newPos;
};
|