From 22357ea81fdaef663442dddf379b65d489e5cf2a Mon Sep 17 00:00:00 2001 From: Joren Broekema Date: Thu, 27 Jun 2019 14:09:19 +0200 Subject: [PATCH] feat(overlays): base LocalOverlay positioning system on Popper.js --- packages/overlays/package.json | 3 +- .../overlays/src/LocalOverlayController.js | 108 ++++-- packages/overlays/src/utils/async.js | 207 ----------- packages/overlays/src/utils/boot.js | 26 -- packages/overlays/src/utils/debounce.js | 116 ------ packages/overlays/src/utils/get-position.js | 338 ----------------- .../overlays/src/utils/manage-position.js | 150 -------- packages/overlays/src/utils/mixin.js | 71 ---- .../local-overlay-placement.stories.js | 209 ++++++----- .../overlays/stories/local-overlay.stories.js | 3 +- .../test/LocalOverlayController.test.js | 215 +++++++++-- .../test/utils-tests/get-position.test.js | 343 ------------------ .../test/utils-tests/manage-position.test.js | 112 ------ yarn.lock | 10 +- 14 files changed, 397 insertions(+), 1514 deletions(-) delete mode 100644 packages/overlays/src/utils/async.js delete mode 100644 packages/overlays/src/utils/boot.js delete mode 100644 packages/overlays/src/utils/debounce.js delete mode 100644 packages/overlays/src/utils/get-position.js delete mode 100644 packages/overlays/src/utils/manage-position.js delete mode 100644 packages/overlays/src/utils/mixin.js delete mode 100644 packages/overlays/test/utils-tests/get-position.test.js delete mode 100644 packages/overlays/test/utils-tests/manage-position.test.js diff --git a/packages/overlays/package.json b/packages/overlays/package.json index cf3aa3bfa..5a4d54679 100644 --- a/packages/overlays/package.json +++ b/packages/overlays/package.json @@ -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", diff --git a/packages/overlays/src/LocalOverlayController.js b/packages/overlays/src/LocalOverlayController.js index dd433f504..15f23421c 100644 --- a/packages/overlays/src/LocalOverlayController.js +++ b/packages/overlays/src/LocalOverlayController.js @@ -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, + }); + } } diff --git a/packages/overlays/src/utils/async.js b/packages/overlays/src/utils/async.js deleted file mode 100644 index 93155d796..000000000 --- a/packages/overlays/src/utils/async.js +++ /dev/null @@ -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 }; diff --git a/packages/overlays/src/utils/boot.js b/packages/overlays/src/utils/boot.js deleted file mode 100644 index 096325e42..000000000 --- a/packages/overlays/src/utils/boot.js +++ /dev/null @@ -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 {}; diff --git a/packages/overlays/src/utils/debounce.js b/packages/overlays/src/utils/debounce.js deleted file mode 100644 index b8a7a62c0..000000000 --- a/packages/overlays/src/utils/debounce.js +++ /dev/null @@ -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; - } -} diff --git a/packages/overlays/src/utils/get-position.js b/packages/overlays/src/utils/get-position.js deleted file mode 100644 index 1d65906a1..000000000 --- a/packages/overlays/src/utils/get-position.js +++ /dev/null @@ -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, - }; -} diff --git a/packages/overlays/src/utils/manage-position.js b/packages/overlays/src/utils/manage-position.js deleted file mode 100644 index 16cc2e67a..000000000 --- a/packages/overlays/src/utils/manage-position.js +++ /dev/null @@ -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 }; -} diff --git a/packages/overlays/src/utils/mixin.js b/packages/overlays/src/utils/mixin.js deleted file mode 100644 index 33b33e7b4..000000000 --- a/packages/overlays/src/utils/mixin.js +++ /dev/null @@ -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 */ diff --git a/packages/overlays/stories/local-overlay-placement.stories.js b/packages/overlays/stories/local-overlay-placement.stories.js index ef5a2b878..df448799f 100644 --- a/packages/overlays/stories/local-overlay-placement.stories.js +++ b/packages/overlays/stories/local-overlay-placement.stories.js @@ -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` +
United Kingdom
+ `, + invokerTemplate: () => + html` + + `, + }), + ); - 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` -
- -

