You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
633 lines
22 KiB
633 lines
22 KiB
/** |
|
* @file |
|
* A modified version of jQuery UI position. |
|
* |
|
* Per jQuery UI's public domain license, it is permissible to run modified |
|
* versions of their code. This file offers the same functionality as what is |
|
* provided by jQuery UI position, but refactored to meet Drupal coding |
|
* standards, and restructured so it extends jQuery core instead of jQuery UI. |
|
* |
|
* This is provided to support pre-existing code that expects the jQuery |
|
* position API. |
|
* |
|
* @see https://github.com/jquery/jquery-ui/blob/1.12.1/LICENSE.txt |
|
* @see https://raw.githubusercontent.com/jquery/jquery-ui/1.12.1/ui/position.js |
|
*/ |
|
|
|
/** |
|
* This provides ported version of jQuery UI position, refactored to not depend |
|
* on jQuery UI and to meet Drupal JavaScript coding standards. Functionality |
|
* and usage is identical. It positions an element relative to another. The |
|
* `position()` function can be called by any jQuery object. Additional details |
|
* on using `position()` are provided in this file in the docblock for |
|
* $.fn.position. |
|
*/ |
|
(($) => { |
|
let cachedScrollbarWidth = null; |
|
const { max, abs } = Math; |
|
const regexHorizontal = /left|center|right/; |
|
const regexVertical = /top|center|bottom/; |
|
const regexOffset = /[+-]\d+(\.[\d]+)?%?/; |
|
const regexPosition = /^\w+/; |
|
const _position = $.fn.position; |
|
|
|
function getOffsets(offsets, width, height) { |
|
return [ |
|
parseFloat(offsets[0]) * |
|
(typeof offsets[0] === 'string' && offsets[0].endsWith('%') |
|
? width / 100 |
|
: 1), |
|
parseFloat(offsets[1]) * |
|
(typeof offsets[1] === 'string' && offsets[1].endsWith('%') |
|
? height / 100 |
|
: 1), |
|
]; |
|
} |
|
|
|
function parseCss(element, property) { |
|
return parseInt(window.getComputedStyle(element)[property], 10) || 0; |
|
} |
|
|
|
function getDimensions(elem) { |
|
const raw = elem[0]; |
|
if (raw.nodeType === Node.DOCUMENT_NODE) { |
|
return { |
|
width: elem.width(), |
|
height: elem.height(), |
|
offset: { top: 0, left: 0 }, |
|
}; |
|
} |
|
if (!!raw && raw === raw.window) { |
|
return { |
|
width: elem.width(), |
|
height: elem.height(), |
|
offset: { top: elem.scrollTop(), left: elem.scrollLeft() }, |
|
}; |
|
} |
|
if (raw.preventDefault) { |
|
return { |
|
width: 0, |
|
height: 0, |
|
offset: { top: raw.pageY, left: raw.pageX }, |
|
}; |
|
} |
|
return { |
|
width: elem.outerWidth(), |
|
height: elem.outerHeight(), |
|
offset: elem.offset(), |
|
}; |
|
} |
|
|
|
const collisions = { |
|
fit: { |
|
left(position, data) { |
|
const { within } = data; |
|
const withinOffset = within.isWindow |
|
? within.scrollLeft |
|
: within.offset.left; |
|
const outerWidth = within.width; |
|
const collisionPosLeft = |
|
position.left - data.collisionPosition.marginLeft; |
|
const overLeft = withinOffset - collisionPosLeft; |
|
const overRight = |
|
collisionPosLeft + data.collisionWidth - outerWidth - withinOffset; |
|
let newOverRight; |
|
|
|
// Element is wider than within |
|
if (data.collisionWidth > outerWidth) { |
|
// Element is initially over the left side of within |
|
if (overLeft > 0 && overRight <= 0) { |
|
newOverRight = |
|
position.left + |
|
overLeft + |
|
data.collisionWidth - |
|
outerWidth - |
|
withinOffset; |
|
position.left += overLeft - newOverRight; |
|
|
|
// Element is initially over right side of within |
|
} else if (overRight > 0 && overLeft <= 0) { |
|
position.left = withinOffset; |
|
|
|
// Element is initially over both left and right sides of within |
|
} else if (overLeft > overRight) { |
|
position.left = withinOffset + outerWidth - data.collisionWidth; |
|
} else { |
|
position.left = withinOffset; |
|
} |
|
|
|
// Too far left -> align with left edge |
|
} else if (overLeft > 0) { |
|
position.left += overLeft; |
|
|
|
// Too far right -> align with right edge |
|
} else if (overRight > 0) { |
|
position.left -= overRight; |
|
|
|
// Adjust based on position and margin |
|
} else { |
|
position.left = max(position.left - collisionPosLeft, position.left); |
|
} |
|
}, |
|
top(position, data) { |
|
const { within } = data; |
|
const withinOffset = within.isWindow |
|
? within.scrollTop |
|
: within.offset.top; |
|
const outerHeight = data.within.height; |
|
const collisionPosTop = position.top - data.collisionPosition.marginTop; |
|
const overTop = withinOffset - collisionPosTop; |
|
const overBottom = |
|
collisionPosTop + data.collisionHeight - outerHeight - withinOffset; |
|
let newOverBottom; |
|
|
|
// Element is taller than within |
|
if (data.collisionHeight > outerHeight) { |
|
// Element is initially over the top of within |
|
if (overTop > 0 && overBottom <= 0) { |
|
newOverBottom = |
|
position.top + |
|
overTop + |
|
data.collisionHeight - |
|
outerHeight - |
|
withinOffset; |
|
position.top += overTop - newOverBottom; |
|
|
|
// Element is initially over bottom of within |
|
} else if (overBottom > 0 && overTop <= 0) { |
|
position.top = withinOffset; |
|
|
|
// Element is initially over both top and bottom of within |
|
} else if (overTop > overBottom) { |
|
position.top = withinOffset + outerHeight - data.collisionHeight; |
|
} else { |
|
position.top = withinOffset; |
|
} |
|
|
|
// Too far up -> align with top |
|
} else if (overTop > 0) { |
|
position.top += overTop; |
|
|
|
// Too far down -> align with bottom edge |
|
} else if (overBottom > 0) { |
|
position.top -= overBottom; |
|
|
|
// Adjust based on position and margin |
|
} else { |
|
position.top = max(position.top - collisionPosTop, position.top); |
|
} |
|
}, |
|
}, |
|
flip: { |
|
left(position, data) { |
|
const { within } = data; |
|
const withinOffset = within.offset.left + within.scrollLeft; |
|
const outerWidth = within.width; |
|
const offsetLeft = within.isWindow |
|
? within.scrollLeft |
|
: within.offset.left; |
|
const collisionPosLeft = |
|
position.left - data.collisionPosition.marginLeft; |
|
const overLeft = collisionPosLeft - offsetLeft; |
|
const overRight = |
|
collisionPosLeft + data.collisionWidth - outerWidth - offsetLeft; |
|
const myOffset = |
|
// eslint-disable-next-line no-nested-ternary |
|
data.my[0] === 'left' |
|
? -data.elemWidth |
|
: data.my[0] === 'right' |
|
? data.elemWidth |
|
: 0; |
|
const atOffset = |
|
// eslint-disable-next-line no-nested-ternary |
|
data.at[0] === 'left' |
|
? data.targetWidth |
|
: data.at[0] === 'right' |
|
? -data.targetWidth |
|
: 0; |
|
const offset = -2 * data.offset[0]; |
|
let newOverRight; |
|
let newOverLeft; |
|
|
|
if (overLeft < 0) { |
|
newOverRight = |
|
position.left + |
|
myOffset + |
|
atOffset + |
|
offset + |
|
data.collisionWidth - |
|
outerWidth - |
|
withinOffset; |
|
if (newOverRight < 0 || newOverRight < abs(overLeft)) { |
|
position.left += myOffset + atOffset + offset; |
|
} |
|
} else if (overRight > 0) { |
|
newOverLeft = |
|
position.left - |
|
data.collisionPosition.marginLeft + |
|
myOffset + |
|
atOffset + |
|
offset - |
|
offsetLeft; |
|
if (newOverLeft > 0 || abs(newOverLeft) < overRight) { |
|
position.left += myOffset + atOffset + offset; |
|
} |
|
} |
|
}, |
|
top(position, data) { |
|
const { within } = data; |
|
const withinOffset = within.offset.top + within.scrollTop; |
|
const outerHeight = within.height; |
|
const offsetTop = within.isWindow |
|
? within.scrollTop |
|
: within.offset.top; |
|
const collisionPosTop = position.top - data.collisionPosition.marginTop; |
|
const overTop = collisionPosTop - offsetTop; |
|
const overBottom = |
|
collisionPosTop + data.collisionHeight - outerHeight - offsetTop; |
|
const top = data.my[1] === 'top'; |
|
// eslint-disable-next-line no-nested-ternary |
|
const myOffset = top |
|
? -data.elemHeight |
|
: data.my[1] === 'bottom' |
|
? data.elemHeight |
|
: 0; |
|
const atOffset = |
|
// eslint-disable-next-line no-nested-ternary |
|
data.at[1] === 'top' |
|
? data.targetHeight |
|
: data.at[1] === 'bottom' |
|
? -data.targetHeight |
|
: 0; |
|
const offset = -2 * data.offset[1]; |
|
let newOverTop; |
|
let newOverBottom; |
|
if (overTop < 0) { |
|
newOverBottom = |
|
position.top + |
|
myOffset + |
|
atOffset + |
|
offset + |
|
data.collisionHeight - |
|
outerHeight - |
|
withinOffset; |
|
if (newOverBottom < 0 || newOverBottom < abs(overTop)) { |
|
position.top += myOffset + atOffset + offset; |
|
} |
|
} else if (overBottom > 0) { |
|
newOverTop = |
|
position.top - |
|
data.collisionPosition.marginTop + |
|
myOffset + |
|
atOffset + |
|
offset - |
|
offsetTop; |
|
if (newOverTop > 0 || abs(newOverTop) < overBottom) { |
|
position.top += myOffset + atOffset + offset; |
|
} |
|
} |
|
}, |
|
}, |
|
flipfit: { |
|
left(...args) { |
|
collisions.flip.left.apply(this, args); |
|
collisions.fit.left.apply(this, args); |
|
}, |
|
top(...args) { |
|
collisions.flip.top.apply(this, args); |
|
collisions.fit.top.apply(this, args); |
|
}, |
|
}, |
|
}; |
|
|
|
$.position = { |
|
scrollbarWidth() { |
|
if (cachedScrollbarWidth !== undefined) { |
|
return cachedScrollbarWidth; |
|
} |
|
const div = $( |
|
'<div ' + |
|
"style='display:block;position:absolute;width:50px;height:50px;overflow:hidden;'>" + |
|
"<div style='height:100px;width:auto;'></div></div>", |
|
); |
|
const innerDiv = div.children()[0]; |
|
|
|
$('body').append(div); |
|
const w1 = innerDiv.offsetWidth; |
|
div[0].style.overflow = 'scroll'; |
|
|
|
let w2 = innerDiv.offsetWidth; |
|
|
|
if (w1 === w2) { |
|
w2 = div[0].clientWidth; |
|
} |
|
|
|
div.remove(); |
|
cachedScrollbarWidth = w1 - w2; |
|
return cachedScrollbarWidth; |
|
}, |
|
getScrollInfo(within) { |
|
const overflowX = |
|
within.isWindow || within.isDocument |
|
? '' |
|
: window.getComputedStyle(within.element[0])['overflow-x']; |
|
const overflowY = |
|
within.isWindow || within.isDocument |
|
? '' |
|
: window.getComputedStyle(within.element[0])['overflow-y']; |
|
const hasOverflowX = |
|
overflowX === 'scroll' || |
|
(overflowX === 'auto' && within.width < within.element[0].scrollWidth); |
|
const hasOverflowY = |
|
overflowY === 'scroll' || |
|
(overflowY === 'auto' && |
|
within.height < within.element[0].scrollHeight); |
|
return { |
|
width: hasOverflowY ? $.position.scrollbarWidth() : 0, |
|
height: hasOverflowX ? $.position.scrollbarWidth() : 0, |
|
}; |
|
}, |
|
getWithinInfo(element) { |
|
const withinElement = $(element || window); |
|
const isWindow = |
|
!!withinElement[0] && withinElement[0] === withinElement[0].window; |
|
const isDocument = |
|
!!withinElement[0] && withinElement[0].nodeType === Node.DOCUMENT_NODE; |
|
const hasOffset = !isWindow && !isDocument; |
|
return { |
|
element: withinElement, |
|
isWindow, |
|
isDocument, |
|
offset: hasOffset ? $(element).offset() : { left: 0, top: 0 }, |
|
scrollLeft: withinElement.scrollLeft(), |
|
scrollTop: withinElement.scrollTop(), |
|
width: withinElement.outerWidth(), |
|
height: withinElement.outerHeight(), |
|
}; |
|
}, |
|
}; |
|
|
|
// eslint-disable-next-line func-names |
|
/** |
|
* Positions an element relative to another. |
|
* |
|
* The following documentation is originally from |
|
* {@link https://api.jqueryui.com/position/}. |
|
* |
|
* @param {Object} options - the options object. |
|
* @param {string} options.my - Defines which position on the element being |
|
* positioned to align with the target element: "horizontal vertical" |
|
* alignment. A single value such as "right" will be normalized to "right |
|
* center", "top" will be normalized to "center top" (following CSS |
|
* convention). Acceptable horizontal values: "left", "center", "right". |
|
* Acceptable vertical values: "top", "center", "bottom". Example: "left |
|
* top" or "center center". Each dimension can also contain offsets, in |
|
* pixels or percent, e.g., "right+10 top-25%". Percentage offsets are |
|
* relative to the element being positioned. Default value is "center". |
|
* @param {string} options.at - Defines which position on the target element |
|
* to align the positioned element against: "horizontal vertical" alignment. |
|
* See the `my` option for full details on possible values. Percentage |
|
* offsets are relative to the target element. Default value is "center". |
|
* @param {string|Element|jQuery|Event|null} options.of - Which element to |
|
* position against. If you provide a selector or jQuery object, the first |
|
* matching element will be used. If you provide an event object, the pageX |
|
* and pageY properties will be used. Example: "#top-menu". Default value is |
|
* null. |
|
* @param {string} options.collision - When the positioned element overflows |
|
* the window in some direction, move it to an alternative position. Similar |
|
* to `my` and `at`, this accepts a single value or a pair for |
|
* horizontal/vertical, e.g., "flip", "fit", "fit flip", "fit none". Default |
|
* value is "flip". The options work as follows: |
|
* - "flip": Flips the element to the opposite side of the target and the |
|
* collision detection is run again to see if it will fit. Whichever side |
|
* allows more of the element to be visible will be used. |
|
* - "fit": Shift the element away from the edge of the window. |
|
* - "flipfit": First applies the flip logic, placing the element on |
|
* whichever side allows more of the element to be visible. Then the fit |
|
* logic is applied to ensure as much of the element is visible as |
|
* possible. |
|
* "none": Does not apply any collision detection. |
|
* @param {function|null} options.using - When specified, the actual property |
|
* setting is delegated to this callback. Receives two parameters: The first |
|
* is a hash of top and left values for the position that should be set and |
|
* can be forwarded to .css() or .animate().The second provides feedback |
|
* about the position and dimensions of both elements, as well as |
|
* calculations to their relative position. Both target and element have |
|
* these properties: element, left, top, width, height. In addition, there's |
|
* horizontal, vertical and important, providing twelve potential directions |
|
* like { horizontal: "center", vertical: "left", important: "horizontal" }. |
|
* Default value is null. |
|
* @param {string|Element|jQuery} options.within - Element to position within, |
|
* affecting collision detection. If you provide a selector or jQuery |
|
* object, the first matching element will be used. Default value is window. |
|
* |
|
* @return {jQuery} |
|
* The jQuery object that called called this function. |
|
*/ |
|
$.fn.position = function (options) { |
|
if (!options || !options.of) { |
|
// eslint-disable-next-line prefer-rest-params |
|
return _position.apply(this, arguments); |
|
} |
|
|
|
// Make a copy, we don't want to modify arguments |
|
options = $.extend({}, options); |
|
|
|
const within = $.position.getWithinInfo(options.within); |
|
const scrollInfo = $.position.getScrollInfo(within); |
|
const collision = (options.collision || 'flip').split(' '); |
|
const offsets = {}; |
|
|
|
// Make sure string options are treated as CSS selectors |
|
const target = |
|
typeof options.of === 'string' |
|
? $(document).find(options.of) |
|
: $(options.of); |
|
const dimensions = getDimensions(target); |
|
const targetWidth = dimensions.width; |
|
const targetHeight = dimensions.height; |
|
const targetOffset = dimensions.offset; |
|
|
|
if (target[0].preventDefault) { |
|
// Force left top to allow flipping |
|
options.at = 'left top'; |
|
} |
|
|
|
// Clone to reuse original targetOffset later |
|
const basePosition = $.extend({}, targetOffset); |
|
|
|
// Force my and at to have valid horizontal and vertical positions |
|
// if a value is missing or invalid, it will be converted to center |
|
// eslint-disable-next-line func-names |
|
$.each(['my', 'at'], function () { |
|
let pos = (options[this] || '').split(' '); |
|
|
|
if (pos.length === 1) { |
|
// eslint-disable-next-line no-nested-ternary |
|
pos = regexHorizontal.test(pos[0]) |
|
? pos.concat(['center']) |
|
: regexVertical.test(pos[0]) |
|
? ['center'].concat(pos) |
|
: ['center', 'center']; |
|
} |
|
pos[0] = regexHorizontal.test(pos[0]) ? pos[0] : 'center'; |
|
pos[1] = regexVertical.test(pos[1]) ? pos[1] : 'center'; |
|
|
|
// Calculate offsets |
|
const horizontalOffset = regexOffset.exec(pos[0]); |
|
const verticalOffset = regexOffset.exec(pos[1]); |
|
offsets[this] = [ |
|
horizontalOffset ? horizontalOffset[0] : 0, |
|
verticalOffset ? verticalOffset[0] : 0, |
|
]; |
|
|
|
// Reduce to just the positions without the offsets |
|
options[this] = [ |
|
regexPosition.exec(pos[0])[0], |
|
regexPosition.exec(pos[1])[0], |
|
]; |
|
}); |
|
|
|
// Normalize collision option |
|
if (collision.length === 1) { |
|
// eslint-disable-next-line prefer-destructuring |
|
collision[1] = collision[0]; |
|
} |
|
|
|
if (options.at[0] === 'right') { |
|
basePosition.left += targetWidth; |
|
} else if (options.at[0] === 'center') { |
|
basePosition.left += targetWidth / 2; |
|
} |
|
|
|
if (options.at[1] === 'bottom') { |
|
basePosition.top += targetHeight; |
|
} else if (options.at[1] === 'center') { |
|
basePosition.top += targetHeight / 2; |
|
} |
|
|
|
const atOffset = getOffsets(offsets.at, targetWidth, targetHeight); |
|
basePosition.left += atOffset[0]; |
|
basePosition.top += atOffset[1]; |
|
|
|
// eslint-disable-next-line func-names |
|
return this.each(function () { |
|
let using; |
|
const elem = $(this); |
|
const elemWidth = elem.outerWidth(); |
|
const elemHeight = elem.outerHeight(); |
|
const marginLeft = parseCss(this, 'marginLeft'); |
|
const marginTop = parseCss(this, 'marginTop'); |
|
const collisionWidth = |
|
elemWidth + |
|
marginLeft + |
|
parseCss(this, 'marginRight') + |
|
scrollInfo.width; |
|
const collisionHeight = |
|
elemHeight + |
|
marginTop + |
|
parseCss(this, 'marginBottom') + |
|
scrollInfo.height; |
|
const position = $.extend({}, basePosition); |
|
const myOffset = getOffsets( |
|
offsets.my, |
|
elem.outerWidth(), |
|
elem.outerHeight(), |
|
); |
|
|
|
if (options.my[0] === 'right') { |
|
position.left -= elemWidth; |
|
} else if (options.my[0] === 'center') { |
|
position.left -= elemWidth / 2; |
|
} |
|
|
|
if (options.my[1] === 'bottom') { |
|
position.top -= elemHeight; |
|
} else if (options.my[1] === 'center') { |
|
position.top -= elemHeight / 2; |
|
} |
|
|
|
position.left += myOffset[0]; |
|
position.top += myOffset[1]; |
|
|
|
const collisionPosition = { |
|
marginLeft, |
|
marginTop, |
|
}; |
|
|
|
// eslint-disable-next-line func-names |
|
$.each(['left', 'top'], function (i, dir) { |
|
if (collisions[collision[i]]) { |
|
collisions[collision[i]][dir](position, { |
|
targetWidth, |
|
targetHeight, |
|
elemWidth, |
|
elemHeight, |
|
collisionPosition, |
|
collisionWidth, |
|
collisionHeight, |
|
offset: [atOffset[0] + myOffset[0], atOffset[1] + myOffset[1]], |
|
my: options.my, |
|
at: options.at, |
|
within, |
|
elem, |
|
}); |
|
} |
|
}); |
|
|
|
if (options.using) { |
|
// Adds feedback as second argument to using callback, if present |
|
// eslint-disable-next-line func-names |
|
using = function (props) { |
|
const left = targetOffset.left - position.left; |
|
const right = left + targetWidth - elemWidth; |
|
const top = targetOffset.top - position.top; |
|
const bottom = top + targetHeight - elemHeight; |
|
const feedback = { |
|
target: { |
|
element: target, |
|
left: targetOffset.left, |
|
top: targetOffset.top, |
|
width: targetWidth, |
|
height: targetHeight, |
|
}, |
|
element: { |
|
element: elem, |
|
left: position.left, |
|
top: position.top, |
|
width: elemWidth, |
|
height: elemHeight, |
|
}, |
|
// eslint-disable-next-line no-nested-ternary |
|
horizontal: right < 0 ? 'left' : left > 0 ? 'right' : 'center', |
|
// eslint-disable-next-line no-nested-ternary |
|
vertical: bottom < 0 ? 'top' : top > 0 ? 'bottom' : 'middle', |
|
}; |
|
if (targetWidth < elemWidth && abs(left + right) < targetWidth) { |
|
feedback.horizontal = 'center'; |
|
} |
|
if (targetHeight < elemHeight && abs(top + bottom) < targetHeight) { |
|
feedback.vertical = 'middle'; |
|
} |
|
if (max(abs(left), abs(right)) > max(abs(top), abs(bottom))) { |
|
feedback.important = 'horizontal'; |
|
} else { |
|
feedback.important = 'vertical'; |
|
} |
|
options.using.call(this, props, feedback); |
|
}; |
|
} |
|
|
|
elem.offset($.extend(position, { using })); |
|
}); |
|
}; |
|
|
|
// Although $.ui.position is not built to be called directly, some legacy code |
|
// may have checks for the presence of $.ui.position, which can be used to |
|
// confirm the presence of jQuery UI position's API, as opposed to the more |
|
// limited version provided by jQuery. |
|
if (!$.hasOwnProperty('ui')) { |
|
$.ui = {}; |
|
} |
|
$.ui.position = collisions; |
|
})(jQuery);
|
|
|