@atlassian/aui
Version:
Atlassian User Interface library
388 lines (340 loc) • 13.7 kB
JavaScript
import { createPopper } from '@popperjs/core';
const ATTR_ALIGNMENT = 'alignment';
const DEFAULT_ATTACHMENT = 'right middle';
export 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.left >
(containerBounds.right - containerBounds.left) / 2
) {
snap = 'right';
}
}
return snap;
}
function calculatePlacement(element, target) {
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);
}
return placement;
}
/*
this determines allowed flip placement e.g.
for top it will try to position itself at the top,
if there is no space try to flip to bottom
*/
const allowedPlacement = {
auto: [],
top: ['top', 'bottom'],
right: ['right', 'left'],
bottom: ['bottom', 'top'],
left: ['left', 'right'],
};
/**
* 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 {Array.<Number>} [options.offset] - array containing [skidding, distance]. if present, will cause
* the element to offset from the trigger; Defaults to [0,0] (no offset)
* skidding, displaces the popper along the reference element.
* distance, displaces the popper away from, or toward, the reference element in the direction of its placement
* @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.
* @param {Function} [options.onEvents]
* @param {Function} [options.onEvents.enabled] - called when the scroll and resize events are added.
* @param {Function} [options.onEvents.disabled] - called when the scroll and resize events are removed.
* @param {boolean} [options.eventsEnabled=false] - if true, will cause the element to attempt to reposition itself on
* scroll and resize. Equivalent of calling .enable() after init but saves one update cycle.
*/
function Alignment(element, target, options = {}) {
const alignment = getAlignment(element);
const placement = calculatePlacement(element, target);
const allowedAutoPlacements = allowedPlacement[placement.split('-')[0]];
const frame = target.ownerDocument.defaultView.frameElement;
this._eventListenersEnabled = options.hasOwnProperty('eventsEnabled')
? options.eventsEnabled
: false;
this._triggerOnEvents = false;
const modifiers = [
{
name: 'flip',
enabled: options.hasOwnProperty('flip') ? options.flip : true,
options: {
allowedAutoPlacements,
boundary:
frame ||
(options.hasOwnProperty('flipContainer')
? options.flipContainer
: 'clippingParents'), // clippingParents by default
},
},
{
name: 'preventOverflow',
enabled: options.hasOwnProperty('preventOverflow') ? options.preventOverflow : true,
options: {
padding: 0, // as of Popper 2.0 it's 0 by default, but explicitly specify in case of defaults change.
escapeWithReference: false,
rootBoundary: frame
? 'document'
: options.hasOwnProperty('overflowContainer')
? options.overflowContainer
: 'document', //viewport by default
},
},
{
name: 'offset',
enabled: options.hasOwnProperty('offset') && !!options.offset,
options: {
offset: options.offset,
},
},
{
name: 'hide',
enabled: false,
},
{
name: 'computeStyles',
options: {
gpuAcceleration: document.body.classList.contains(GPU_ACCELERATION_FLAG),
// adaptive: false, // true by default, breaks CSS transitions (do we need it?)
},
},
{
name: 'eventListeners',
enabled: this._eventListenersEnabled,
},
{
// left for backwards compatibility
name: 'x-placement',
enabled: true,
phase: 'write',
requires: ['computeStyles'],
fn: ({ state }) => {
if (state.elements.popper) {
// popper-specific attributes are NOT contracted, public API of AUI layered element
state.elements.popper.setAttribute('x-placement', state.placement);
}
},
},
{
name: 'onUpdate',
enabled: options.hasOwnProperty('onUpdate'),
phase: 'afterWrite',
effect: ({ state, name }) => {
// enable it after initial cycle
state.modifiersData[`${name}#persistent`] = {
enabled: true,
fn: options.onUpdate,
};
},
fn: ({ state, name }) => {
const o = state.modifiersData[`${name}#persistent`];
if (o.enabled) {
o.fn();
}
return state;
},
},
{
name: 'onEvents',
enabled: options.hasOwnProperty('onEvents'),
phase: 'afterWrite',
effect: ({ state, name }) => {
// enable it after initial cycle
state.modifiersData[`${name}#persistent`] = {
fn: options.onEvents,
};
},
fn: ({ state, name }) => {
const o = state.modifiersData[`${name}#persistent`];
if (this._triggerOnEvents) {
if (this._eventListenersEnabled) {
o.fn.enabled && o.fn.enabled();
} else {
o.fn.disabled && o.fn.disabled();
}
this._triggerOnEvents = false;
}
return state;
},
},
];
// IE/Edge may throw a "Permission denied" error when strict-comparing two documents
// eslint-disable-next-line eqeqeq
if (frame && target.ownerDocument != element.ownerDocument) {
modifiers.push({
name: 'iframeOffset',
enabled: true,
fn(data) {
const rect = frame.getBoundingClientRect();
const style = window.getComputedStyle(frame);
const sum = (a, b) => a + b;
const getTotalValue = (values) =>
values.map(parseFloat).filter(Boolean).reduce(sum, 0);
const top = getTotalValue([rect.top, style.paddingTop, style.borderTop]);
const left = getTotalValue([rect.left, style.paddingLeft, style.borderLeft]);
data.offsets.reference.left += left;
data.offsets.reference.top += top;
data.offsets.popper.left += left;
data.offsets.popper.top += top;
return data;
},
});
}
const popperConfig = {
placement, //controlled by the flip modifier
strategy:
options.hasOwnProperty('positionFixed') && !options.positionFixed
? 'absolute'
: 'fixed',
modifiers,
onFirstUpdate: options.onCreate,
};
this.popper = createPopper(target, element, popperConfig);
addAlignmentClasses(element, alignment.side, alignment.snap);
}
Alignment.prototype = {
destroy() {
this.popper.destroy();
return this;
},
/**
* In extreme situations may cause element to be inaccessible. To be considered as 9.1.1 bugfix / 9.2.0 improvement?
*
* 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;
if (referenceEl && referenceEl !== this.popper.state.elements.reference) {
this.popper.state.elements.reference = referenceEl;
this.popper.setOptions({}); // .options() re-instanciate all modifiers and updates the view
}
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.update();
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._eventListenersEnabled = true;
this._triggerOnEvents = true;
this.popper.setOptions({}); // setOptions will re-instanciate all modifiers.
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._eventListenersEnabled = false;
this._triggerOnEvents = true;
this.popper.setOptions({}); // setOptions will re-instanciate all modifiers.
return this;
},
};
export default Alignment;