@atlassian/aui
Version:
Atlassian User Interface library
245 lines (211 loc) • 8.26 kB
JavaScript
import Popper from 'popper.js';
const ATTR_ALIGNMENT = 'alignment';
const DEFAULT_ATTACHMENT = 'right middle';
const ATTR_CONTAINER = 'alignment-container';
const CLASS_PREFIX_SIDE = 'aui-alignment-side-';
const CLASS_PREFIX_SNAP = 'aui-alignment-snap-';
export const GPU_ACCELERATION_FLAG = 'aui-alignment-use-gpu';
/**
* The "side" and "snap" that an element should use when aligning, where:
* - "side" is the edge of the **target** that the aligned element should touch, and
* - "snap" is the effective position that both the target and aligned element should share.
* @enum {String}
* @name AlignmentType
*/
const ALIGNMENT_MAP = {
'top left': 'top-start',
'top center': 'top',
'top right': 'top-end',
'right top': 'right-start',
'right middle': 'right',
'right bottom': 'right-end',
'bottom right': 'bottom-end',
'bottom center': 'bottom',
'bottom left': 'bottom-start',
'left bottom': 'left-end',
'left middle': 'left',
'left top': 'left-start',
};
function getAttribute(element, name) {
return element.getAttribute(name) || element.getAttribute('data-aui-' + name);
}
function getAlignmentAttribute(element) {
return getAttribute(element, ATTR_ALIGNMENT) || DEFAULT_ATTACHMENT;
}
function getPlacement(element) {
const attr = getAlignmentAttribute(element);
return ALIGNMENT_MAP[attr] || 'right';
}
function getAlignment(element) {
let [side, snap] = (getAlignmentAttribute(element)).split(' ');
return {
side,
snap
};
}
function addAlignmentClasses(element, side, snap) {
const sideClass = CLASS_PREFIX_SIDE + side;
const snapClass = CLASS_PREFIX_SNAP + snap;
if (!element.classList.contains(sideClass)) {
element.classList.add(sideClass);
}
if (!element.classList.contains(snapClass)) {
element.classList.add(snapClass);
}
}
function getContainer (element) {
let container = getAttribute(element, ATTR_CONTAINER) || window;
if (typeof container === 'string') {
container = document.querySelector(container);
}
return container;
}
function calculateBestAlignmentSnap (target) {
let container = getContainer(target);
let snap = 'left';
if (!container || container === window || container === document) {
container = document.documentElement;
}
if (container && container.nodeType && container.nodeType === Node.ELEMENT_NODE) {
let containerBounds = container.getBoundingClientRect();
let targetBounds = target.getBoundingClientRect();
if (targetBounds.left > containerBounds.right / 2) {
snap = 'right';
}
}
return snap;
}
/*
this determines flip order e.g.
for top it will try to position itself at the top,
if there is no space try to flip to bottom and if there is no space it will stay at the top
*/
const getFlipBehavior = {
auto: [],
top: ['top', 'bottom', 'top'],
right: ['right', 'left', 'right'],
bottom: ['bottom', 'top', 'bottom'],
left: ['left', 'right', 'left'],
};
/**
* Visually positions an element adjacent to another one in the DOM.
* Can also be told to keep the element aligned
* when the user resizes the browser or scrolls around the page.
* @constructor
* @constructs Alignment
* @param {HTMLElement} element - the element that will be repositioned. Should have an "alignment" attribute
* with a valid {@link AlignmentType} value.
* @param {HTMLElement} target - the point in the DOM to visually position the {@param element} adjacent to.
* @param {Object} [options]
* @param {boolean} [options.preventOverflow=true] - if true, will cause element to not overflow viewable area
* @param {boolean} [options.flip=true] - if true, will cause the element to attempt to reposition itself within
* a viewable area as its {@param target} disappears from view.
* @param {HTMLElement|'viewport'|'window'|'scrollContainer'} [options.flipContainer='viewport'] - the container
* in which the element should attempt to stay within the viewable area of.
* Used in conjunction with {@param options.flip}.
* @param {HTMLElement|'viewport'|'window'|'scrollContainer'} [options.overflowContainer='window'] - the container
* in which the element should attempt to stay within the viewable area of.
* Used in conjunction with {@param options.preventOverflow}.
* @param {Function} [options.onCreate] - called when the element is first positioned upon creation of the Alignment.
* @param {Function} [options.onUpdate] - called whenever the element is positioned, except upon creation.
*/
function Alignment(element, target, options = {}) {
const alignment = getAlignment(element);
let placement;
if (!alignment.snap || alignment.snap === 'auto') {
alignment.snap = calculateBestAlignmentSnap(target);
if (alignment.side === 'submenu') {
placement = ALIGNMENT_MAP[`${alignment.snap === 'right' ? 'left' : 'right'} top`];
} else {
placement = ALIGNMENT_MAP[`${alignment.side} ${alignment.snap}`];
}
} else {
placement = getPlacement(element);
}
const flipBehavior = getFlipBehavior[placement.split('-')[0]];
const modifiers = {
flip: {
enabled: options.hasOwnProperty('flip') ? options.flip : true,
behavior: flipBehavior,
boundariesElement: options.hasOwnProperty('flipContainer') ? options.flipContainer : 'viewport',
},
preventOverflow: {
enabled: options.hasOwnProperty('preventOverflow') ? options.preventOverflow : true,
padding: 0,
escapeWithReference: false,
boundariesElement: options.hasOwnProperty('overflowContainer') ? options.overflowContainer : 'window',
},
hide: {
enabled: false
},
computeStyle: {
gpuAcceleration: document.body.classList.contains(GPU_ACCELERATION_FLAG)
}
};
const popperConfig = {
eventsEnabled: false,
placement,
positionFixed: options.hasOwnProperty('positionFixed') ? options.positionFixed : true,
modifiers
};
['onCreate', 'onUpdate'].forEach(function(callbackName) {
var callback = options[callbackName];
if (typeof callback === 'function') {
popperConfig[callbackName] = callback;
}
});
this.popper = new Popper(target, element, popperConfig);
addAlignmentClasses(element, alignment.side, alignment.snap);
}
Alignment.prototype = {
destroy() {
this.popper.destroy();
return this;
},
/**
* Changes what the aligned element is trying to align itself with.
* Will call {@link #scheduleUpdate} as needed to ensure the element will be aligned
* with whatever the new target is.
* @param {HTMLElement} newTarget - the new target DOM element to align the element with.
* @returns {Alignment}
*/
changeTarget(newTarget) {
const referenceEl = newTarget.jquery ? newTarget[0] : newTarget;
const shouldRebind = this.popper.state.eventsEnabled;
if (referenceEl && referenceEl !== this.popper.reference) {
shouldRebind && this.disable();
this.popper.reference = referenceEl;
shouldRebind && this.enable();
this.scheduleUpdate();
}
return this;
},
/**
* The position of the element will be updated on the next execution stack.
* Triggering a render this way will always be asynchronous.
* @returns {Alignment}
*/
scheduleUpdate() {
this.popper.scheduleUpdate();
return this;
},
/**
* Causes the position of the element to auto-update
* when the browser window resizes or scroll parent is scrolled.
* @returns {Alignment}
*/
enable() {
this.popper.enableEventListeners();
return this;
},
/**
* Prevents the position of the element from auto-updating
* when the browser window resizes or scroll parent is scrolled.
* @returns {Alignment}
*/
disable() {
this.popper.disableEventListeners();
return this;
},
};
export default Alignment;