UNPKG

@atlassian/aui

Version:

Atlassian User Interface library

388 lines (340 loc) 13.7 kB
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;