Check the action logger to see the placement changes on toggling this button.

- ${target} ${element} + +
+ ${popupController.invoker} ${popupController.content}
`; }) - .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` +
United Kingdom
+ `, + invokerTemplate: () => + html` + + `, + }), + ); - 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` -
- -

Check the action logger to see the placement changes on toggling this button.

- ${target} ${element} +
+ The API is aligned with Popper.js, visit their documentation for more information: + Popper.js Docs +
+ +
+ ${popupController.invoker} ${popupController.content} +
+ `; + }) + /* 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` +
+ 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. +
+ `, + invokerTemplate: () => + html` + + `, + }), + ); + + return html` + +
+ + +
+
+ ${popupController.invoker} ${popupController.content}
`; }); diff --git a/packages/overlays/stories/local-overlay.stories.js b/packages/overlays/stories/local-overlay.stories.js index 324694d32..dbed7b23d 100644 --- a/packages/overlays/stories/local-overlay.stories.js +++ b/packages/overlays/stories/local-overlay.stories.js @@ -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` - + `, }), ); diff --git a/packages/overlays/test/LocalOverlayController.test.js b/packages/overlays/test/LocalOverlayController.test.js index 311991a50..6c1ae92b3 100644 --- a/packages/overlays/test/LocalOverlayController.test.js +++ b/packages/overlays/test/LocalOverlayController.test.js @@ -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` +

Content

+ `, + invokerTemplate: () => + html` + + `, + }); + 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` -
+
${controller.invoker} ${controller.content}
`); - 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`

Content

`, - placement: 'top right', + placementConfig: { + placement: 'left-start', + }, }); await fixture(html`
@@ -253,10 +279,9 @@ describe('LocalOverlayController', () => {
`); - 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 `, - placement: 'top right', + placementConfig: { + placement: 'top-start', + }, }); await fixture(`
@@ -278,10 +305,145 @@ describe('LocalOverlayController', () => {
`); - 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` +

Content

+ `, + invokerTemplate: () => html` + + `, + }); + await fixture(html` +
+ ${controller.invoker} ${controller.content} +
+ `); + + 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` +

Content

+ `, + invokerTemplate: () => html` + + `, + placementConfig: { + placement: 'top', + }, + }); + await fixture(html` +
+ ${controller.invoker} ${controller.content} +
+ `); + 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` +

Content

+ `, + invokerTemplate: () => html` + + `, + placementConfig: { + placement: 'top', + }, + }); + await fixture(html` +
+ ${controller.invoker} ${controller.content} +
+ `); + + 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` +

Content

+ `, + invokerTemplate: () => html` + + `, + placementConfig: { + placement: 'top', + }, + }); + await fixture(html` +
+ ${controller.invoker} ${controller.content} +
+ `); + + 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', () => {
`, - invokerTemplate: () => - html` - - `, + invokerTemplate: () => html` + + `, 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(``); controller.show(); const el1 = controller.content.querySelector('button'); diff --git a/packages/overlays/test/utils-tests/get-position.test.js b/packages/overlays/test/utils-tests/get-position.test.js deleted file mode 100644 index 95fe1c11c..000000000 --- a/packages/overlays/test/utils-tests/get-position.test.js +++ /dev/null @@ -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'); - }); -}); diff --git a/packages/overlays/test/utils-tests/manage-position.test.js b/packages/overlays/test/utils-tests/manage-position.test.js deleted file mode 100644 index 3843f4c59..000000000 --- a/packages/overlays/test/utils-tests/manage-position.test.js +++ /dev/null @@ -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); - }); -}); diff --git a/yarn.lock b/yarn.lock index 90a684cca..718c03d59 100644 --- a/yarn.lock +++ b/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==