feat(overlays): base LocalOverlay positioning system on Popper.js
This commit is contained in:
parent
187d50b6bc
commit
22357ea81f
14 changed files with 397 additions and 1514 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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 {};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
@ -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>
|
||||
`;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
<button>Invoker</button>
|
||||
`,
|
||||
invokerTemplate: () => html`
|
||||
<button>Invoker</button>
|
||||
`,
|
||||
trapsKeyboardFocus: false,
|
||||
});
|
||||
// make sure we're connected to the dom
|
||||
await fixture(
|
||||
html`
|
||||
${controller.invoker}${controller.content}
|
||||
`,
|
||||
);
|
||||
await fixture(html`
|
||||
${controller.invoker}${controller.content}
|
||||
`);
|
||||
const elOutside = await fixture(`<button>click me</button>`);
|
||||
controller.show();
|
||||
const el1 = controller.content.querySelector('button');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
10
yarn.lock
10
yarn.lock
|
|
@ -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==
|
||||
|
|
|
|||
Loading…
Reference in a new issue