feat(overlays): base LocalOverlay positioning system on Popper.js

This commit is contained in:
Joren Broekema 2019-06-27 14:09:19 +02:00
parent 187d50b6bc
commit 22357ea81f
14 changed files with 397 additions and 1514 deletions

View file

@ -32,7 +32,8 @@
"*.js"
],
"dependencies": {
"@lion/core": "^0.1.9"
"@lion/core": "^0.1.5",
"popper.js": "^1.15.0"
},
"devDependencies": {
"@open-wc/demoing-storybook": "^0.2.0",

View file

@ -1,20 +1,20 @@
import { render, html } from '@lion/core';
import { managePosition } from './utils/manage-position.js';
import { containFocus } from './utils/contain-focus.js';
import { keyCodes } from './utils/key-codes.js';
async function __preloadPopper() {
return import('popper.js/dist/popper.min.js');
}
export class LocalOverlayController {
constructor(params = {}) {
const finalParams = {
placement: 'top',
position: 'absolute',
...params,
};
this.hidesOnEsc = finalParams.hidesOnEsc;
this.hidesOnOutsideClick = finalParams.hidesOnOutsideClick;
this.trapsKeyboardFocus = finalParams.trapsKeyboardFocus;
this.placement = finalParams.placement;
this.position = finalParams.position;
// TODO: Instead of in constructor, prefetch it or use a preloader-manager to load it during idle time
this.constructor.popperModule = __preloadPopper();
this.__mergePlacementConfigs(params.placementConfig || {});
this.hidesOnEsc = params.hidesOnEsc;
this.hidesOnOutsideClick = params.hidesOnOutsideClick;
this.trapsKeyboardFocus = params.trapsKeyboardFocus;
/**
* A wrapper to render into the invokerTemplate
*
@ -22,15 +22,16 @@ export class LocalOverlayController {
*/
this.invoker = document.createElement('div');
this.invoker.style.display = 'inline-block';
this.invokerTemplate = finalParams.invokerTemplate;
this.invokerTemplate = params.invokerTemplate;
/**
* The actual invoker element we work with - it get's all the events and a11y
*
* @property {HTMLElement}
*/
this.invokerNode = this.invoker;
if (finalParams.invokerNode) {
this.invokerNode = finalParams.invokerNode;
if (params.invokerNode) {
this.invokerNode = params.invokerNode;
this.invoker = this.invokerNode;
}
@ -41,10 +42,10 @@ export class LocalOverlayController {
*/
this.content = document.createElement('div');
this.content.style.display = 'inline-block';
this.contentTemplate = finalParams.contentTemplate;
this.contentTemplate = params.contentTemplate;
this.contentNode = this.content;
if (finalParams.contentNode) {
this.contentNode = finalParams.contentNode;
if (params.contentNode) {
this.contentNode = params.contentNode;
this.content = this.contentNode;
}
@ -94,8 +95,17 @@ export class LocalOverlayController {
/**
* Shows the overlay.
*/
show() {
async show() {
this._createOrUpdateOverlay(true, this._prevData);
/**
* Popper is weird about properly positioning the popper element when its is recreated so
* we just recreate the popper instance to make it behave like it should.
* Probably related to this issue: https://github.com/FezVrasta/popper.js/issues/796
* calling just the .update() function on the popper instance sadly does not resolve this.
* This is however necessary for initial placement.
*/
await this.__createPopperInstance();
this._popper.update();
}
/**
@ -113,6 +123,13 @@ export class LocalOverlayController {
this.isShown ? this.hide() : this.show();
}
// Popper does not export a nice method to update an existing instance with a new config. Therefore we recreate the instance.
// TODO: Send a merge request to Popper to abstract their logic in the constructor to an exposed method which takes in the user config.
async updatePlacementConfig(config = {}) {
this.__mergePlacementConfigs(config);
await this.__createPopperInstance();
}
_createOrUpdateOverlay(shown = this._prevShown, data = this._prevData) {
if (shown) {
this._contentData = { ...this._contentData, ...data };
@ -122,15 +139,10 @@ export class LocalOverlayController {
render(this.contentTemplate(this._contentData), this.content);
this.contentNode = this.content.firstElementChild;
}
this.contentNode.style.display = 'inline-block';
this.contentNode.id = this.contentId;
this.contentNode.style.display = 'inline-block';
this.invokerNode.setAttribute('aria-expanded', true);
managePosition(this.contentNode, this.invokerNode, {
placement: this.placement,
position: this.position,
});
if (this.trapsKeyboardFocus) this._setupTrapsKeyboardFocus();
if (this.hidesOnOutsideClick) this._setupHidesOnOutsideClick();
if (this.hidesOnEsc) this._setupHidesOnEsc();
@ -214,4 +226,52 @@ export class LocalOverlayController {
this.hide();
}
}
/**
* Merges the default config with the current config, and finally with the user supplied config
* @param {Object} config user supplied configuration
*/
__mergePlacementConfigs(config = {}) {
this.placementConfig = {
placement: 'top',
positionFixed: false,
...(this.placementConfig || {}),
...(config || {}),
modifiers: {
keepTogether: {
enabled: false,
},
preventOverflow: {
enabled: true,
boundariesElement: 'viewport',
padding: 16, // viewport-margin for shifting/sliding
},
flip: {
boundariesElement: 'viewport',
padding: 16, // viewport-margin for flipping
},
offset: {
enabled: true,
offset: `0, 8px`, // horizontal and vertical margin (distance between popper and referenceElement)
},
arrow: {
enabled: false,
},
...((this.placementConfig && this.placementConfig.modifiers) || {}),
...((config && config.modifiers) || {}),
},
};
}
async __createPopperInstance() {
if (this._popper) {
this._popper.destroy();
this._popper = null;
}
const mod = await this.constructor.popperModule;
const Popper = mod.default;
this._popper = new Popper(this.invokerNode, this.contentNode, {
...this.placementConfig,
});
}
}

View file

@ -1,207 +0,0 @@
/* eslint-disable */
/**
@license
Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
Code distributed by Google as part of the polymer project is also
subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
*/
/**
* @fileoverview
*
* This module provides a number of strategies for enqueuing asynchronous
* tasks. Each sub-module provides a standard `run(fn)` interface that returns a
* handle, and a `cancel(handle)` interface for canceling async tasks before
* they run.
*
* @summary Module that provides a number of strategies for enqueuing
* asynchronous tasks.
*/
import './boot.js';
// Microtask implemented using Mutation Observer
let microtaskCurrHandle = 0;
let microtaskLastHandle = 0;
let microtaskCallbacks = [];
let microtaskNodeContent = 0;
let microtaskNode = document.createTextNode('');
new window.MutationObserver(microtaskFlush).observe(microtaskNode, { characterData: true });
function microtaskFlush() {
const len = microtaskCallbacks.length;
for (let i = 0; i < len; i++) {
let cb = microtaskCallbacks[i];
if (cb) {
try {
cb();
} catch (e) {
setTimeout(() => {
throw e;
});
}
}
}
microtaskCallbacks.splice(0, len);
microtaskLastHandle += len;
}
/**
* Async interface wrapper around `setTimeout`.
*
* @namespace
* @summary Async interface wrapper around `setTimeout`.
*/
const timeOut = {
/**
* Returns a sub-module with the async interface providing the provided
* delay.
*
* @memberof timeOut
* @param {number=} delay Time to wait before calling callbacks in ms
* @return {!AsyncInterface} An async timeout interface
*/
after(delay) {
return {
run(fn) {
return window.setTimeout(fn, delay);
},
cancel(handle) {
window.clearTimeout(handle);
},
};
},
/**
* Enqueues a function called in the next task.
*
* @memberof timeOut
* @param {!Function} fn Callback to run
* @param {number=} delay Delay in milliseconds
* @return {number} Handle used for canceling task
*/
run(fn, delay) {
return window.setTimeout(fn, delay);
},
/**
* Cancels a previously enqueued `timeOut` callback.
*
* @memberof timeOut
* @param {number} handle Handle returned from `run` of callback to cancel
* @return {void}
*/
cancel(handle) {
window.clearTimeout(handle);
},
};
export { timeOut };
/**
* Async interface wrapper around `requestAnimationFrame`.
*
* @namespace
* @summary Async interface wrapper around `requestAnimationFrame`.
*/
const animationFrame = {
/**
* Enqueues a function called at `requestAnimationFrame` timing.
*
* @memberof animationFrame
* @param {function(number):void} fn Callback to run
* @return {number} Handle used for canceling task
*/
run(fn) {
return window.requestAnimationFrame(fn);
},
/**
* Cancels a previously enqueued `animationFrame` callback.
*
* @memberof animationFrame
* @param {number} handle Handle returned from `run` of callback to cancel
* @return {void}
*/
cancel(handle) {
window.cancelAnimationFrame(handle);
},
};
export { animationFrame };
/**
* Async interface wrapper around `requestIdleCallback`. Falls back to
* `setTimeout` on browsers that do not support `requestIdleCallback`.
*
* @namespace
* @summary Async interface wrapper around `requestIdleCallback`.
*/
const idlePeriod = {
/**
* Enqueues a function called at `requestIdleCallback` timing.
*
* @memberof idlePeriod
* @param {function(!IdleDeadline):void} fn Callback to run
* @return {number} Handle used for canceling task
*/
run(fn) {
return window.requestIdleCallback ? window.requestIdleCallback(fn) : window.setTimeout(fn, 16);
},
/**
* Cancels a previously enqueued `idlePeriod` callback.
*
* @memberof idlePeriod
* @param {number} handle Handle returned from `run` of callback to cancel
* @return {void}
*/
cancel(handle) {
window.cancelIdleCallback ? window.cancelIdleCallback(handle) : window.clearTimeout(handle);
},
};
export { idlePeriod };
/**
* Async interface for enqueuing callbacks that run at microtask timing.
*
* Note that microtask timing is achieved via a single `MutationObserver`,
* and thus callbacks enqueued with this API will all run in a single
* batch, and not interleaved with other microtasks such as promises.
* Promises are avoided as an implementation choice for the time being
* due to Safari bugs that cause Promises to lack microtask guarantees.
*
* @namespace
* @summary Async interface for enqueuing callbacks that run at microtask
* timing.
*/
const microTask = {
/**
* Enqueues a function called at microtask timing.
*
* @memberof microTask
* @param {!Function=} callback Callback to run
* @return {number} Handle used for canceling task
*/
run(callback) {
microtaskNode.textContent = microtaskNodeContent++;
microtaskCallbacks.push(callback);
return microtaskCurrHandle++;
},
/**
* Cancels a previously enqueued `microTask` callback.
*
* @memberof microTask
* @param {number} handle Handle returned from `run` of callback to cancel
* @return {void}
*/
cancel(handle) {
const idx = handle - microtaskLastHandle;
if (idx >= 0) {
if (!microtaskCallbacks[idx]) {
throw new Error('invalid async handle: ' + handle);
}
microtaskCallbacks[idx] = null;
}
},
};
export { microTask };

View file

@ -1,26 +0,0 @@
/* eslint-disable */
/**
@license
Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
Code distributed by Google as part of the polymer project is also
subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
*/
/* eslint-disable no-unused-vars */
/**
* When using Closure Compiler, JSCompiler_renameProperty(property, object) is replaced by the munged name for object[property]
* We cannot alias this function, so we have to use a small shim that has the same behavior when not compiling.
*
* @param {string} prop Property name
* @param {?Object} obj Reference object
* @return {string} Potentially renamed property name
*/
window.JSCompiler_renameProperty = function(prop, obj) {
return prop;
};
/* eslint-enable */
export {};

View file

@ -1,116 +0,0 @@
/* eslint-disable */
/**
@license
Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
Code distributed by Google as part of the polymer project is also
subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
*/
import './boot.js';
import './mixin.js';
import './async.js';
/**
* @summary Collapse multiple callbacks into one invocation after a timer.
*/
export class Debouncer {
constructor() {
this._asyncModule = null;
this._callback = null;
this._timer = null;
}
/**
* Sets the scheduler; that is, a module with the Async interface,
* a callback and optional arguments to be passed to the run function
* from the async module.
*
* @param {!AsyncInterface} asyncModule Object with Async interface.
* @param {function()} callback Callback to run.
* @return {void}
*/
setConfig(asyncModule, callback) {
this._asyncModule = asyncModule;
this._callback = callback;
this._timer = this._asyncModule.run(() => {
this._timer = null;
this._callback();
});
}
/**
* Cancels an active debouncer and returns a reference to itself.
*
* @return {void}
*/
cancel() {
if (this.isActive()) {
this._asyncModule.cancel(/** @type {number} */ (this._timer));
this._timer = null;
}
}
/**
* Flushes an active debouncer and returns a reference to itself.
*
* @return {void}
*/
flush() {
if (this.isActive()) {
this.cancel();
this._callback();
}
}
/**
* Returns true if the debouncer is active.
*
* @return {boolean} True if active.
*/
isActive() {
return this._timer != null;
}
/**
* Creates a debouncer if no debouncer is passed as a parameter
* or it cancels an active debouncer otherwise. The following
* example shows how a debouncer can be called multiple times within a
* microtask and "debounced" such that the provided callback function is
* called once. Add this method to a custom element:
*
* ```js
* import {microTask} from '@polymer/polymer/lib/utils/async.js';
* import {Debouncer} from '@polymer/polymer/lib/utils/debounce.js';
* // ...
*
* _debounceWork() {
* this._debounceJob = Debouncer.debounce(this._debounceJob,
* microTask, () => this._doWork());
* }
* ```
*
* If the `_debounceWork` method is called multiple times within the same
* microtask, the `_doWork` function will be called only once at the next
* microtask checkpoint.
*
* Note: In testing it is often convenient to avoid asynchrony. To accomplish
* this with a debouncer, you can use `enqueueDebouncer` and
* `flush`. For example, extend the above example by adding
* `enqueueDebouncer(this._debounceJob)` at the end of the
* `_debounceWork` method. Then in a test, call `flush` to ensure
* the debouncer has completed.
*
* @param {Debouncer?} debouncer Debouncer object.
* @param {!AsyncInterface} asyncModule Object with Async interface
* @param {function()} callback Callback to run.
* @return {!Debouncer} Returns a debouncer object.
*/
static debounce(debouncer, asyncModule, callback) {
if (debouncer instanceof Debouncer) {
debouncer.cancel();
} else {
debouncer = new Debouncer();
}
debouncer.setConfig(asyncModule, callback);
return debouncer;
}
}

View file

@ -1,338 +0,0 @@
/**
* @typedef {Object} Config Expand/collapse config
* @property {number} [viewportMargin] space between positioned element and the viewport
* @property {number} [verticalMargin] space between the positioned element and the element it's
* positioned relatively to
* @property {'top left' | 'top right' | 'bottom left' | 'bottom right'} [placement] Preferred
* placement. Defaults to 'bottom right'
* @property {'absolute' | 'fixed'} [position] position property. Defaults to absolute
* @property {number} [minHeight] Minimum height the positioned element should take up
* @property {number} [minWidth] Minimum width the positioned element should take up
*/
/**
* @typedef {Object} Viewport the viewport, defaults to the document.documentElement but can be
* overwritten for testing
* @property {number} clientHeight height of the viewport
* @property {number} clientWidth width of the viewport
*/
/**
* @typedef {Object} RelativeElement stripped down property definition of relative element
* @property {number} offsetLeft height of the viewport
* @property {number} offsetTop width of the viewport
*/
/**
* @typedef {Object} PositionContext
* @property {RelativeElement} relEl the element being positioned relative to
* @property {ClientRect} elRect positioned element ClientRect
* @property {ClientRect} relRect relative element ClientRect
* @property {number} viewportMargin margin between the positioned element and the viewport
* @property {number} verticalMargin vertical margin between the
* positioned element and the relative element
* @property {number} horizontalMargin horizontal margin between the
* positioned element and the relative element
* @property {Viewport} viewport reference to the window, can be overwritten for tests
*/
/** Calculates position relative to the trigger element for a given direction. */
export const calcAbsolutePosition = {
/**
* Legend:
*
* offsetTop: distance of top of element to the top of the parent relative/absolutely
* positioned context, if any. Otherwise the window.
* offsetLeft: distance of left of element to the left of parent relative/absolutely
* positioned context, if any. Otherwise the window.
*
* positionedHeight / Width: the height/width of the positioned element, after taking all
* the calculations and clipping into account
*/
/**
* @param {PositionContext} pc
* @param {number} positionedHeight
*/
top: (pc, positionedHeight) => pc.relEl.offsetTop - positionedHeight - pc.verticalMargin,
/** @param {PositionContext} pc */
bottom: pc => pc.relEl.offsetTop + pc.relRect.height + pc.verticalMargin,
/**
* @param {PositionContext} pc
* @param {number} positionedWidth
*/
left: (pc, positionedWidth, isCenteredVertically) => {
if (isCenteredVertically) {
return (
pc.relEl.offsetLeft -
(positionedWidth - pc.relRect.width) -
(pc.relEl.offsetWidth || 0) -
pc.horizontalMargin
);
}
return pc.relEl.offsetLeft - (positionedWidth - pc.relRect.width);
},
/** @param {PositionContext} pc */
right: (pc, positionedWidth, isCenteredVertically) => {
if (isCenteredVertically) {
return pc.relEl.offsetLeft + (pc.relEl.offsetWidth || 0) + pc.horizontalMargin;
}
return pc.relEl.offsetLeft;
},
/**
* @param {PositionContext} pc
* @param {number} positionedWidth
*/
centerHorizontal: (pc, positionedWidth) =>
pc.relEl.offsetLeft - (positionedWidth - pc.relRect.width) / 2,
/**
* @param {PositionContext} pc
*/
centerVertical: (pc, positionedHeight) =>
pc.relEl.offsetTop - (positionedHeight - pc.relRect.height) / 2,
};
/** Calculates position relative to the trigger element for a given direction. */
export const calcFixedPosition = {
/**
* Legend:
*
* top: distance of top of element to the top of the document
* top: distance of top of element to the top of the document
*
* positionedHeight / Width: the height/width of the positioned element, after taking all
* the calculations and clipping into account
*/
/**
* @param {PositionContext} pc
* @param {number} positionedHeight
*/
top: (pc, positionedHeight) => pc.relRect.top - positionedHeight - pc.verticalMargin,
/** @param {PositionContext} pc */
bottom: pc => pc.relRect.top + pc.relRect.height + pc.verticalMargin,
/**
* @param {PositionContext} pc
* @param {number} positionedWidth
*/
left: (pc, positionedWidth, isCenteredVertically) => {
if (isCenteredVertically) {
return pc.relRect.left - positionedWidth - pc.horizontalMargin;
}
return pc.relRect.left - (positionedWidth - pc.relRect.width);
},
/** @param {PositionContext} pc */
right: (pc, positionedWidth, isCenteredVertically) => {
if (isCenteredVertically) {
return pc.relRect.left + pc.relRect.width + pc.horizontalMargin;
}
return pc.relRect.left;
},
/**
* @param {PositionContext} pc
* @param {number} positionedWidth
*/
centerHorizontal: (pc, positionedWidth) =>
pc.relRect.left - (positionedWidth - pc.relRect.width) / 2,
/**
* @param {PositionContext} pc
* @param {number} positionedHeight
*/
centerVertical: (pc, positionedHeight) =>
pc.relRect.top - (positionedHeight - pc.relRect.height) / 2,
};
/**
* Calculates the available space within the viewport around element we are positioning relatively
* to for a given direction.
*/
export const getAvailableSpace = {
/**
* Legend:
*
* top: distance of top of element to top of viewport
* left: distance of left of element to left of viewport
* bottom: distance of bottom of element to top of viewport (not bottom of viewport)
* right: distance of right of element to left of viewport (not right of viewport)
* innerHeight: visible height of viewport
* innerWidth: visible width of the viewport
*/
/** @param {PositionContext} pc */
top: pc => pc.relRect.top - pc.viewportMargin,
/** @param {PositionContext} pc */
bottom: pc => (pc.viewport.clientHeight || 0) - pc.relRect.bottom - pc.viewportMargin,
/** @param {PositionContext} pc */
left: (pc, isCenteredVertically) => {
if (isCenteredVertically) {
return pc.relRect.left - pc.viewportMargin - pc.horizontalMargin;
}
return pc.relRect.right - pc.viewportMargin;
},
/** @param {PositionContext} pc */
right: (pc, isCenteredVertically) => {
const distanceFromRight = (pc.viewport.clientWidth || 0) - pc.relRect.left;
if (isCenteredVertically) {
return (
distanceFromRight - (pc.relEl.offsetWidth || 0) - pc.viewportMargin - pc.horizontalMargin
);
}
return distanceFromRight - pc.viewportMargin;
},
/** @param {PositionContext} pc */
centerHorizontal: pc => {
// Center position means you get half the relRect's width as extra space
// Deduct the viewportMargin from that
const extraSpace = pc.relRect.width / 2 - pc.viewportMargin;
const spaceAvailableLeft = pc.relRect.left + extraSpace;
const spaceAvailableRight = (pc.viewport.clientWidth || 0) - pc.relRect.right + extraSpace;
return [spaceAvailableLeft, spaceAvailableRight];
},
/** @param {PositionContext} pc */
centerVertical: pc => pc.relRect.top - (pc.viewportMargin + pc.elRect.height / 2),
};
/**
* Calculates the direction the element should flow to. We first try to position the element in the
* preferred direction. If this does not fit, we take the direction which has the most available
* space.
*
* @param {number} minSpace
* @param {string} prefDir
* @param {{ name: string, space: number }} dirA
* @param {{ name: string, space: number }} dirB
* @param {{ name: string, space: number }} dirC
*/
export function calcDirection(minSpace, prefDir, dirA, dirB, dirC) {
// Determine order to check direction, preferred direction comes first
let dirs = [];
if (prefDir === dirB.name) {
dirs = [dirB, dirA, dirC];
} else if (prefDir === dirC.name) {
dirs = [dirC, dirA, dirB];
} else {
dirs = [dirA, dirB, dirC];
}
// Check if there is enough space
// For horizontal center, check both directions, space required is half
if (Array.isArray(dirs[0].space)) {
if (dirs[0].space[0] >= minSpace / 2 && dirs[0].space[1] >= minSpace / 2) {
return dirs[0];
}
} else if (dirs[0].space >= minSpace) {
return dirs[0];
}
// Otherwise, take whichever has the most space
// Horizontal center should always be our last fallback
/* eslint-disable-next-line arrow-body-style */
const dirsSort = (a, b) => {
if (Array.isArray(a.space)) {
return 1;
}
if (Array.isArray(b.space)) {
return -1;
}
return a.space > b.space ? -1 : 1;
};
return dirs.sort(dirsSort)[0];
}
/**
* Distracts the placement to horizontal and vertical
* By default, uses 'bottom right' as placement
* @param {string} placement, options are ['top','right','bottom','left','center']
* and ordered as [vertical horizontal]
*/
export function getPlacement(placement) {
let vertical;
let horizontal;
const placementArgs = placement.split(' ');
// If only 1 argument is given, assume center for the other one
if (placementArgs.length === 1) {
if (placementArgs[0] === 'top' || placementArgs[0] === 'bottom') {
placementArgs.push('center');
} else if (placementArgs[0] === 'left' || placementArgs[0] === 'right') {
placementArgs.unshift('center');
}
}
[vertical, horizontal] = placementArgs;
if (vertical === 'center') vertical = 'centerVertical';
if (horizontal === 'center') horizontal = 'centerHorizontal';
return { vertical, horizontal };
}
/**
* Calculates the position of the element. We try to position the element above or below and
* left or right of the trigger element. Preferred direction is evaluated first. If a minWidth
* or minHeight is given, it is taken into account as well. Otherwise, the element full height
* or width without restrictions is taken as max.
*
* If not enough space is found on any direction, the direction with the most available space is
* taken.
*
* @param {PositionContext} pc
* @param {Config} config
*/
export function getPosition(pc, config) {
// Calculate the directions to position into
const verticalDir = calcDirection(
config.minHeight || pc.elRect.height,
getPlacement(config.placement).vertical,
{ name: 'top', space: getAvailableSpace.top(pc) },
{ name: 'bottom', space: getAvailableSpace.bottom(pc) },
{ name: 'centerVertical', space: getAvailableSpace.centerVertical(pc) },
);
const isCenteredVertically = verticalDir.name === 'centerVertical';
const horizontalDir = calcDirection(
config.minWidth || pc.elRect.width,
getPlacement(config.placement).horizontal,
{ name: 'left', space: getAvailableSpace.left(pc, isCenteredVertically) },
{ name: 'right', space: getAvailableSpace.right(pc, isCenteredVertically) },
{ name: 'centerHorizontal', space: getAvailableSpace.centerHorizontal(pc) },
);
// Max dimensions will be available space for the direction + viewportMargin
// const maxHeight = verticalDir.space - pc.viewportMargin;
// const maxWidth = horizontalDir.space - pc.viewportMargin;
const maxHeight = verticalDir.space;
let maxWidth;
if (Array.isArray(horizontalDir.space)) {
maxWidth = Math.min(horizontalDir.space[0] * 2, horizontalDir.space[1] * 2);
} else {
maxWidth = horizontalDir.space;
}
// Actual dimensions will be max dimensions or current dimensions if they are already smaller
const eventualHeight = Math.min(maxHeight, pc.elRect.height);
const eventualWidth = Math.min(maxWidth, pc.elRect.width);
const calcPosition = config.position === 'absolute' ? calcAbsolutePosition : calcFixedPosition;
return {
maxHeight,
width: Math.min(eventualWidth, maxWidth),
top: calcPosition[verticalDir.name](pc, eventualHeight),
left: calcPosition[horizontalDir.name](pc, eventualWidth, isCenteredVertically),
verticalDir: verticalDir.name,
horizontalDir: horizontalDir.name,
};
}

View file

@ -1,150 +0,0 @@
import { Debouncer } from './debounce.js';
import { animationFrame } from './async.js';
import { getPosition } from './get-position.js';
/* eslint-disable no-param-reassign */
const genericObservedEvents = ['resize', 'orentationchange'];
const observedFixedEvents = ['scroll'];
const elementStyleProps = [
'position',
'z-index',
'overflow',
'box-sizing',
'top',
'left',
'max-height',
'width',
];
const elementAttributes = ['js-positioning-horizontal', 'js-positioning-vertical'];
/**
* @param {HTMLElement} el
* @param {HTMLElement} relEl
* @param {number} viewportMargin
* @param {number} verticalMargin
* @param {number} horizontalMargin
* @param {Config} config
*/
/* eslint-disable-next-line max-len */
function updatePosition(
el,
relEl,
viewportMargin,
verticalMargin,
horizontalMargin,
config,
viewport,
) {
// Reset width/height restrictions so that desired height/width can be calculated
el.style.removeProperty('max-height');
el.style.removeProperty('width');
const elRect = el.getBoundingClientRect();
const relRect = relEl.getBoundingClientRect();
const positionContext = {
relEl,
elRect,
relRect,
viewportMargin,
verticalMargin,
horizontalMargin,
viewport,
};
const position = getPosition(positionContext, config);
el.style.top = `${position.top}px`;
el.style.left = `${position.left}px`;
el.style.maxHeight = `${position.maxHeight}px`;
el.style.width = `${position.width}px`;
el.setAttribute('js-positioning-horizontal', position.horizontalDir);
el.setAttribute('js-positioning-vertical', position.verticalDir);
}
/**
* Manages the positions of an element relative to another element. The element is positioned
* in the direction which has the most available space around the other element. The element's
* width/height is cut off where it overflows the viewport.
*
* The positioned element's position is updated to account for viewport changes.
*
* Call updatePosition() to manually trigger a position update.
* Call disconnect() on the returned handler to stop managing the position.
*
* @param {HTMLElement} element The element to position relatively.
* @param {HTMLElement} relativeTo The element to position relatively to.
* @param {Config} config
*/
// eslint-disable-next-line max-len
export function managePosition(
element,
relativeTo,
config = {},
viewport = document.documentElement,
) {
const {
viewportMargin = 16,
verticalMargin = 8,
horizontalMargin = 8,
placement = 'bottom right',
position = 'absolute',
minHeight,
minWidth,
} = config;
const observedEvents =
position === 'absolute'
? genericObservedEvents
: [...genericObservedEvents, ...observedFixedEvents];
let debouncer;
function handleUpdate() {
const updateConfig = {
placement,
position,
minHeight,
minWidth,
};
const params = [
element,
relativeTo,
viewportMargin,
verticalMargin,
horizontalMargin,
updateConfig,
viewport,
];
updatePosition(...params);
}
function handleUpdateEvent() {
debouncer = Debouncer.debounce(debouncer, animationFrame, handleUpdate);
}
// Cleans up listeners, properties and attributes
function disconnect() {
observedEvents.forEach(e =>
window.removeEventListener(e, handleUpdateEvent, { capture: true, passive: true }),
);
elementStyleProps.forEach(prop => {
element.style.removeProperty(prop);
});
elementAttributes.forEach(attr => {
element.removeAttribute(attr);
});
relativeTo.style.removeProperty('box-sizing');
}
observedEvents.forEach(e =>
window.addEventListener(e, handleUpdateEvent, { capture: true, passive: true }),
);
element.style.position = position;
element.style.zIndex = position === 'absolute' ? '10' : '200';
element.style.overflow = 'auto';
element.style.boxSizing = 'border-box';
relativeTo.style.boxSizing = 'border-box';
handleUpdate();
return { updatePosition: handleUpdate, disconnect };
}

View file

@ -1,71 +0,0 @@
/* eslint-disable */
/**
@license
Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
Code distributed by Google as part of the polymer project is also
subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
*/
import './boot.js';
// unique global id for deduping mixins.
let dedupeId = 0;
/**
* @constructor
* @extends {Function}
* @private
*/
function MixinFunction() {}
/** @type {(WeakMap | undefined)} */
MixinFunction.prototype.__mixinApplications;
/** @type {(Object | undefined)} */
MixinFunction.prototype.__mixinSet;
/* eslint-disable valid-jsdoc */
/**
* Wraps an ES6 class expression mixin such that the mixin is only applied
* if it has not already been applied its base argument. Also memoizes mixin
* applications.
*
* @template T
* @param {T} mixin ES6 class expression mixin to wrap
* @return {T}
* @suppress {invalidCasts}
*/
export const dedupingMixin = function(mixin) {
let mixinApplications = /** @type {!MixinFunction} */ (mixin).__mixinApplications;
if (!mixinApplications) {
mixinApplications = new WeakMap();
/** @type {!MixinFunction} */ (mixin).__mixinApplications = mixinApplications;
}
// maintain a unique id for each mixin
let mixinDedupeId = dedupeId++;
function dedupingMixin(base) {
let baseSet = /** @type {!MixinFunction} */ (base).__mixinSet;
if (baseSet && baseSet[mixinDedupeId]) {
return base;
}
let map = mixinApplications;
let extended = map.get(base);
if (!extended) {
extended = /** @type {!Function} */ (mixin)(base);
map.set(base, extended);
}
// copy inherited mixin set from the extended class, or the base class
// NOTE: we avoid use of Set here because some browser (IE11)
// cannot extend a base Set via the constructor.
let mixinSet = Object.create(
/** @type {!MixinFunction} */ (extended.__mixinSet || baseSet || null),
);
mixinSet[mixinDedupeId] = true;
/** @type {!MixinFunction} */ (extended).__mixinSet = mixinSet;
return extended;
}
return dedupingMixin;
};
/* eslint-enable valid-jsdoc */

View file

@ -1,13 +1,29 @@
import { storiesOf, html } from '@open-wc/demoing-storybook';
import { css } from '@lion/core';
import { managePosition } from '../src/utils/manage-position.js';
import { LocalOverlayController } from '../src/LocalOverlayController.js';
import { overlays } from '../src/overlays.js';
let placement = 'top';
const togglePlacement = popupController => {
const placements = [
'top-end',
'top',
'top-start',
'right-end',
'right',
'right-start',
'bottom-start',
'bottom',
'bottom-end',
'left-start',
'left',
'left-end',
];
placement = placements[(placements.indexOf(placement) + 1) % placements.length];
popupController.updatePlacementConfig({ placement });
};
const popupPlacementDemoStyle = css`
.demo-container {
height: 100vh;
background-color: #ebebeb;
}
.demo-box {
width: 40px;
height: 40px;
@ -19,9 +35,6 @@ const popupPlacementDemoStyle = css`
}
.demo-popup {
display: block;
position: absolute;
width: 250px;
background-color: white;
border-radius: 2px;
border: 1px solid grey;
@ -33,105 +46,111 @@ const popupPlacementDemoStyle = css`
storiesOf('Local Overlay System|Local Overlay Placement', module)
.addParameters({ options: { selectedPanel: 'storybook/actions/actions-panel' } })
.add('Preferred placement overlay absolute', () => {
const element = document.createElement('div');
element.classList.add('demo-popup');
element.innerText = 'Toggle the placement of this overlay with the buttons.';
const popupController = overlays.add(
new LocalOverlayController({
hidesOnEsc: true,
contentTemplate: () =>
html`
<div class="demo-popup">United Kingdom</div>
`,
invokerTemplate: () =>
html`
<button style="border: none" @click=${() => popupController.toggle()}>UK</button>
`,
}),
);
const target = document.createElement('div');
target.id = 'target';
target.classList.add('demo-box');
let placement = 'top left';
const togglePlacement = () => {
switch (placement) {
case 'top left':
placement = 'top';
break;
case 'top':
placement = 'top right';
break;
case 'top right':
placement = 'right';
break;
case 'right':
placement = 'bottom right';
break;
case 'bottom right':
placement = 'bottom';
break;
case 'bottom':
placement = 'bottom left';
break;
case 'bottom left':
placement = 'left';
break;
default:
placement = 'top left';
}
managePosition(element, target, { placement, position: 'absolute' });
};
return html`
<style>
${popupPlacementDemoStyle}
</style>
<div class="demo-container">
<button @click=${() => togglePlacement()}>Toggle placement</button>
<p>Check the action logger to see the placement changes on toggling this button.</p>
${target} ${element}
<button @click=${() => togglePlacement(popupController)}>Toggle placement</button>
<div class="demo-box">
${popupController.invoker} ${popupController.content}
</div>
`;
})
.add('Space not available', () => {
const element = document.createElement('div');
element.classList.add('demo-popup');
element.innerText = `
Toggle the placement of this overlay with the buttons.
Since there is not enough space available on the vertical center or the top for this popup,
the popup will get displayed on the available space on the bottom.
Try dragging the viewport to increase/decrease space see the behavior of this.
`;
.add('Override the placement config', () => {
const popupController = overlays.add(
new LocalOverlayController({
hidesOnEsc: true,
placementConfig: {
placement: 'bottom-start',
positionFixed: true,
modifiers: {
keepTogether: {
enabled: true /* Prevents detachment of content element from reference element */,
},
preventOverflow: {
enabled: false /* disables shifting/sliding behavior on secondary axis */,
padding: 32 /* when enabled, this is the viewport-margin for shifting/sliding */,
},
flip: {
boundariesElement: 'viewport',
padding: 16 /* viewport-margin for flipping on primary axis */,
},
offset: {
enabled: true,
offset: `0, 16px` /* horizontal and vertical margin (distance between popper and referenceElement) */,
},
},
},
contentTemplate: () =>
html`
<div class="demo-popup">United Kingdom</div>
`,
invokerTemplate: () =>
html`
<button style="border: none" @click=${() => popupController.toggle()}>UK</button>
`,
}),
);
const target = document.createElement('div');
target.id = 'target';
target.classList.add('demo-box');
let placement = 'top left';
const togglePlacement = () => {
switch (placement) {
case 'top left':
placement = 'top';
break;
case 'top':
placement = 'top right';
break;
case 'top right':
placement = 'right';
break;
case 'right':
placement = 'bottom right';
break;
case 'bottom right':
placement = 'bottom';
break;
case 'bottom':
placement = 'bottom left';
break;
case 'bottom left':
placement = 'left';
break;
default:
placement = 'top left';
}
managePosition(element, target, { placement, position: 'absolute' });
};
return html`
<style>
${popupPlacementDemoStyle}
</style>
<div class="demo-container">
<button @click=${() => togglePlacement()}>Toggle placement</button>
<p>Check the action logger to see the placement changes on toggling this button.</p>
${target} ${element}
<div>
The API is aligned with Popper.js, visit their documentation for more information:
<a href="https://popper.js.org/popper-documentation.html">Popper.js Docs</a>
</div>
<button @click=${() => togglePlacement(popupController)}>Toggle placement</button>
<div class="demo-box">
${popupController.invoker} ${popupController.content}
</div>
`;
})
/* TODO: Add this when we have a feature in place that adds scrollbars / overflow when no space is available */
.add('Space not available', () => {
const popupController = overlays.add(
new LocalOverlayController({
hidesOnEsc: true,
contentTemplate: () =>
html`
<div class="demo-popup">
Toggle the placement of this overlay with the buttons. Since there is not enough space
available on the vertical center or the top for this popup, the popup will get
displayed on the available space on the bottom. Try dragging the viewport to
increase/decrease space see the behavior of this.
</div>
`,
invokerTemplate: () =>
html`
<button style="border: none" @click=${() => popupController.show()}>UK</button>
`,
}),
);
return html`
<style>
${popupPlacementDemoStyle}
</style>
<div>
<button @click=${() => togglePlacement(popupController)}>Toggle placement</button>
<button @click=${() => popupController.hide()}>Close popup</button>
</div>
<div class="demo-box">
${popupController.invoker} ${popupController.content}
</div>
`;
});

View file

@ -19,7 +19,6 @@ const popupDemoStyle = css`
position: absolute;
background-color: white;
border-radius: 2px;
border: 1px solid grey;
box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.12), 0 6px 6px 0 rgba(0, 0, 0, 0.24);
padding: 8px;
}
@ -37,7 +36,7 @@ storiesOf('Local Overlay System|Local Overlay', module)
`,
invokerTemplate: () =>
html`
<button @click=${() => popup.toggle()}>UK</button>
<button style="border: none" @click=${() => popup.toggle()}>UK</button>
`,
}),
);

View file

@ -1,4 +1,5 @@
import { expect, fixture, html, aTimeout, defineCE, unsafeStatic } from '@open-wc/testing';
import Popper from 'popper.js/dist/popper.min.js';
import { keyUpOn } from '@polymer/iron-test-helpers/mock-interactions.js';
import { LionLitElement } from '@lion/core/src/LionLitElement.js';
@ -163,7 +164,7 @@ describe('LocalOverlayController', () => {
`,
});
controller.show();
await controller.show();
controller.sync({ data: { text: 'foo' } });
expect(controller.content.textContent.trim()).to.equal('foo');
@ -194,8 +195,29 @@ describe('LocalOverlayController', () => {
// Please use absolute positions in the tests below to prevent the HTML generated by
// the test runner from interfering.
describe('positioning', () => {
it('creates a popper instance on the controller when shown, keeps it when hidden', async () => {
const controller = new LocalOverlayController({
contentTemplate: () =>
html`
<p>Content</p>
`,
invokerTemplate: () =>
html`
<button>Invoker</button>
`,
});
await controller.show();
expect(controller._popper)
.to.be.an.instanceof(Popper)
.and.have.property('modifiers');
controller.hide();
expect(controller._popper)
.to.be.an.instanceof(Popper)
.and.have.property('modifiers');
});
it('positions correctly', async () => {
// smoke test for integration of positioning system
// smoke test for integration of popper
const controller = new LocalOverlayController({
contentTemplate: () =>
html`
@ -207,8 +229,11 @@ describe('LocalOverlayController', () => {
`,
});
controller.show();
expect(controller.contentNode.style.top).to.equal('8px');
await controller.show();
// 16px displacement due to default 16px viewport margin both horizontal and vertical
expect(controller.content.firstElementChild.style.transform).to.equal(
'translate3d(16px, 16px, 0px)',
);
});
it('uses top as the default placement', async () => {
@ -224,14 +249,13 @@ describe('LocalOverlayController', () => {
`,
});
await fixture(html`
<div style="position: absolute; left: 100px; top: 50px;">
<div style="position: absolute; left: 100px; top: 100px;">
${controller.invoker} ${controller.content}
</div>
`);
controller.show();
const { contentNode } = controller;
expect(contentNode.getAttribute('js-positioning-vertical')).to.equal('top');
expect(contentNode.getAttribute('js-positioning-horizontal')).to.equal('centerHorizontal');
await controller.show();
const contentChild = controller.content.firstElementChild;
expect(contentChild.getAttribute('x-placement')).to.equal('top');
});
it('positions to preferred place if placement is set and space is available', async () => {
@ -245,7 +269,9 @@ describe('LocalOverlayController', () => {
html`
<p>Content</p>
`,
placement: 'top right',
placementConfig: {
placement: 'left-start',
},
});
await fixture(html`
<div style="position: absolute; left: 100px; top: 50px;">
@ -253,10 +279,9 @@ describe('LocalOverlayController', () => {
</div>
`);
controller.show();
const { contentNode } = controller;
expect(contentNode.getAttribute('js-positioning-vertical')).to.equal('top');
expect(contentNode.getAttribute('js-positioning-horizontal')).to.equal('right');
await controller.show();
const contentChild = controller.content.firstElementChild;
expect(contentChild.getAttribute('x-placement')).to.equal('left-start');
});
it('positions to different place if placement is set and no space is available', async () => {
@ -270,7 +295,9 @@ describe('LocalOverlayController', () => {
Invoker
</button>
`,
placement: 'top right',
placementConfig: {
placement: 'top-start',
},
});
await fixture(`
<div style="position: absolute; top: 0;">
@ -278,10 +305,145 @@ describe('LocalOverlayController', () => {
</div>
`);
controller.show();
const { contentNode } = controller;
expect(contentNode.getAttribute('js-positioning-vertical')).to.equal('bottom');
expect(contentNode.getAttribute('js-positioning-horizontal')).to.equal('right');
await controller.show();
const contentChild = controller.content.firstElementChild;
expect(contentChild.getAttribute('x-placement')).to.equal('bottom-start');
});
it('allows the user to override default Popper modifiers', async () => {
const controller = new LocalOverlayController({
placementConfig: {
modifiers: {
keepTogether: {
enabled: false,
},
offset: {
enabled: true,
offset: `0, 16px`,
},
},
},
contentTemplate: () =>
html`
<p>Content</p>
`,
invokerTemplate: () => html`
<button style="padding: 16px;" @click=${() => controller.show()}>Invoker</button>
`,
});
await fixture(html`
<div style="position: absolute; left: 100px; top: 50px;">
${controller.invoker} ${controller.content}
</div>
`);
await controller.show();
const keepTogether = controller._popper.modifiers.find(item => item.name === 'keepTogether');
const offset = controller._popper.modifiers.find(item => item.name === 'offset');
expect(keepTogether.enabled).to.be.false;
expect(offset.enabled).to.be.true;
expect(offset.offset).to.equal('0, 16px');
});
it('updates placementConfig even when overlay is closed', async () => {
const controller = new LocalOverlayController({
contentTemplate: () =>
html`
<p>Content</p>
`,
invokerTemplate: () => html`
<button style="padding: 16px; margin: 200px;" @click=${() => controller.show()}>
Invoker
</button>
`,
placementConfig: {
placement: 'top',
},
});
await fixture(html`
<div style="width: 800px; height: 800px;">
${controller.invoker} ${controller.content}
</div>
`);
await controller.show();
const contentChild = controller.content.firstElementChild;
expect(contentChild.getAttribute('x-placement')).to.equal('top');
controller.hide();
await controller.updatePlacementConfig({ placement: 'bottom' });
await controller.show();
expect(controller._popper.options.placement).to.equal('bottom');
});
it('positions the popper element correctly on show', async () => {
const controller = new LocalOverlayController({
contentTemplate: () =>
html`
<p>Content</p>
`,
invokerTemplate: () => html`
<button style="padding: 16px;" @click=${() => controller.show()}>
Invoker
</button>
`,
placementConfig: {
placement: 'top',
},
});
await fixture(html`
<div style="position: absolute; top: 300px; left: 100px;">
${controller.invoker} ${controller.content}
</div>
`);
await controller.show();
let contentChild = controller.content.firstElementChild;
expect(contentChild.style.transform).to.equal('translate3d(14px, -58px, 0px)');
controller.hide();
await controller.show();
contentChild = controller.content.firstElementChild;
expect(contentChild.style.transform).to.equal('translate3d(14px, -58px, 0px)');
});
it('updates placement properly even during hidden state', async () => {
const controller = new LocalOverlayController({
contentTemplate: () =>
html`
<p>Content</p>
`,
invokerTemplate: () => html`
<button style="padding: 16px;" @click=${() => controller.show()}>
Invoker
</button>
`,
placementConfig: {
placement: 'top',
},
});
await fixture(html`
<div style="position: absolute; top: 300px; left: 100px;">
${controller.invoker} ${controller.content}
</div>
`);
await controller.show();
let contentChild = controller.content.firstElementChild;
expect(contentChild.style.transform).to.equal('translate3d(14px, -58px, 0px)');
controller.hide();
await controller.updatePlacementConfig({
modifiers: {
offset: {
enabled: true,
offset: '0, 32px',
},
},
});
await controller.show();
contentChild = controller.content.firstElementChild;
expect(controller._popper.options.modifiers.offset.offset).to.equal('0, 32px');
expect(contentChild.style.transform).to.equal('translate3d(14px, -82px, 0px)');
});
});
@ -380,18 +542,15 @@ describe('LocalOverlayController', () => {
<button id="el1">Button</button>
</div>
`,
invokerTemplate: () =>
html`
invokerTemplate: () => html`
<button>Invoker</button>
`,
trapsKeyboardFocus: false,
});
// make sure we're connected to the dom
await fixture(
html`
await fixture(html`
${controller.invoker}${controller.content}
`,
);
`);
const elOutside = await fixture(`<button>click me</button>`);
controller.show();
const el1 = controller.content.querySelector('button');

View file

@ -1,343 +0,0 @@
import { expect } from '@open-wc/testing';
import { getPosition, getPlacement } from '../../src/utils/get-position.js';
// Test cases:
// offset top, absolute in absolute
/* positionContext (pc) gets overridden in some tests to make or restrict space for the test */
describe('getPosition()', () => {
const pc = {
relEl: {
offsetTop: 50,
offsetLeft: 50,
offsetWidth: 0,
offsetHeight: 0,
},
elRect: {
height: 50,
width: 50,
top: -1,
right: -1,
bottom: -1,
left: -1,
},
relRect: {
height: 50,
width: 50,
top: 50,
right: 100,
bottom: 100,
left: 50,
},
viewportMargin: 8,
verticalMargin: 8,
horizontalMargin: 8,
viewport: { clientHeight: 200, clientWidth: 200 },
};
const config = {
placement: 'bottom right',
};
it('positions bottom right', () => {
const position = getPosition(pc, config);
expect(position).to.eql({
maxHeight: 92,
width: 50,
top: 108,
left: 50,
verticalDir: 'bottom',
horizontalDir: 'right',
});
});
it('positions top right if not enough space', () => {
const position = getPosition(
{
...pc,
relEl: { offsetTop: 90, offsetLeft: 50 },
relRect: {
height: 50,
width: 50,
top: 90,
right: 100,
bottom: 150,
left: 50,
},
},
config,
);
expect(position).to.eql({
maxHeight: 82,
width: 50,
top: 32,
left: 50,
verticalDir: 'top',
horizontalDir: 'right',
});
});
it('positions bottom left if not enough space', () => {
const position = getPosition(
{
...pc,
relEl: { offsetTop: 50, offsetLeft: 150 },
relRect: {
height: 50,
width: 50,
top: 50,
right: 200,
bottom: 100,
left: 150,
},
},
config,
);
expect(position).to.eql({
maxHeight: 92,
width: 50,
top: 108,
left: 150,
verticalDir: 'bottom',
horizontalDir: 'left',
});
});
it('takes the preferred direction if enough space', () => {
const testPc = {
...pc,
relEl: { offsetTop: 90, offsetLeft: 50 },
relRect: {
height: 50,
width: 50,
top: 80,
right: 100,
bottom: 130,
left: 50,
},
};
const position = getPosition(testPc, {
placement: 'top right',
});
expect(position).to.eql({
maxHeight: 72,
width: 50,
top: 22,
left: 50,
verticalDir: 'top',
horizontalDir: 'right',
});
});
it('handles horizontal center positions with absolute position', () => {
const testPc = {
...pc,
relEl: { offsetTop: 90, offsetLeft: 50 },
relRect: {
height: 50,
width: 50,
top: 80,
right: 100,
bottom: 130,
left: 50,
},
};
const positionTop = getPosition(testPc, {
placement: 'top',
position: 'absolute',
});
expect(positionTop).to.eql({
maxHeight: 72,
width: 50,
top: 32,
left: 50,
verticalDir: 'top',
horizontalDir: 'centerHorizontal',
});
const positionBottom = getPosition(pc, {
placement: 'bottom',
position: 'absolute',
});
expect(positionBottom).to.eql({
maxHeight: 92,
width: 50,
top: 108,
left: 50,
verticalDir: 'bottom',
horizontalDir: 'centerHorizontal',
});
});
it('handles horizontal center positions with fixed position', () => {
const testPc = {
...pc,
relEl: { offsetTop: 90, offsetLeft: 50 },
relRect: {
height: 50,
width: 50,
top: 80,
right: 100,
bottom: 130,
left: 50,
},
};
const positionTop = getPosition(testPc, {
placement: 'top center',
position: 'fixed',
});
expect(positionTop).to.eql({
maxHeight: 72,
width: 50,
top: 22,
left: 50,
verticalDir: 'top',
horizontalDir: 'centerHorizontal',
});
const positionBottom = getPosition(pc, {
placement: 'bottom center',
position: 'fixed',
});
expect(positionBottom).to.eql({
maxHeight: 92,
width: 50,
top: 108,
left: 50,
verticalDir: 'bottom',
horizontalDir: 'centerHorizontal',
});
});
it('handles vertical center positions', () => {
let testPc = {
...pc,
relEl: { offsetTop: 90, offsetLeft: 50 },
relRect: {
height: 50,
width: 50,
top: 90,
right: 100,
bottom: 100,
left: 50,
},
};
const positionRight = getPosition(testPc, {
placement: 'right',
position: 'absolute',
});
expect(positionRight).to.eql({
maxHeight: 57,
width: 50,
top: 90,
left: 58,
verticalDir: 'centerVertical',
horizontalDir: 'right',
});
testPc = {
...pc,
relEl: { offsetTop: 90, offsetLeft: 50 },
relRect: {
height: 50,
width: 50,
top: 90,
right: 100,
bottom: 100,
left: 100,
},
};
const positionLeft = getPosition(testPc, {
placement: 'left',
});
expect(positionLeft).to.eql({
maxHeight: 57,
width: 50,
top: 90,
left: 42,
verticalDir: 'centerVertical',
horizontalDir: 'left',
});
});
it('handles vertical margins', () => {
const position = getPosition({ ...pc, verticalMargin: 50 }, config);
expect(position).to.eql({
maxHeight: 92,
width: 50,
top: 150,
left: 50,
verticalDir: 'bottom',
horizontalDir: 'right',
});
});
it('handles large viewport margin', () => {
const position = getPosition({ ...pc, viewportMargin: 50 }, config);
expect(position).to.eql({
maxHeight: 50,
width: 50,
top: 108,
left: 50,
verticalDir: 'bottom',
horizontalDir: 'right',
});
});
it('handles no viewport margin', () => {
const position = getPosition({ ...pc, viewportMargin: 0 }, config);
expect(position).to.eql({
maxHeight: 100,
width: 50,
top: 108,
left: 50,
verticalDir: 'bottom',
horizontalDir: 'right',
});
});
});
describe('getPlacement()', () => {
it('can overwrite horizontal and vertical placement', () => {
const placement = getPlacement('top left');
expect(placement.vertical).to.equal('top');
expect(placement.horizontal).to.equal('left');
});
it('can use center placements both vertically and horizontally', () => {
const placementVertical = getPlacement('center left');
expect(placementVertical.vertical).to.equal('centerVertical');
expect(placementVertical.horizontal).to.equal('left');
const placementHorizontal = getPlacement('top center');
expect(placementHorizontal.horizontal).to.equal('centerHorizontal');
expect(placementHorizontal.vertical).to.equal('top');
});
it('accepts a single parameter, uses center for the other', () => {
let placement = getPlacement('top');
expect(placement.vertical).to.equal('top');
expect(placement.horizontal).to.equal('centerHorizontal');
placement = getPlacement('right');
expect(placement.vertical).to.equal('centerVertical');
expect(placement.horizontal).to.equal('right');
});
});

View file

@ -1,112 +0,0 @@
import { expect } from '@open-wc/testing';
import sinon from 'sinon';
import { managePosition } from '../../src/utils/manage-position.js';
describe('managePosition()', () => {
let positionedBoundingRectCalls = 0;
let relativeBoundingRectCalls = 0;
let positionHandler;
const windowMock = {
innerHeight: 200,
innerWidth: 200,
};
const positioned = {
setAttribute: sinon.stub(),
removeAttribute: sinon.stub(),
style: {
removeProperty: sinon.stub(),
},
getBoundingClientRect() {
positionedBoundingRectCalls += 1;
return {
height: 50,
width: 50,
};
},
};
const relative = {
setAttribute: sinon.stub(),
removeAttribute: sinon.stub(),
style: {
removeProperty: sinon.stub(),
},
getBoundingClientRect() {
relativeBoundingRectCalls += 1;
return {
height: 50,
width: 50,
top: 50,
right: 100,
bottom: 100,
left: 50,
};
},
offsetTop: 50,
offsetLeft: 50,
};
beforeEach(() => {
relativeBoundingRectCalls = 0;
positionedBoundingRectCalls = 0;
positionHandler = managePosition(positioned, relative, {}, windowMock);
});
afterEach(() => {
positionHandler.disconnect();
});
it('sets the right styles', () => {
expect(relativeBoundingRectCalls).to.equal(1);
expect(positionedBoundingRectCalls).to.equal(1);
expect(positioned.style).to.eql({
removeProperty: positioned.style.removeProperty,
position: 'absolute',
zIndex: '10',
overflow: 'auto',
boxSizing: 'border-box',
top: '8px',
left: '50px',
maxHeight: '34px',
width: '50px',
});
expect(relative.style).to.eql({
boxSizing: 'border-box',
removeProperty: relative.style.removeProperty,
});
expect(relativeBoundingRectCalls).to.equal(1);
expect(positionedBoundingRectCalls).to.equal(1);
});
it('recalculates on resize, only once per animation frame', done => {
expect(relativeBoundingRectCalls).to.equal(1);
expect(positionedBoundingRectCalls).to.equal(1);
window.dispatchEvent(new CustomEvent('resize'));
expect(relativeBoundingRectCalls).to.equal(1);
expect(positionedBoundingRectCalls).to.equal(1);
requestAnimationFrame(() => {
expect(relativeBoundingRectCalls).to.equal(2);
expect(positionedBoundingRectCalls).to.equal(2);
window.dispatchEvent(new CustomEvent('resize'));
expect(relativeBoundingRectCalls).to.equal(2);
expect(positionedBoundingRectCalls).to.equal(2);
window.dispatchEvent(new CustomEvent('resize'));
expect(relativeBoundingRectCalls).to.equal(2);
expect(positionedBoundingRectCalls).to.equal(2);
done();
});
});
it('does not recalculate after disconnect', () => {
expect(relativeBoundingRectCalls).to.equal(1);
expect(positionedBoundingRectCalls).to.equal(1);
positionHandler.disconnect();
window.dispatchEvent(new CustomEvent('resize'));
expect(relativeBoundingRectCalls).to.equal(1);
expect(positionedBoundingRectCalls).to.equal(1);
});
});

View file

@ -1736,6 +1736,14 @@
npmlog "^4.1.2"
write-file-atomic "^2.3.0"
"@lion/localize@^0.1.6":
version "0.1.7"
resolved "https://artifactory.ing.net/artifactory/api/npm/releases_npm_all/@lion/localize/-/localize-0.1.7.tgz#2f0eb725fbe5fa8842985140ae3a6f0dfbd732d6"
integrity sha1-Lw63Jfvl+ohCmFFArjpvDfvXMtY=
dependencies:
"@bundled-es-modules/message-format" "6.0.4"
"@lion/core" "^0.1.4"
"@marionebl/sander@^0.6.0":
version "0.6.1"
resolved "https://registry.yarnpkg.com/@marionebl/sander/-/sander-0.6.1.tgz#1958965874f24bc51be48875feb50d642fc41f7b"
@ -10114,7 +10122,7 @@ polymer-webpack-loader@^2.0.0:
postcss "^6.0.9"
source-map "^0.5.6"
popper.js@^1.14.4:
popper.js@^1.14.4, popper.js@^1.15.0:
version "1.15.0"
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2"
integrity sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA==