/**
* Place an Element relative to a point, rectangle, or another Element.
* @module delite/place
*/
define([
"./Viewport" // getEffectiveBox
], function (Viewport) {
/**
* @typedef {Object} module:delite/place.Position
* @property {number} x - Horizontal coordinate in pixels, relative to document body.
* @property {number} y - Vertical coordinate in pixels, relative to document body.
*/
/**
* Represents the position of the "anchor" node. Popup node will be placed adjacent to this rectangle.
* @typedef {Object} module:delite/place.Rectangle
* @property {number} x - Horizontal coordinate in pixels, relative to document body.
* @property {number} y - Vertical coordinate in pixels, relative to document body.
* @property {number} w - Width in pixels.
* @property {number} h - Height in pixels.
*/
/**
* Function on popup widget to adjust it based on what position it's being displayed in,
* relative to anchor node.
* @callback module:delite/place.LayoutFunc
* @param {Element} node - The DOM node for the popup widget.
* @param {string} aroundCorner - Corner of the anchor node, one of:
* - "BL" - bottom left
* - "BR" - bottom right
* - "TL" - top left
* - "TR" - top right
* @param {string} nodeCorner - Corner of the popup node, one of:
* - "BL" - bottom left
* - "BR" - bottom right
* - "TL" - top left
* - "TR" - top right
* @param {Object} size - `{w: 20, h: 30}` type object specifying size of the popup.
* @returns {number} Optional. Amount that the popup needed to be modified to fit in the space provided.
* If no value is returned, it's assumed that the popup fit completely without modification.
*/
/**
* Meta-data about the position chosen for a popup node.
* Specifies the corner of the anchor node and the corner of the popup node that touch each other,
* plus sizing data.
* @typedef {Object} module:delite/place.ChosenPosition
* @property {string} aroundCorner - Corner of the anchor node:
* - "BL" - bottom left
* - "BR" - bottom right
* - "TL" - top left
* - "TR" - top right
* @property {string} corner - Corner of the popup node:
* - "BL" - bottom left
* - "BR" - bottom right
* - "TL" - top left
* - "TR" - top right
* @property {number} x - Horizontal position of popup in pixels, relative to document body.
* @property {number} y - Vertical position of popup in pixels, relative to document body.
* @property {number} w - Width of popup in pixels.
* @property {number} h - Height of popup in pixels.
* @property {Object} spaceAvailable - `{w: 30, h: 20}` type object listing the amount of space that
* was available fot the popup in the chosen position.
*/
/**
* Given a list of positions to place node, place it at the first position where it fits,
* of if it doesn't fit anywhere then the position with the least overflow.
* @param {Element} node
* @param {Array} choices - Array of objects like `{corner: "TL", pos: {x: 10, y: 20} }`.
* This example says to put the top-left corner of the node at (10,20).
* @param {module:delite/place.LayoutFunc} [layoutNode] - Widgets like tooltips are displayed differently an
* have different dimensions based on their orientation relative to the parent.
* This adjusts the popup based on orientation.
* It also passes in the available size for the popup, which is useful for tooltips to
* tell them that their width is limited to a certain amount. layoutNode() may return a value
* expressing how much the popup had to be modified to fit into the available space.
* This is used to determine what the best placement is.
* @param {module:delite/place.Rectangle} aroundNodeCoords - Size and position of aroundNode.
* @returns {module:delite/place.ChosenPosition} Best position to place node.
* @private
*/
function _placeAt(node, choices, layoutNode, aroundNodeCoords) {
// get {l: 10, t: 10, w: 100, h:100} type obj representing position of
// viewport over document
var view = Viewport.getEffectiveBox(node.ownerDocument),
style = node.style;
// This won't work if the node is inside a <div style="position: relative">,
// so reattach it to <body>. (Otherwise, the positioning will be wrong
// and also it might get cut off.)
if (!node.parentNode || String(node.parentNode.tagName).toLowerCase() !== "body") {
node.ownerDocument.body.appendChild(node);
}
var best = null;
choices.some(function (choice) {
var corner = choice.corner;
var pos = choice.pos;
var overflow = 0;
// calculate amount of space available given specified position of node
var spaceAvailable = {
w: {
"L": view.l + view.w - pos.x,
"R": pos.x - view.l,
"M": view.w
}[corner.charAt(1)],
h: {
"T": view.t + view.h - pos.y,
"B": pos.y - view.t,
"M": view.h
}[corner.charAt(0)]
};
// Clear left/right position settings set earlier so they don't interfere with calculations,
// specifically when layoutNode() (a.k.a. Tooltip.orient()) measures natural width of Tooltip
style.left = style.right = "auto";
// configure node to be displayed in given position relative to button
// (need to do this in order to get an accurate size for the node, because
// a tooltip's size changes based on position, due to triangle)
if (layoutNode) {
var res = layoutNode(node, choice.aroundCorner, corner, spaceAvailable, aroundNodeCoords);
overflow = typeof res === "undefined" ? 0 : res;
}
// get node's size
var oldDisplay = style.display;
var oldVis = style.visibility;
if (style.display === "none") {
style.visibility = "hidden";
style.display = "";
}
var bb = node.getBoundingClientRect();
style.display = oldDisplay;
style.visibility = oldVis;
// coordinates and size of node with specified corner placed at pos,
// and clipped by viewport
var
startXpos = {
"L": pos.x,
"R": pos.x - bb.width,
// M orientation is more flexible
"M": Math.max(view.l, Math.min(view.l + view.w, pos.x + (bb.width >> 1)) - bb.width)
}[corner.charAt(1)],
startYpos = {
"T": pos.y,
"B": pos.y - bb.height,
"M": Math.max(view.t, Math.min(view.t + view.h, pos.y + (bb.height >> 1)) - bb.height)
}[corner.charAt(0)],
startX = Math.max(view.l, startXpos),
startY = Math.max(view.t, startYpos),
endX = Math.min(view.l + view.w, startXpos + bb.width),
endY = Math.min(view.t + view.h, startYpos + bb.height),
width = endX - startX,
height = endY - startY;
overflow += (bb.width - width) + (bb.height - height);
if (best == null || overflow < best.overflow) {
best = {
corner: corner,
aroundCorner: choice.aroundCorner,
x: startX,
y: startY,
w: width,
h: height,
overflow: overflow,
spaceAvailable: spaceAvailable
};
}
return !overflow;
});
// In case the best position is not the last one we checked, need to call
// layoutNode() again.
if (best.overflow && layoutNode) {
layoutNode(node, best.aroundCorner, best.corner, best.spaceAvailable, aroundNodeCoords);
}
// And then position the node. Do this last, after the layoutNode() above
// has sized the node, due to browser quirks when the viewport is scrolled
// (specifically that a Tooltip will shrink to fit as though the window was
// scrolled to the left).
var top = best.y,
side = best.x,
cs = getComputedStyle(node.ownerDocument.body);
if (/^(relative|absolute)$/.test(cs.position)) {
// compensate for margin on <body>, see #16148
top -= cs.marginTop;
side -= cs.marginLeft;
}
style.top = top + "px";
style.left = side + "px";
style.right = "auto"; // needed for FF or else tooltip goes to far left
return best;
}
var reverse = {
// Map from corner to kitty-corner
"TL": "BR",
"TR": "BL",
"BL": "TR",
"BR": "TL"
};
var place = /** @lends module:delite/place */ {
// TODO: it's weird that padding is specified as x/y rather than h/w.
/**
* Positions node kitty-corner to the rectangle centered at (pos.x, pos.y) with width and height of
* padding.x * 2 and padding.y * 2, or zero if padding not specified. Picks first corner in
* corners[] where node is fully visible, or the corner where it's most visible.
*
* Node is assumed to be absolutely or relatively positioned.
*
* @param {Element} node - The popup node to be positioned.
* @param {module:delite/place.Position} pos - The point (or if padding specified, rectangle) to place
* the node kitty-corner to.
* @param {string[]} corners - Array of strings representing order to try corners of the node in,
* like `["TR", "BL"]`. Possible values are:
* - "BL" - bottom left
* - "BR" - bottom right
* - "TL" - top left
* - "TR" - top right
* @param {module:delite/place.Position} [padding] - Optional param to set padding, to put some buffer
* around the element you want to position. Defaults to zero.
* @param {module:delite/place.LayoutFunc} [layoutNode]
* @returns {module:delite/place.ChosenPosition} Position node was placed at.
* @example
* // Try to place node's top right corner at (10,20).
* // If that makes node go (partially) off screen, then try placing
* // bottom left corner at (10,20).
* place.at(node, {x: 10, y: 20}, ["TR", "BL"])
*/
at: function (node, pos, corners, padding, layoutNode) {
var choices = corners.map(function (corner) {
var c = {
corner: corner,
aroundCorner: reverse[corner], // so TooltipDialog.orient() gets aroundCorner argument set
pos: {x: pos.x, y: pos.y}
};
if (padding) {
c.pos.x += corner.charAt(1) === "L" ? padding.x : -padding.x;
c.pos.y += corner.charAt(0) === "T" ? padding.y : -padding.y;
}
return c;
});
return _placeAt(node, choices, layoutNode);
},
/**
* Position node adjacent to anchor such that it's fully visible in viewport.
* Adjacent means that one side of the anchor is flush with one side of the node.
* @param {Element} node - The popup node to be positioned.
* @param {Element|module:delite/place.Rectangle} anchor - Place node adjacent to this Element or rectangle.
* @param {string[]} positions - Ordered list of positions to try matching up.
* - before: places drop down to the left of the anchor node/widget, or to the right in the case
* of RTL scripts like Hebrew and Arabic; aligns either the top of the drop down
* with the top of the anchor, or the bottom of the drop down with bottom of the anchor.
* - after: places drop down to the right of the anchor node/widget, or to the left in the case
* of RTL scripts like Hebrew and Arabic; aligns either the top of the drop down
* with the top of the anchor, or the bottom of the drop down with bottom of the anchor.
* - before-centered: centers drop down to the left of the anchor node/widget, or to the right
* in the case of RTL scripts like Hebrew and Arabic
* - after-centered: centers drop down to the right of the anchor node/widget, or to the left
* in the case of RTL scripts like Hebrew and Arabic
* - above-centered: drop down is centered above anchor node
* - above: drop down goes above anchor node, left sides aligned
* - above-alt: drop down goes above anchor node, right sides aligned
* - below-centered: drop down is centered above anchor node
* - below: drop down goes below anchor node
* - below-alt: drop down goes below anchor node, right sides aligned
* @param {boolean} leftToRight - True if widget is LTR, false if widget is RTL.
* Affects the behavior of "above" and "below" positions slightly.
* @param {module:delite/place.LayoutFunc} [layoutNode] - Widgets like tooltips are displayed differently and
* have different dimensions based on their orientation relative to the parent.
* This adjusts the popup based on orientation.
* @returns {module:delite/place.ChosenPosition} Position node was placed at.
* @example
* // Try to position node such that node's top-left corner is at the same position
* // as the bottom left corner of the aroundNode (ie, put node below
* // aroundNode, with left edges aligned). If that fails try to put
* // the bottom-right corner of node where the top right corner of aroundNode is
* // (i.e., put node above aroundNode, with right edges aligned)
* place.around(node, aroundNode, {'BL':'TL', 'TR':'BR'});
*/
around: function (node, anchor, positions, leftToRight, layoutNode) {
/* jshint maxcomplexity:12 */
// If around is a DOMNode (or DOMNode id), convert to coordinates.
var aroundNodePos;
if (typeof anchor === "string" || "offsetWidth" in anchor || "ownerSVGElement" in anchor) {
aroundNodePos = place.position(anchor);
// For above and below dropdowns, subtract width of border so that popup and aroundNode borders
// overlap, preventing a double-border effect. Unfortunately, difficult to measure the border
// width of either anchor or popup because in both cases the border may be on an inner node.
if (/^(above|below)/.test(positions[0])) {
var border = function (node) {
var cs = getComputedStyle(node);
return {
t: parseFloat(cs.borderTopWidth), // remove "px"
b: parseFloat(cs.borderBottomWidth) // remove "px"
};
};
var anchorBorder = border(anchor),
anchorChildBorder = anchor.firstElementChild ? border(anchor.firstElementChild) : {t: 0, b: 0},
nodeBorder = border(node),
nodeChildBorder = node.firstElementChild ? border(node.firstElementChild) : {t: 0, b: 0};
aroundNodePos.y += Math.min(anchorBorder.t + anchorChildBorder.t,
nodeBorder.t + nodeChildBorder.t);
aroundNodePos.h -= Math.min(anchorBorder.t + anchorChildBorder.t,
nodeBorder.t + nodeChildBorder.t) +
Math.min(anchorBorder.b + anchorChildBorder.b, nodeBorder.b + nodeChildBorder.b);
}
} else {
aroundNodePos = anchor;
}
// Compute position and size of visible part of anchor (it may be partially hidden by ancestor
// nodes w/scrollbars)
if (anchor.parentNode) {
// ignore nodes between position:relative and position:absolute
var sawPosAbsolute = getComputedStyle(anchor).position === "absolute";
var parent = anchor.parentNode;
// ignoring the body will help performance
while (parent && parent.nodeType === 1 && parent.nodeName !== "BODY") {
var parentPos = place.position(parent),
pcs = getComputedStyle(parent);
if (/^(relative|absolute)$/.test(pcs.position)) {
sawPosAbsolute = false;
}
if (!sawPosAbsolute && /^(hidden|auto|scroll)$/.test(pcs.overflow)) {
var bottomYCoord = Math.min(aroundNodePos.y + aroundNodePos.h, parentPos.y + parentPos.h);
var rightXCoord = Math.min(aroundNodePos.x + aroundNodePos.w, parentPos.x + parentPos.w);
aroundNodePos.x = Math.max(aroundNodePos.x, parentPos.x);
aroundNodePos.y = Math.max(aroundNodePos.y, parentPos.y);
aroundNodePos.h = bottomYCoord - aroundNodePos.y;
aroundNodePos.w = rightXCoord - aroundNodePos.x;
}
if (pcs.position === "absolute") {
sawPosAbsolute = true;
}
parent = parent.parentNode;
}
}
var x = aroundNodePos.x,
y = aroundNodePos.y,
width = aroundNodePos.w,
height = aroundNodePos.h;
// Convert positions arguments into choices argument for _placeAt()
var choices = [];
function push(aroundCorner, corner) {
choices.push({
aroundCorner: aroundCorner,
corner: corner,
pos: {
x: {
"L": x,
"R": x + width,
"M": x + (width >> 1)
}[aroundCorner.charAt(1)],
y: {
"T": y,
"B": y + height,
"M": y + (height >> 1)
}[aroundCorner.charAt(0)]
}
});
}
positions.forEach(function (pos) {
/* jshint maxcomplexity:25 */ // TODO: rewrite to avoid 25 max complexity
var ltr = leftToRight;
switch (pos) {
case "above-centered":
push("TM", "BM");
break;
case "below-centered":
push("BM", "TM");
break;
case "after-centered":
ltr = !ltr;
/* falls through */
case "before-centered":
push(ltr ? "ML" : "MR", ltr ? "MR" : "ML");
break;
case "after":
ltr = !ltr;
/* falls through */
case "before":
push(ltr ? "TL" : "TR", ltr ? "TR" : "TL");
push(ltr ? "BL" : "BR", ltr ? "BR" : "BL");
break;
case "below-alt":
ltr = !ltr;
/* falls through */
case "below":
// first try to align left borders, next try to align right borders (or reverse for RTL mode)
push(ltr ? "BL" : "BR", ltr ? "TL" : "TR");
push(ltr ? "BR" : "BL", ltr ? "TR" : "TL");
break;
case "above-alt":
ltr = !ltr;
/* falls through */
case "above":
// first try to align left borders, next try to align right borders (or reverse for RTL mode)
push(ltr ? "TL" : "TR", ltr ? "BL" : "BR");
push(ltr ? "TR" : "TL", ltr ? "BR" : "BL");
break;
}
});
var position = _placeAt(node, choices, layoutNode, {w: width, h: height});
position.aroundNodePos = aroundNodePos;
return position;
},
/**
* Centers the specified node, like a Dialog.
* Node must fit within viewport.
*
* Node is assumed to be absolutely or relatively positioned.
*
* @param {Element} node - The popup node to be positioned.
*/
center: function (node) {
// First move node off screen so we can get accurate size.
// TODO: move this code [and RTL detect code) to separate methods, and leverage from popup.moveOffScreen()
var style = node.style,
rtl = (/^rtl$/i).test(node.dir || node.ownerDocument.body.dir ||
node.ownerDocument.documentElement.dir);
style.top = "-9999px";
style[rtl ? "right" : "left"] = "-9999px";
// Then set position so node is centered.
var view = Viewport.getEffectiveBox(),
bb = node.getBoundingClientRect();
style.top = view.t + (view.h - bb.height) / 2 + "px";
style.left = view.l + (view.w - bb.width) / 2 + "px";
style.right = "auto";
},
/**
* Return node position relative to document (rather than to viewport).
* @param node
*/
position: function (node) {
var bcr = node.getBoundingClientRect(),
doc = node.ownerDocument,
win = doc.defaultView;
return {
x: bcr.left + (win.pageXOffset || doc.documentElement.scrollLeft),
y: bcr.top + (win.pageYOffset || doc.documentElement.scrollTop),
h: bcr.height,
w: bcr.width
};
}
};
return place;
});