${this.name ? this.msgLit('my-hello-component:greeting', { name: this.name }) : ''}
+ `;
+}
+```
+
+Refer to demos to see a full example.
+
+## Usage for application developers
+
+As an application developer you get:
+- ability to inline localization data for any locales and namespaces to prevent async loading and improve rendering speed in critical cases;
+- smart defaults for data loading;
+- simple customization of paths where the data is loaded from for common use cases;
+- full control over how the data is loaded for very specific use cases;
+
+### Inlining of data
+
+If you want to optimize the page rendering and you can inline some of your localization data upfront then there is a simple way to do it:
+
+```javascript
+// my-inlined-data.js
+import { localize } from 'lion-localize/localize.js';
+localize.addData('en-GB', 'my-namespace', {/* data */});
+localize.addData('nl-NL', 'my-namespace', {/* data */});
+
+// my-app.js
+import './my-inlined-data.js'; // must be on top to be executed before any other code using the data
+```
+
+This code must come before any other code which might potentially render before the data is added.
+You can inline as much locales as you support or sniff request headers on the server side and inline only the needed one.
+
+### Customize loading
+
+By convention most components will keep their localization data in ES modules at `/translations/%locale%.js`.
+But as we have already covered in the documentation for component developers there is a way to change the loading for certain namespaces.
+
+The configuration is done via `setupNamespaceLoader()`.
+This is sort of a router for the data and is typically needed to fetch it from an API.
+
+```javascript
+// for one specific component
+localize.setupNamespaceLoader('my-hello-component', async (locale) => {
+ const response = await fetch(`http://api.example.com/?namespace=my-hello-component&locale=${locale}`);
+ return response.json();
+});
+
+// for all components which have a prefix in their names
+localize.setupNamespaceLoader(/my-.+/, async (locale, namespace) => {
+ const response = await fetch(`http://api.example.com/?namespace=${namespace}&locale=${locale}`);
+ return response.json();
+});
+```
+
+Typically in the application you have a prefix in all of your application specific components.
+Having a way to load their corresponding localization data in a unified way is handy in such cases.
+But you need to make sure this configuration happens before you run any other code using these namespaces.
diff --git a/packages/localize/docs/amount-html.md b/packages/localize/docs/amount-html.md
new file mode 100644
index 000000000..53e70f1e2
--- /dev/null
+++ b/packages/localize/docs/amount-html.md
@@ -0,0 +1,30 @@
+# Amount
+
+The amount formatter returns a number based on the locale by using [Intl NumberFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat) specification.
+
+## Features
+- **formatAmountHtml**: returns a formatted amount based on locale to be used in lit-html
+- **formatAmountHtmlString**: returns a formatted amount based on locale as a string
+
+## How to use
+
+### Installation
+```
+npm i --save @lion/localize;
+```
+
+### Example
+
+```js
+import { formatAmountHtml } from '@lion/localize';
+
+// inside your webcomponent
+render () {
+ return html`
+ The current cart values is ${formatAmountHtml(1999.9)}.
+ `;
+}
+
+// output (depending on locale)
+// The current cart values is EUR 1.999,
+ ${this.name ? this.msgLit('my-hello-component:greeting', { name: this.name }) : ''}
+
+ `;
+}
+```
+
+Usage of dynamic imports is recommended if you want to be able to create smart bundles later on for a certain locale.
+
+### Not using a webcomponent
+For example, if you simply want to make a reusable template, you can also use localization using the singleton instance of LocalizeManager called `localize`.
+
+```js
+import { html } from '@lion/core';
+import { localize } from '@lion/localize';
+
+export function myTemplate(someData) {
+ return html`
+
+ ${localize.msg('my-hello-component:feeling', { feeling: someData.feeling })}
+
+ `;
+}
+```
+This template is meant for importing in your webcomponent which uses this localize namespace.
+
+### Translation files
+
+Data is split into locales.
+Typically the locale is an ES module which is by convention put into the `/translations` directory of your project or package. There are base language files (`en.js`, `nl.js`) and dialect files which extend them (`en-GB.js`, `nl-NL.js`). In the dialect files, you can extend or overwrite translations in the base language files.
+
+Localization data modules for `my-hello-component` might look like these:
+
+
+- `/path/to/my-family-component/translations/en.js`
+
+ ```js
+ export default {
+ havePartnerQuestion: 'Do you have a partner?',
+ haveChildrenQuestion: 'Do you have children?',
+ };
+ ```
+
+- `/path/to/my-family-component/translations/en-GB.js`
+
+ ```js
+ import en from './en.js'
+ export default en;
+ ```
+
+- `/path/to/my-family-component/translations/en-US.js`
+
+ ```js
+ import en from './en.js'
+
+ export default {
+ ...en,
+ haveChildrenQuestion: 'Do you have kids?',
+ };
+ ```
+
+The module must have a `default` export as shown above to be handled properly.
+
+### Advanced
+
+If you want to fetch translation data from some API this is also possible.
+
+```js
+// fetch from an API
+localize.loadNamespace({
+ 'my-hello-component': async (locale) => {
+ const response = await fetch(`http://api.example.com/?namespace=my-hello-component&locale=${locale}`);
+ return response.json(); // resolves to the JSON object `{ greeting: 'Hallo {name}!' }`
+ },
+});
+```
+
+There is also a method which helps with setting up the loading of multiple namespaces.
+
+Using the loaders preconfigured via `setupNamespaceLoader()`:
+
+```js
+// using the regexp to match all component names staring with 'my-'
+localize.setupNamespaceLoader(/my-.+/, async (locale, namespace) => {
+ const response = await fetch(`http://api.example.com/?namespace=${namespace}&locale=${locale}`);
+ return response.json();
+});
+
+Promise.all([
+ localize.loadNamespace('my-hello-component');
+ localize.loadNamespace('my-goodbye-component');
+])
+```
+
+`localize.msg` uses [Intl MessageFormat implementation](https://www.npmjs.com/package/message-format) under the hood, so you can use all of its powerful features.
diff --git a/packages/localize/docs/number.md b/packages/localize/docs/number.md
new file mode 100644
index 000000000..b7d086482
--- /dev/null
+++ b/packages/localize/docs/number.md
@@ -0,0 +1,29 @@
+# Number
+
+The number formatter returns a number based on the locale by using [Intl NumberFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat) specification.
+
+## Features
+- **formatNumber**: returns a formatted number based on locale
+- **formatNumberToParts**: returns a formatted number in parts based on locale
+- **getFractionDigits**: returns the fraction digit for a certain currency
+- **getGroupSeparator**: returns the group separator based on locale
+- **getDecimalSeparator**: returns the decimal separator based on locale
+
+## How to use
+
+### Installation
+```
+npm i --save @lion/localize;
+```
+
+### Example
+
+```js
+import { formatNumber } from '@lion/localize';
+
+function numberExampleFunction () {
+ const number = 2000;
+ const options = { style: 'currency', currency: 'EUR', currencyDisplay: 'code' };
+ return formatNumber(number, options) // 'EUR 2,000.00' for British locale
+}
+```
diff --git a/packages/localize/index.js b/packages/localize/index.js
new file mode 100644
index 000000000..dbe9cc69f
--- /dev/null
+++ b/packages/localize/index.js
@@ -0,0 +1,11 @@
+export { formatDate, getDateFormatBasedOnLocale, parseDate } from './src/formatDate.js';
+export {
+ formatNumber,
+ formatNumberToParts,
+ getFractionDigits,
+ getDecimalSeparator,
+ getGroupSeparator,
+} from './src/formatNumber.js';
+export { localize } from './src/localize.js';
+export { LocalizeManager } from './src/LocalizeManager.js';
+export { LocalizeMixin } from './src/LocalizeMixin.js';
diff --git a/packages/localize/package.json b/packages/localize/package.json
new file mode 100644
index 000000000..db6c74673
--- /dev/null
+++ b/packages/localize/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@lion/localize",
+ "version": "0.0.0",
+ "description": "The localization system helps to manage localization data split into locales and automate its loading",
+ "author": "ing-bank",
+ "homepage": "https://github.com/ing-bank/lion/",
+ "license": "MIT",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/ing-bank/lion.git",
+ "directory": "packages/localize"
+ },
+ "scripts": {
+ "prepublishOnly": "../../scripts/insert-header.js"
+ },
+ "keywords": [
+ "lion",
+ "web-components",
+ "localize"
+ ],
+ "main": "index.js",
+ "module": "index.js",
+ "files": [
+ "stories",
+ "test",
+ "*.js"
+ ],
+ "dependencies": {
+ "@bundled-es-modules/message-format": "6.0.4",
+ "@lion/core": "0.0.0"
+ },
+ "devDependencies": {
+ "@bundled-es-modules/fetch-mock": "^6.5.2",
+ "@open-wc/testing": "^0.11.1",
+ "@open-wc/storybook": "^0.1.5",
+ "sinon": "^7.2.2"
+ }
+}
diff --git a/packages/localize/src/LocalizeManager.js b/packages/localize/src/LocalizeManager.js
new file mode 100644
index 000000000..00356f913
--- /dev/null
+++ b/packages/localize/src/LocalizeManager.js
@@ -0,0 +1,228 @@
+/* eslint-disable no-underscore-dangle, class-methods-use-this */
+
+import MessageFormat from '@bundled-es-modules/message-format/MessageFormat.js';
+import { LionSingleton } from '@lion/core';
+import isLocalizeESModule from './isLocalizeESModule.js';
+
+/**
+ * `LocalizeManager` manages your translations (includes loading)
+ */
+export class LocalizeManager extends LionSingleton {
+ // eslint-disable-line no-unused-vars
+ constructor(params = {}) {
+ super(params);
+ this._fakeExtendsEventTarget();
+
+ if (!this.locale) {
+ this.locale = 'en-GB';
+ }
+ this._autoLoadOnLocaleChange = !!params.autoLoadOnLocaleChange;
+ this.__storage = {};
+ this.__namespacePatternsMap = new Map();
+ this.__namespaceLoadersCache = {};
+ this.__namespaceLoaderPromisesCache = {};
+ this.formatNumberOptions = { returnIfNaN: '' };
+ }
+
+ get locale() {
+ // eslint-disable-line class-methods-use-this
+ return document.documentElement.lang;
+ }
+
+ set locale(value) {
+ const oldLocale = document.documentElement.lang;
+ document.documentElement.lang = value;
+ this._onLocaleChanged(value, oldLocale);
+ }
+
+ get loadingComplete() {
+ return Promise.all(Object.values(this.__namespaceLoaderPromisesCache[this.locale]));
+ }
+
+ reset() {
+ this.__storage = {};
+ this.__namespacePatternsMap = new Map();
+ this.__namespaceLoadersCache = {};
+ this.__namespaceLoaderPromisesCache = {};
+ }
+
+ addData(locale, namespace, data) {
+ if (this._isNamespaceInCache(locale, namespace)) {
+ throw new Error(
+ `Namespace "${namespace}" has been already added for the locale "${locale}".`,
+ );
+ }
+
+ this.__storage[locale] = this.__storage[locale] || {};
+ this.__storage[locale][namespace] = data;
+ }
+
+ setupNamespaceLoader(pattern, loader) {
+ this.__namespacePatternsMap.set(pattern, loader);
+ }
+
+ loadNamespaces(namespaces) {
+ return Promise.all(namespaces.map(namespace => this.loadNamespace(namespace)));
+ }
+
+ loadNamespace(namespaceObj) {
+ const isDynamicImport = typeof namespaceObj === 'object';
+
+ const namespace = isDynamicImport ? Object.keys(namespaceObj)[0] : namespaceObj;
+
+ if (this._isNamespaceInCache(this.locale, namespace)) {
+ return Promise.resolve();
+ }
+
+ const existingLoaderPromise = this._getCachedNamespaceLoaderPromise(this.locale, namespace);
+ if (existingLoaderPromise) {
+ return existingLoaderPromise;
+ }
+
+ return this._loadNamespaceData(this.locale, namespaceObj, isDynamicImport, namespace);
+ }
+
+ msg(keys, vars, opts = {}) {
+ const locale = opts.locale ? opts.locale : this.locale;
+ const message = this._getMessageForKeys(keys, locale);
+ if (!message) {
+ return '';
+ }
+ const formatter = new MessageFormat(message, locale);
+ return formatter.format(vars);
+ }
+
+ _isNamespaceInCache(locale, namespace) {
+ return !!(this.__storage[locale] && this.__storage[locale][namespace]);
+ }
+
+ _getCachedNamespaceLoaderPromise(locale, namespace) {
+ if (this.__namespaceLoaderPromisesCache[locale]) {
+ return this.__namespaceLoaderPromisesCache[locale][namespace];
+ }
+ return null;
+ }
+
+ _loadNamespaceData(locale, namespaceObj, isDynamicImport, namespace) {
+ const loader = this._getNamespaceLoader(namespaceObj, isDynamicImport, namespace);
+ const loaderPromise = this._getNamespaceLoaderPromise(loader, locale, namespace);
+ this._cacheNamespaceLoaderPromise(locale, namespace, loaderPromise);
+ return loaderPromise.then(obj => {
+ const data = isLocalizeESModule(obj) ? obj.default : obj;
+ this.addData(locale, namespace, data);
+ });
+ }
+
+ _getNamespaceLoader(namespaceObj, isDynamicImport, namespace) {
+ let loader = this.__namespaceLoadersCache[namespace];
+
+ if (!loader) {
+ if (isDynamicImport) {
+ loader = namespaceObj[namespace];
+ this.__namespaceLoadersCache[namespace] = loader;
+ } else {
+ loader = this._lookupNamespaceLoader(namespace);
+ this.__namespaceLoadersCache[namespace] = loader;
+ }
+ }
+
+ if (!loader) {
+ throw new Error(`Namespace "${namespace}" was not properly setup.`);
+ }
+
+ this.__namespaceLoadersCache[namespace] = loader;
+
+ return loader;
+ }
+
+ _getNamespaceLoaderPromise(loader, locale, namespace) {
+ return loader(locale, namespace).catch(() => {
+ const lang = this._getLangFromLocale(locale);
+ return loader(lang, namespace).catch(() => {
+ throw new Error(
+ `Data for namespace "${namespace}" and locale "${locale}" could not be loaded. ` +
+ `Make sure you have data for locale "${locale}" and/or generic language "${lang}".`,
+ );
+ });
+ });
+ }
+
+ _cacheNamespaceLoaderPromise(locale, namespace, promise) {
+ if (!this.__namespaceLoaderPromisesCache[locale]) {
+ this.__namespaceLoaderPromisesCache[locale] = {};
+ }
+ this.__namespaceLoaderPromisesCache[locale][namespace] = promise;
+ }
+
+ _lookupNamespaceLoader(namespace) {
+ /* eslint-disable no-restricted-syntax */
+ for (const [key, value] of this.__namespacePatternsMap) {
+ const isMatchingString = typeof key === 'string' && key === namespace;
+ const isMatchingRegexp =
+ typeof key === 'object' && key.constructor.name === 'RegExp' && key.test(namespace);
+ if (isMatchingString || isMatchingRegexp) {
+ return value;
+ }
+ }
+ return null;
+ /* eslint-enable no-restricted-syntax */
+ }
+
+ _getLangFromLocale(locale) {
+ return locale.substring(0, 2);
+ }
+
+ // TODO: this method has to be removed when EventTarget polyfill is available on IE11
+ // issue: https://gitlab.ing.net/TheGuideComponents/lion-element/issues/12
+ _fakeExtendsEventTarget() {
+ const delegate = document.createDocumentFragment();
+ ['addEventListener', 'dispatchEvent', 'removeEventListener'].forEach(funcName => {
+ this[funcName] = (...args) => delegate[funcName](...args);
+ });
+ }
+
+ _onLocaleChanged(newLocale, oldLocale) {
+ this.dispatchEvent(new CustomEvent('localeChanged', { detail: { newLocale, oldLocale } }));
+ if (this._autoLoadOnLocaleChange) {
+ this._loadAllMissing(newLocale, oldLocale);
+ }
+ }
+
+ _loadAllMissing(newLocale, oldLocale) {
+ const oldLocaleNamespaces = this.__storage[oldLocale] || {};
+ const newLocaleNamespaces = this.__storage[newLocale] || {};
+ const promises = [];
+ Object.keys(oldLocaleNamespaces).forEach(namespace => {
+ const newNamespaceData = newLocaleNamespaces[namespace];
+ if (!newNamespaceData) {
+ promises.push(this.loadNamespace(namespace));
+ }
+ });
+ return Promise.all(promises);
+ }
+
+ _getMessageForKeys(keys, locale) {
+ if (typeof keys === 'string') {
+ return this._getMessageForKey(keys, locale);
+ }
+ const reversedKeys = Array.from(keys).reverse(); // Array.from prevents mutation of argument
+ let key;
+ let message;
+ while (reversedKeys.length) {
+ key = reversedKeys.pop();
+ message = this._getMessageForKey(key, locale);
+ if (message) {
+ return message;
+ }
+ }
+ return undefined;
+ }
+
+ _getMessageForKey(key, locale) {
+ const [ns, namesString] = key.split(':');
+ const namespaces = this.__storage[locale];
+ const messages = namespaces ? namespaces[ns] : null;
+ const names = namesString.split('.');
+ return names.reduce((message, n) => (message ? message[n] : null), messages);
+ }
+}
diff --git a/packages/localize/src/LocalizeMixin.js b/packages/localize/src/LocalizeMixin.js
new file mode 100644
index 000000000..22f954b34
--- /dev/null
+++ b/packages/localize/src/LocalizeMixin.js
@@ -0,0 +1,111 @@
+/* eslint-disable no-underscore-dangle */
+
+import { dedupeMixin, until, nothing } from '@lion/core';
+import { localize } from './localize.js';
+
+/**
+ * # LocalizeMixin - for self managed templates
+ *
+ * @polymerMixin
+ * @mixinFunction
+ */
+export const LocalizeMixin = dedupeMixin(
+ superclass =>
+ // eslint-disable-next-line
+ class LocalizeMixin extends superclass {
+ static get localizeNamespaces() {
+ return [];
+ }
+
+ static get waitForLocalizeNamespaces() {
+ return true;
+ }
+
+ constructor() {
+ super();
+
+ this.__boundLocalizeOnLocaleChanged = (...args) => this.__localizeOnLocaleChanged(...args);
+
+ // should be loaded in advance
+ this.__localizeStartLoadingNamespaces();
+ this.localizeNamespacesLoaded.then(() => {
+ this.__localizeMessageSync = true;
+ });
+ }
+
+ /**
+ * hook into LitElement to only render once all translations are loaded
+ */
+ async performUpdate() {
+ if (this.constructor.waitForLocalizeNamespaces) {
+ await this.localizeNamespacesLoaded;
+ }
+ super.performUpdate();
+ }
+
+ connectedCallback() {
+ if (super.connectedCallback) {
+ super.connectedCallback();
+ }
+
+ this.localizeNamespacesLoaded.then(() => this.onLocaleReady());
+ this.__localizeAddLocaleChangedListener();
+ }
+
+ disconnectedCallback() {
+ if (super.disconnectedCallback) {
+ super.disconnectedCallback();
+ }
+
+ this.__localizeRemoveLocaleChangedListener();
+ }
+
+ msgLit(...args) {
+ if (this.__localizeMessageSync) {
+ return localize.msg(...args);
+ }
+ return until(this.localizeNamespacesLoaded.then(() => localize.msg(...args)), nothing);
+ }
+
+ __getUniqueNamespaces() {
+ const uniqueNamespaces = [];
+
+ // IE11 does not support iterable in the constructor
+ const s = new Set();
+ this.constructor.localizeNamespaces.forEach(s.add.bind(s));
+ s.forEach(uniqueNamespace => {
+ uniqueNamespaces.push(uniqueNamespace);
+ });
+ return uniqueNamespaces;
+ }
+
+ __localizeStartLoadingNamespaces() {
+ this.localizeNamespacesLoaded = localize.loadNamespaces(this.__getUniqueNamespaces());
+ }
+
+ __localizeAddLocaleChangedListener() {
+ localize.addEventListener('localeChanged', this.__boundLocalizeOnLocaleChanged);
+ }
+
+ __localizeRemoveLocaleChangedListener() {
+ localize.removeEventListener('localeChanged', this.__boundLocalizeOnLocaleChanged);
+ }
+
+ __localizeOnLocaleChanged(event) {
+ this.onLocaleChanged(event.detail.newLocale, event.detail.oldLocale);
+ }
+
+ onLocaleReady() {
+ this.onLocaleUpdated();
+ }
+
+ onLocaleChanged() {
+ this.localizeNamespacesLoaded = localize.loadNamespaces(this.__getUniqueNamespaces());
+ this.onLocaleUpdated();
+ this.requestUpdate();
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ onLocaleUpdated() {}
+ },
+);
diff --git a/packages/localize/src/formatDate.js b/packages/localize/src/formatDate.js
new file mode 100644
index 000000000..ab5352b86
--- /dev/null
+++ b/packages/localize/src/formatDate.js
@@ -0,0 +1,236 @@
+import { localize } from './localize.js';
+
+/**
+ * Gets the locale to use
+ *
+ * @param {string} locale Locale to override browser locale
+ * @returns {string}
+ */
+function getLocale(locale) {
+ if (locale) {
+ return locale;
+ }
+ if (localize && localize.locale) {
+ return localize.locale;
+ }
+ return 'en-GB';
+}
+
+/**
+ * To filter out some added characters in IE
+ *
+ * @param str
+ * @returns {string}
+ */
+function normalizeDate(str) {
+ const dateString = [];
+ for (let i = 0, n = str.length; i < n; i += 1) {
+ // remove unicode 160
+ if (str.charCodeAt(i) === 160) {
+ dateString.push(' ');
+ // remove unicode 8206
+ } else if (str.charCodeAt(i) === 8206) {
+ dateString.push('');
+ } else {
+ dateString.push(str.charAt(i));
+ }
+ }
+
+ return dateString.join('');
+}
+
+/**
+ * Formats date based on locale and options
+ *
+ * @param date
+ * @param options
+ * @returns {*}
+ */
+export function formatDate(date, options) {
+ if (!(date instanceof Date)) {
+ return '0000-00-00';
+ }
+ const formatOptions = options || {};
+ // make sure months and days are always 2-digits
+ if (!options) {
+ formatOptions.year = 'numeric';
+ formatOptions.month = '2-digit';
+ formatOptions.day = '2-digit';
+ }
+ if (options && !(options && options.year)) {
+ formatOptions.year = 'numeric';
+ }
+ if (options && !(options && options.month)) {
+ formatOptions.month = '2-digit';
+ }
+ if (options && !(options && options.day)) {
+ formatOptions.day = '2-digit';
+ }
+
+ const computedLocale = getLocale(formatOptions && formatOptions.locale);
+ let formattedDate = '';
+ try {
+ formattedDate = new Intl.DateTimeFormat(computedLocale, formatOptions).format(date);
+ } catch (e) {
+ formattedDate = '';
+ }
+ return normalizeDate(formattedDate);
+}
+/**
+ * To trim the date
+ *
+ * @param dateAsString
+ * @returns {string|XML}
+ */
+function trim(dateAsString) {
+ return dateAsString.replace(/^[^\d]*/g, '').replace(/[^\d]*$/g, '');
+}
+
+/**
+ * To clean date from added characters from IE
+ *
+ * @param dateAsString
+ * @returns {string|XML}
+ */
+function clean(dateAsString) {
+ // list of separators is from wikipedia https://www.wikiwand.com/en/Date_format_by_country
+ // slash, point, dash or space
+ return trim(dateAsString.replace(/[^\d-. /]/g, ''));
+}
+
+/**
+ * To get the absolute value of a number.
+ *
+ * @param n
+ * @returns {string}
+ */
+function pad(n) {
+ const v = Math.abs(n);
+
+ return String(v < 10 ? `0${v}` : v);
+}
+
+/**
+ * To sanitize a date from IE11 handling
+ *
+ * @param date
+ * @returns {string|XML}
+ */
+function sanitizedDateTimeFormat(date) {
+ const fDate = formatDate(date);
+ return clean(fDate);
+}
+
+/**
+ * To split a date into days, months, years, etc
+ *
+ * @param date
+ * @returns {Array|{index: number, input: string}|*}
+ */
+function splitDate(date) {
+ return date.match(/(\d{1,4})([^\d]+)(\d{1,4})([^\d]+)(\d{1,4})/);
+}
+
+/**
+ * To add a leading zero to a single number
+ *
+ * @param dateString
+ * @returns {*}
+ */
+function addLeadingZero(dateString) {
+ const dateParts = splitDate(dateString);
+ const delimiter = dateParts ? dateParts[2] : '';
+ const uniformDateString = dateString.replace(/[.\-/\s]/g, delimiter);
+ const dateArray = uniformDateString.split && uniformDateString.split(delimiter);
+ if (!dateArray || dateArray.length !== 3) {
+ // prevent fail on invalid dates
+ return '';
+ }
+ return dateArray.map(pad).join('-');
+}
+
+/**
+ * To compute the localized date format
+ *
+ * @returns {string}
+ */
+export function getDateFormatBasedOnLocale() {
+ function computePositions(dateParts) {
+ function getPartByIndex(index) {
+ return { 2012: 'year', 12: 'month', 20: 'day' }[dateParts[index]];
+ }
+
+ return [1, 3, 5].map(getPartByIndex);
+ }
+
+ // Arbitrary date with different values for year,month,day
+ const date = new Date();
+ date.setDate(20);
+ date.setMonth(11);
+ date.setFullYear(2012);
+
+ // Strange characters added by IE11 need to be taken into account here
+ const formattedDate = sanitizedDateTimeFormat(date);
+
+ // For Dutch locale, dateParts would match: [ 1:'20', 2:'-', 3:'12', 4:'-', 5:'2012' ]
+ const dateParts = splitDate(formattedDate);
+
+ const dateFormat = {};
+ dateFormat.positions = computePositions(dateParts);
+ return `${dateFormat.positions[0]}-${dateFormat.positions[1]}-${dateFormat.positions[2]}`;
+}
+
+const memoize = (fn, parm) => {
+ const cache = {};
+ return () => {
+ const n = parm;
+ if (n in cache) {
+ return cache[n];
+ }
+ const result = fn(n);
+ cache[n] = result;
+ return result;
+ };
+};
+
+const memoizedGetDateFormatBasedOnLocale = memoize(getDateFormatBasedOnLocale, localize.locale);
+
+/**
+ * To parse a date into the right format
+ *
+ * @param date
+ * @returns {Date}
+ */
+export function parseDate(date) {
+ const stringToParse = addLeadingZero(date);
+ let parsedString;
+ switch (memoizedGetDateFormatBasedOnLocale()) {
+ case 'day-month-year':
+ parsedString = `${stringToParse.slice(6, 10)}-${stringToParse.slice(
+ 3,
+ 5,
+ )}-${stringToParse.slice(0, 2)}T00:00:00Z`;
+ break;
+ case 'month-day-year':
+ parsedString = `${stringToParse.slice(6, 10)}-${stringToParse.slice(
+ 0,
+ 2,
+ )}-${stringToParse.slice(3, 5)}T00:00:00Z`;
+ break;
+ case 'year-month-day':
+ parsedString = `${stringToParse.slice(0, 4)}-${stringToParse.slice(
+ 5,
+ 7,
+ )}-${stringToParse.slice(8, 10)}T00:00:00Z`;
+ break;
+ default:
+ parsedString = '0000-00-00T00:00:00Z';
+ }
+ const parsedDate = new Date(parsedString);
+ // Check if parsedDate is not `Invalid Date`
+ // eslint-disable-next-line no-restricted-globals
+ if (!isNaN(parsedDate)) {
+ return parsedDate;
+ }
+ return undefined;
+}
diff --git a/packages/localize/src/formatNumber.js b/packages/localize/src/formatNumber.js
new file mode 100644
index 000000000..fdc34dcf8
--- /dev/null
+++ b/packages/localize/src/formatNumber.js
@@ -0,0 +1,403 @@
+import { localize } from './localize.js';
+
+/**
+ * Gets the locale to use
+ *
+ * @param {string} locale Locale to override browser locale
+ * @returns {string}
+ */
+function getLocale(locale) {
+ if (locale) {
+ return locale;
+ }
+ if (localize && localize.locale) {
+ return localize.locale;
+ }
+ return 'en-GB';
+}
+
+/**
+ * Round the number based on the options
+ *
+ * @param {number} number
+ * @param {string} roundMode
+ * @returns {*}
+ */
+function roundNumber(number, roundMode) {
+ switch (roundMode) {
+ case 'floor':
+ return Math.floor(number);
+ case 'ceiling':
+ return Math.ceil(number);
+ case 'round':
+ return Math.round(number);
+ default:
+ throw new Error('roundMode can only be round|floor|ceiling');
+ }
+}
+/**
+ * @param {Array} value
+ * @return {Array} value with forced "normal" space
+ */
+export function normalSpaces(value) {
+ // If non-breaking space (160) or narrow non-breaking space (8239) then return ' '
+ return value.charCodeAt(0) === 160 || value.charCodeAt(0) === 8239 ? ' ' : value;
+}
+
+/**
+ * To get the group separator
+ *
+ * @param {string} locale To override the browser locale
+ * @returns {Object} the separator
+ */
+export function getGroupSeparator(locale) {
+ const computedLocale = getLocale(locale);
+ const formattedNumber = Intl.NumberFormat(computedLocale, {
+ style: 'decimal',
+ minimumFractionDigits: 0,
+ }).format('1000');
+ return normalSpaces(formattedNumber[1]);
+}
+
+/**
+ * To get the decimal separator
+ *
+ * @param {string} locale To override the browser locale
+ * @returns {Object} the separator
+ */
+export function getDecimalSeparator(locale) {
+ const computedLocale = getLocale(locale);
+ const formattedNumber = Intl.NumberFormat(computedLocale, {
+ style: 'decimal',
+ minimumFractionDigits: 1,
+ }).format('1');
+ return formattedNumber[1];
+}
+
+/**
+ * When number is NaN we should return an empty string or returnIfNaN param
+ *
+ * @param {string} returnIfNaN
+ * @returns {*}
+ */
+function emptyStringWhenNumberNan(returnIfNaN) {
+ const stringToReturn = returnIfNaN || localize.formatNumberOptions.returnIfNaN;
+ return stringToReturn;
+}
+
+/**
+ * For Dutch and Belgian amounts the currency should be at the end of the string
+ *
+ * @param {Array} formattedParts
+ * @returns {Array}
+ */
+function forceCurrencyToEnd(formattedParts) {
+ if (formattedParts[0].type === 'currency') {
+ const moveCur = formattedParts.splice(0, 1);
+ const moveLit = formattedParts.splice(0, 1);
+ formattedParts.push(moveLit[0]);
+ formattedParts.push(moveCur[0]);
+ } else if (formattedParts[0].type === 'minusSign' && formattedParts[1].type === 'currency') {
+ const moveCur = formattedParts.splice(1, 1);
+ const moveLit = formattedParts.splice(1, 1);
+ formattedParts.push(moveLit[0]);
+ formattedParts.push(moveCur[0]);
+ }
+ return formattedParts;
+}
+
+/**
+ * When in some locales there is no space between currency and amount it is added
+ *
+ * @param {Array} formattedParts
+ * @param {Object} options
+ * @returns {*}
+ */
+function forceSpaceBetweenCurrencyCodeAndNumber(formattedParts, options) {
+ const numberOfParts = formattedParts.length;
+ const literalObject = { type: 'literal', value: ' ' };
+ if (numberOfParts > 1 && options && options.currency && options.currencyDisplay === 'code') {
+ if (formattedParts[0].type === 'currency' && formattedParts[1].type !== 'literal') {
+ // currency in front of a number: EUR 1.00
+ formattedParts.splice(1, 0, literalObject);
+ } else if (
+ formattedParts[0].type === 'minusSign' &&
+ formattedParts[1].type === 'currency' &&
+ formattedParts[2].type !== 'literal'
+ ) {
+ // currency in front of a negative number: -EUR 1.00
+ formattedParts.splice(2, 0, literalObject);
+ } else if (
+ formattedParts[numberOfParts - 1].type === 'currency' &&
+ formattedParts[numberOfParts - 2].type !== 'literal'
+ ) {
+ // currency in behind a number: 1.00 EUR || -1.00 EUR
+ formattedParts.splice(numberOfParts - 1, 0, literalObject);
+ }
+ }
+ return formattedParts;
+}
+
+/**
+ * Add separators when they are not present
+ *
+ * @param {Array} formattedParts
+ * @param {string} groupSeparator
+ * @returns {Array}
+ */
+function forceAddGroupSeparators(formattedParts, groupSeparator) {
+ let concatArray = [];
+ if (formattedParts[0].type === 'integer') {
+ const getInteger = formattedParts.splice(0, 1);
+ const numberOfDigits = getInteger[0].value.length;
+ const mod3 = numberOfDigits % 3;
+ const groups = Math.floor(numberOfDigits / 3);
+ const numberArray = [];
+ let numberOfGroups = 0;
+ let numberPart = '';
+ let firstGroup = false;
+ // Loop through the integer
+ for (let i = 0; i < numberOfDigits; i += 1) {
+ numberPart += getInteger[0].value[i];
+ // Create first grouping which is < 3
+ if (numberPart.length === mod3 && firstGroup === false) {
+ numberArray.push({ type: 'integer', value: numberPart });
+ if (numberOfDigits > 3) {
+ numberArray.push({ type: 'group', value: groupSeparator });
+ }
+ numberPart = '';
+ firstGroup = true;
+ // Create groupings of 3
+ } else if (numberPart.length === 3 && i < numberOfDigits - 1) {
+ numberOfGroups += 1;
+ numberArray.push({ type: 'integer', value: numberPart });
+ if (numberOfGroups !== groups) {
+ numberArray.push({ type: 'group', value: groupSeparator });
+ }
+ numberPart = '';
+ }
+ }
+ numberArray.push({ type: 'integer', value: numberPart });
+ concatArray = numberArray.concat(formattedParts);
+ }
+ return concatArray;
+}
+
+/**
+ * @param {Array} formattedParts
+ * @return {Array} parts with forced "normal" spaces
+ */
+function forceNormalSpaces(formattedParts) {
+ const result = [];
+ formattedParts.forEach(part => {
+ result.push({
+ type: part.type,
+ value: normalSpaces(part.value),
+ });
+ });
+ return result;
+}
+
+function forceYenSymbol(formattedParts, options) {
+ const result = formattedParts;
+ const numberOfParts = result.length;
+ // Change the symbol from JPY to ¥, due to bug in Chrome
+ if (
+ numberOfParts > 1 &&
+ options &&
+ options.currency === 'JPY' &&
+ options.currencyDisplay === 'symbol'
+ ) {
+ result[numberOfParts - 1].value = '¥';
+ }
+ return result;
+}
+
+/**
+ * Function with all fixes on localize
+ *
+ * @param {Array} formattedParts
+ * @param {Object} options
+ * @param {string} _locale
+ * @returns {*}
+ */
+function normalizeIntl(formattedParts, options, _locale) {
+ let normalize = forceNormalSpaces(formattedParts, options);
+ // Dutch and Belgian currency must be moved to end of number
+ if (options && options.style === 'currency') {
+ if (_locale === 'nl-NL' || _locale.slice(-2) === 'BE') {
+ normalize = forceCurrencyToEnd(normalize);
+ }
+ // Add group separator for Bulgarian locale
+ if (_locale === 'bg-BG') {
+ normalize = forceAddGroupSeparators(normalize, getGroupSeparator());
+ }
+ // Force space between currency code and number
+ if (_locale === 'en-GB' || _locale === 'en-US' || _locale === 'en-AU') {
+ normalize = forceSpaceBetweenCurrencyCodeAndNumber(normalize, options);
+ }
+ // Force missing Japanese Yen symbol
+ if (_locale === 'fr-FR' || _locale === 'fr-BE') {
+ normalize = forceYenSymbol(normalize, options);
+ }
+ }
+ return normalize;
+}
+
+/**
+ * Splits a number up in parts for integer, fraction, group, literal, decimal and currency.
+ *
+ * @param {number} number Number to split up
+ * @param {Object} options Intl options are available extended by roundMode
+ * @returns {Array} Array with parts
+ */
+export function formatNumberToParts(number, options) {
+ let parsedNumber = typeof number === 'string' ? parseFloat(number) : number;
+ const computedLocale = getLocale(options && options.locale);
+ // when parsedNumber is not a number we should return an empty string or returnIfNaN
+ if (Number.isNaN(parsedNumber)) {
+ return emptyStringWhenNumberNan(options && options.returnIfNaN);
+ }
+ // If roundMode is given the number is rounded based upon the mode
+ if (options && options.roundMode) {
+ parsedNumber = roundNumber(number, options.roundMode);
+ }
+ let formattedParts = [];
+ const formattedNumber = Intl.NumberFormat(computedLocale, options).format(parsedNumber);
+ const regexSymbol = /[A-Z.,\s0-9]/;
+ const regexCode = /[A-Z]/;
+ const regexMinusSign = /[-]/;
+ const regexNum = /[0-9]/;
+ const regexSeparator = /[.,]/;
+ const regexSpace = /[\s]/;
+ let currencyCode = '';
+ let numberPart = '';
+ let fraction = false;
+ for (let i = 0; i < formattedNumber.length; i += 1) {
+ // detect minusSign
+ if (regexMinusSign.test(formattedNumber[i])) {
+ formattedParts.push({ type: 'minusSign', value: formattedNumber[i] });
+ }
+ // detect numbers
+ if (regexNum.test(formattedNumber[i])) {
+ numberPart += formattedNumber[i];
+ }
+ // detect currency symbol
+ if (!regexSymbol.test(formattedNumber[i]) && !regexMinusSign.test(formattedNumber[i])) {
+ // Write number grouping
+ if (numberPart && !fraction) {
+ formattedParts.push({ type: 'integer', value: numberPart });
+ numberPart = '';
+ } else if (numberPart) {
+ formattedParts.push({ type: 'fraction', value: numberPart });
+ numberPart = '';
+ }
+ formattedParts.push({ type: 'currency', value: formattedNumber[i] });
+ }
+ // detect currency code
+ if (regexCode.test(formattedNumber[i])) {
+ currencyCode += formattedNumber[i];
+ // Write number grouping
+ if (numberPart && !fraction) {
+ formattedParts.push({ type: 'integer', value: numberPart });
+ numberPart = '';
+ } else if (numberPart) {
+ formattedParts.push({ type: 'fraction', value: numberPart });
+ numberPart = '';
+ }
+ if (currencyCode.length === 3) {
+ formattedParts.push({ type: 'currency', value: currencyCode });
+ currencyCode = '';
+ }
+ }
+ // detect dot and comma separators
+ if (regexSeparator.test(formattedNumber[i])) {
+ // Write number grouping
+ if (numberPart) {
+ formattedParts.push({ type: 'integer', value: numberPart });
+ numberPart = '';
+ }
+ const decimal = getDecimalSeparator();
+ if (formattedNumber[i] === decimal) {
+ formattedParts.push({ type: 'decimal', value: formattedNumber[i] });
+ fraction = true;
+ } else {
+ formattedParts.push({ type: 'group', value: formattedNumber[i] });
+ }
+ }
+ // detect literals (empty spaces) or space group separator
+ if (regexSpace.test(formattedNumber[i])) {
+ const group = getGroupSeparator();
+ const hasNumberPart = !!numberPart;
+ // Write number grouping
+ if (numberPart && !fraction) {
+ formattedParts.push({ type: 'integer', value: numberPart });
+ numberPart = '';
+ } else if (numberPart) {
+ formattedParts.push({ type: 'fraction', value: numberPart });
+ numberPart = '';
+ }
+ // If space equals the group separator it gets type group
+ if (normalSpaces(formattedNumber[i]) === group && hasNumberPart && !fraction) {
+ formattedParts.push({ type: 'group', value: formattedNumber[i] });
+ } else {
+ formattedParts.push({ type: 'literal', value: formattedNumber[i] });
+ }
+ }
+ // Numbers after the decimal sign are fractions, write the last
+ // fractions at the end of the number
+ if (fraction === true && i === formattedNumber.length - 1) {
+ // write last number part
+ if (numberPart) {
+ formattedParts.push({ type: 'fraction', value: numberPart });
+ }
+ // If there are no fractions but we reached the end write the numberpart as integer
+ } else if (i === formattedNumber.length - 1 && numberPart) {
+ formattedParts.push({ type: 'integer', value: numberPart });
+ }
+ }
+ formattedParts = normalizeIntl(formattedParts, options, computedLocale);
+ return formattedParts;
+}
+
+/**
+ * @example
+ * getFractionDigits('JOD'); // return 3
+ *
+ * @param {string} currency Currency code e.g. EUR
+ * @return {number} fraction for the given currency
+ */
+export function getFractionDigits(currency = 'EUR') {
+ const parts = formatNumberToParts(123, {
+ style: 'currency',
+ currency,
+ });
+ const [fractionPart] = parts.filter(part => part.type === 'fraction');
+ return fractionPart ? fractionPart.value.length : 0;
+}
+
+/**
+ * Formats a number based on locale and options. It uses Intl for the formatting.
+ *
+ * @param {number} number Number to be formatted
+ * @param {Object} options Intl options are available extended by roundMode
+ * @returns {*} Formatted number
+ */
+export function formatNumber(number, options) {
+ if (number === undefined || number === null) return '';
+ const formattedToParts = formatNumberToParts(number, options);
+ // If number is not a number
+ if (
+ formattedToParts === (options && options.returnIfNaN) ||
+ formattedToParts === localize.formatNumberOptions.returnIfNaN
+ ) {
+ return formattedToParts;
+ }
+ let printNumberOfParts = '';
+ // update numberOfParts because there may be some parts added
+ const numberOfParts = formattedToParts && formattedToParts.length;
+ for (let i = 0; i < numberOfParts; i += 1) {
+ printNumberOfParts += formattedToParts[i].value;
+ }
+ return printNumberOfParts;
+}
diff --git a/packages/localize/src/isLocalizeESModule.js b/packages/localize/src/isLocalizeESModule.js
new file mode 100644
index 000000000..3284dfb44
--- /dev/null
+++ b/packages/localize/src/isLocalizeESModule.js
@@ -0,0 +1,5 @@
+/* eslint-disable no-underscore-dangle */
+
+export default function isLocalizeESModule(obj) {
+ return !!(obj && obj.default && typeof obj.default === 'object' && Object.keys(obj).length === 1);
+}
diff --git a/packages/localize/src/localize.js b/packages/localize/src/localize.js
new file mode 100644
index 000000000..fc0d248db
--- /dev/null
+++ b/packages/localize/src/localize.js
@@ -0,0 +1,10 @@
+import { LocalizeManager } from './LocalizeManager.js';
+
+// eslint-disable-next-line import/no-mutable-exports
+export let localize = LocalizeManager.getInstance({
+ autoLoadOnLocaleChange: true,
+});
+
+export function setLocalize(newLocalize) {
+ localize = newLocalize;
+}
diff --git a/packages/localize/stories/index.stories.js b/packages/localize/stories/index.stories.js
new file mode 100644
index 000000000..a35167f13
--- /dev/null
+++ b/packages/localize/stories/index.stories.js
@@ -0,0 +1,200 @@
+/* eslint-disable no-underscore-dangle, class-methods-use-this */
+import { storiesOf, html } from '@open-wc/storybook';
+
+import { html as litHtml } from '@lion/core';
+import { LionLitElement } from '@lion/core/src/LionLitElement.js';
+import { localize } from '../src/localize.js';
+import { LocalizeMixin } from '../src/LocalizeMixin.js';
+import {
+ formatNumber,
+ formatNumberToParts,
+ getGroupSeparator,
+ getDecimalSeparator,
+} from '../src/formatNumber.js';
+import { formatDate, parseDate, getDateFormatBasedOnLocale } from '../src/formatDate.js';
+
+storiesOf('Localize System|localize', module).add('lit component', () => {
+ class LitHtmlExample extends LocalizeMixin(LionLitElement) {
+ static get localizeNamespaces() {
+ return [
+ { 'lit-html-example': locale => import(`./translations/${locale}.js`) },
+ ...super.localizeNamespaces,
+ ];
+ }
+
+ static get properties() {
+ return {
+ now: {
+ type: 'Date',
+ },
+ };
+ }
+
+ render() {
+ // this is as simple as localization can be in JavaScript
+ // the Promise is used to delay inserting of the content until data is loaded
+ // for the first time as soon as `now` is provided or changed, it will rerender
+ // with a new value if locale is changed, there is a preconfigured listener
+ // to rerender when new data is loaded all thanks to lit-html capabilities
+ const headerDate = this.msgLit('lit-html-example:headerDate');
+ const headerNumber = this.msgLit('lit-html-example:headerNumber');
+ const date = this.now ? this.msgLit('lit-html-example:date', { now: this.now }) : '';
+ const time = this.now ? this.msgLit('lit-html-example:time', { now: this.now }) : '';
+
+ // dateFormat
+ const options1 = {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ };
+ options1.timeZone = 'UTC';
+ options1.timeZoneName = 'short';
+ const dateParse1 = parseDate('01-05-2012');
+ const dateParse2 = parseDate('12/05/2012');
+ const dateParse3 = parseDate('1-5-2017');
+ const dateFormat1 = formatDate(dateParse1, options1);
+ const dateFormat2 = formatDate(dateParse2, options1);
+ const dateFormat3 = formatDate(dateParse3, options1);
+ const dateFormat = getDateFormatBasedOnLocale();
+ const datum = new Date();
+ const options2 = {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ };
+ options2.timeZone = 'UTC';
+ options2.timeZoneName = 'short';
+ options2.locale = 'ja-JP-u-ca-japanese';
+ const dateFormatted = formatDate(datum, options2);
+
+ // numberFormat
+ const number1 = formatNumber(123456.789, {
+ style: 'currency',
+ currency: 'EUR',
+ currencyDisplay: 'code',
+ });
+ const formatNumberToParts1 = formatNumberToParts(123456.789, {
+ style: 'currency',
+ currency: 'EUR',
+ currencyDisplay: 'code',
+ });
+ let printParts1 = '';
+ for (let i = 0; i < formatNumberToParts1.length; i += 1) {
+ printParts1 += `{ ${formatNumberToParts1[i].type}: ${formatNumberToParts1[i].value} }`;
+ }
+
+ const number2 = formatNumber(1234.5, { style: 'decimal' });
+ const formatNumberToParts2 = formatNumberToParts(1234.5, { style: 'decimal' });
+ let printParts2 = '';
+ for (let i = 0; i < formatNumberToParts2.length; i += 1) {
+ printParts2 += `{ ${formatNumberToParts2[i].type}: ${formatNumberToParts2[i].value} }`;
+ }
+
+ const number3 = formatNumber(-1234.5, {
+ style: 'currency',
+ currency: 'EUR',
+ currencyDisplay: 'code',
+ });
+ const formatNumberToParts3 = formatNumberToParts(-1234.5, {
+ style: 'currency',
+ currency: 'EUR',
+ currencyDisplay: 'code',
+ });
+ let printParts3 = '';
+ for (let i = 0; i < formatNumberToParts3.length; i += 1) {
+ printParts3 += `{ ${formatNumberToParts3[i].type}: ${formatNumberToParts3[i].value} }`;
+ }
+ const printGroupSeparator = getGroupSeparator();
+ const printDecimalSeparator = getDecimalSeparator();
+ return litHtml`
+ ` has `tabIndex = -1`
+ const ati = Math.max(a.tabIndex, 0);
+ const bti = Math.max(b.tabIndex, 0);
+ return ati === 0 || bti === 0 ? bti > ati : ati > bti;
+}
+
+/**
+ * @param {HTMLElement[]} left
+ * @param {HTMLElement[]} right
+ * @returns {HTMLElement[]}
+ */
+function mergeSortByTabIndex(left, right) {
+ /** @type {HTMLElement[]} */
+ const result = [];
+ while (left.length > 0 && right.length > 0) {
+ if (hasLowerTabOrder(left[0], right[0])) {
+ // @ts-ignore
+ result.push(right.shift());
+ } else {
+ // @ts-ignore
+ result.push(left.shift());
+ }
+ }
+
+ return [...result, ...left, ...right];
+}
+
+/**
+ * @param {HTMLElement[]} elements
+ * @returns {HTMLElement[]}
+ */
+export function sortByTabIndex(elements) {
+ // Implement a merge sort as Array.prototype.sort does a non-stable sort
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort
+ const len = elements.length;
+ if (len < 2) {
+ return elements;
+ }
+
+ const pivot = Math.ceil(len / 2);
+ const left = sortByTabIndex(elements.slice(0, pivot));
+ const right = sortByTabIndex(elements.slice(pivot));
+ return mergeSortByTabIndex(left, right);
+}
diff --git a/packages/overlays/stories/global-overlay.stories.js b/packages/overlays/stories/global-overlay.stories.js
new file mode 100644
index 000000000..4c73a7542
--- /dev/null
+++ b/packages/overlays/stories/global-overlay.stories.js
@@ -0,0 +1,440 @@
+/* eslint-disable no-underscore-dangle, class-methods-use-this */
+import { storiesOf, html } from '@open-wc/storybook';
+
+import { css } from '@lion/core';
+import { LionLitElement } from '@lion/core/src/LionLitElement.js';
+import { overlays } from '../src/overlays.js';
+import { GlobalOverlayController } from '../src/GlobalOverlayController';
+
+const globalOverlayDemoStyle = css`
+ .demo-overlay {
+ background-color: white;
+ position: fixed;
+ top: 20px;
+ left: 20px;
+ width: 200px;
+ border: 1px solid blue;
+ }
+
+ .demo-overlay--2 {
+ left: 240px;
+ }
+
+ .demo-overlay--toast {
+ left: initial;
+ right: 20px;
+ }
+`;
+
+storiesOf('Overlay System|Global/Global Overlay', module)
+ .add('Default', () => {
+ const overlayCtrl = overlays.add(
+ new GlobalOverlayController({
+ contentTemplate: () => html`
+
+
Simple overlay
+
+
+ `,
+ }),
+ );
+
+ return html`
+
+
Anchor 1
+
+
Anchor 2
+ ${Array(50).fill(
+ html`
+
Lorem ipsum
+ `,
+ )}
+ `;
+ })
+ .add('Option "preventsScroll"', () => {
+ const overlayCtrl = overlays.add(
+ new GlobalOverlayController({
+ preventsScroll: true,
+ contentTemplate: () => html`
+
+
Scrolling the body is blocked
+
+
+ `,
+ }),
+ );
+
+ return html`
+
+
+ ${Array(50).fill(
+ html`
+
Lorem ipsum
+ `,
+ )}
+ `;
+ })
+ .add('Option "hasBackdrop"', () => {
+ const overlayCtrl = overlays.add(
+ new GlobalOverlayController({
+ hasBackdrop: true,
+ contentTemplate: () => html`
+
+
There is a backdrop
+
+
+ `,
+ }),
+ );
+
+ return html`
+
+
+ `;
+ })
+ .add('Option "trapsKeyboardFocus"', () => {
+ const overlayCtrl = overlays.add(
+ new GlobalOverlayController({
+ trapsKeyboardFocus: true,
+ contentTemplate: () => html`
+
+
Tab key is trapped within the overlay
+
+
+
Anchor
+
Tabindex
+
+
Contenteditable
+
+
+
+
+ `,
+ }),
+ );
+
+ return html`
+
+
Anchor 1
+
+
Anchor 2
+ `;
+ })
+ .add('Option "trapsKeyboardFocus" (multiple)', () => {
+ const overlayCtrl2 = overlays.add(
+ new GlobalOverlayController({
+ trapsKeyboardFocus: true,
+ contentTemplate: () => html`
+
+
Overlay 2. Tab key is trapped within the overlay
+
+
+ `,
+ }),
+ );
+
+ const overlayCtrl1 = overlays.add(
+ new GlobalOverlayController({
+ trapsKeyboardFocus: true,
+ contentTemplate: () => html`
+
+
Overlay 1. Tab key is trapped within the overlay
+
+
+
+ `,
+ }),
+ );
+
+ return html`
+
+
+ `;
+ })
+ .add('Option "isBlocking"', () => {
+ const blockingOverlayCtrl = overlays.add(
+ new GlobalOverlayController({
+ isBlocking: true,
+ contentTemplate: () => html`
+
+
Hides other overlays
+
+
+ `,
+ }),
+ );
+
+ const normalOverlayCtrl = overlays.add(
+ new GlobalOverlayController({
+ contentTemplate: () => html`
+
+
Normal overlay
+
+
+
+ `,
+ }),
+ );
+
+ return html`
+
+
+ `;
+ })
+ .add('Sync', () => {
+ const overlayCtrl = overlays.add(
+ new GlobalOverlayController({
+ contentTemplate: data => html`
+
+
${data.title}
+
+
overlayCtrl.sync({ isShown: true, data: { title: e.target.value } })}"
+ />
+
+
+ `,
+ }),
+ );
+
+ return html`
+
+
+ `;
+ })
+ .add('Toast', () => {
+ let counter = 0;
+
+ function openInfo() {
+ const toastCtrl = overlays.add(
+ new GlobalOverlayController({
+ contentTemplate: data => html`
+
+
Title ${data.counter}
+
Lorem ipsum ${data.counter}
+
+ `,
+ }),
+ );
+ toastCtrl.sync({
+ isShown: true,
+ data: { counter },
+ });
+ counter += 1;
+ setTimeout(() => {
+ toastCtrl.hide();
+ counter -= 1;
+ }, 2000);
+ }
+
+ return html`
+
+
+
Very naive toast implementation
+
It does not handle adding new while toasts are getting hidden
+ `;
+ })
+ .add('In web components', () => {
+ class EditUsernameOverlay extends LionLitElement {
+ static get properties() {
+ return {
+ username: { type: String },
+ };
+ }
+
+ static get styles() {
+ return css`
+ :host {
+ position: fixed;
+ left: 20px;
+ top: 20px;
+ display: block;
+ width: 300px;
+ padding: 24px;
+ background-color: white;
+ border: 1px solid blue;
+ }
+
+ .close-button {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ }
+ `;
+ }
+
+ render() {
+ return html`
+
+
+
+
+
+
+ `;
+ }
+
+ _onUsernameEdited() {
+ this.dispatchEvent(
+ new CustomEvent('edit-username-submitted', {
+ detail: this.$id('usernameInput').value,
+ }),
+ );
+ }
+
+ _onClose() {
+ this.dispatchEvent(new CustomEvent('edit-username-closed'));
+ }
+ }
+ if (!customElements.get('edit-username-overlay')) {
+ customElements.define('edit-username-overlay', EditUsernameOverlay);
+ }
+ class MyComponent extends LionLitElement {
+ static get properties() {
+ return {
+ username: { type: String },
+ _editingUsername: { type: Boolean },
+ };
+ }
+
+ constructor() {
+ super();
+
+ this.username = 'Steve';
+ this._editingUsername = false;
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this._editOverlay.hide();
+ }
+
+ render() {
+ return html`
+
Your username is: ${this.username}
+
+ `;
+ }
+
+ firstUpdated() {
+ this._editOverlay = overlays.add(
+ new GlobalOverlayController({
+ focusElementAfterHide: this.shadowRoot.querySelector('button'),
+ contentTemplate: data => html`
+
this._onEditSubmitted(e)}"
+ @edit-username-closed="${() => this._onEditClosed()}"
+ >
+
+ `,
+ }),
+ );
+ }
+
+ updated() {
+ this._editOverlay.sync({
+ isShown: this._editingUsername,
+ data: { username: this.username },
+ });
+ }
+
+ _onEditSubmitted(e) {
+ this.username = e.detail;
+ this._editingUsername = false;
+ }
+
+ _onEditClosed() {
+ this._editingUsername = false;
+ }
+
+ _onStartEditUsername() {
+ this._editingUsername = true;
+ }
+ }
+ if (!customElements.get('my-component')) {
+ customElements.define('my-component', MyComponent);
+ }
+ return html`
+
+ `;
+ });
diff --git a/packages/overlays/stories/index.stories.js b/packages/overlays/stories/index.stories.js
new file mode 100644
index 000000000..69897cec5
--- /dev/null
+++ b/packages/overlays/stories/index.stories.js
@@ -0,0 +1,4 @@
+import './global-overlay.stories.js';
+import './modal-dialog.stories.js';
+import './local-overlay.stories.js';
+import './local-overlay-placement.stories.js';
diff --git a/packages/overlays/stories/local-overlay-placement.stories.js b/packages/overlays/stories/local-overlay-placement.stories.js
new file mode 100644
index 000000000..6279ad303
--- /dev/null
+++ b/packages/overlays/stories/local-overlay-placement.stories.js
@@ -0,0 +1,139 @@
+import { storiesOf, html, action } from '@open-wc/storybook';
+import { css } from '@lion/core';
+import { managePosition } from '../src/utils/manage-position.js';
+
+const popupPlacementDemoStyle = css`
+ .demo-container {
+ height: 100vh;
+ background-color: #ebebeb;
+ }
+
+ .demo-box {
+ width: 40px;
+ height: 40px;
+ background-color: white;
+ border-radius: 2px;
+ border: 1px solid grey;
+ margin: 120px auto 120px 360px;
+ padding: 8px;
+ }
+
+ .demo-popup {
+ display: block;
+ position: absolute;
+ width: 250px;
+ 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;
+ }
+`;
+
+storiesOf('Overlay System|Local/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 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';
+ }
+ action('position: ')(placement);
+ managePosition(element, target, { placement, position: 'absolute' });
+ };
+ return html`
+
+
+
+
Check the action logger to see the placement changes on toggling this button.
+ ${target} ${element}
+
+ `;
+ })
+ .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.
+ `;
+
+ 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';
+ }
+ action('position: ')(placement);
+ managePosition(element, target, { placement, position: 'absolute' });
+ };
+ return html`
+
+
+
+
Check the action logger to see the placement changes on toggling this button.
+ ${target} ${element}
+
+ `;
+ });
diff --git a/packages/overlays/stories/local-overlay.stories.js b/packages/overlays/stories/local-overlay.stories.js
new file mode 100644
index 000000000..037b33177
--- /dev/null
+++ b/packages/overlays/stories/local-overlay.stories.js
@@ -0,0 +1,214 @@
+import { storiesOf, html } from '@open-wc/storybook';
+import { css } from '@lion/core';
+import { overlays } from '../src/overlays.js';
+import { LocalOverlayController } from '../src/LocalOverlayController.js';
+
+const popupDemoStyle = css`
+ .demo-box {
+ width: 200px;
+ height: 40px;
+ background-color: white;
+ border-radius: 2px;
+ border: 1px solid grey;
+ margin: 240px auto 240px 240px;
+ padding: 8px;
+ }
+
+ .demo-popup {
+ display: block;
+ max-width: 250px;
+ 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;
+ }
+`;
+
+storiesOf('Overlay System|Local/Local Overlay', module)
+ .add('Basic', () => {
+ const popup = overlays.add(
+ new LocalOverlayController({
+ hidesOnEsc: true,
+ hidesOnOutsideClick: true,
+ contentTemplate: () =>
+ html`
+
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ }),
+ );
+ return html`
+
+
+ In the ${popup.invoker}${popup.content} the weather is nice.
+
+ `;
+ })
+ .add('Change preferred position', () => {
+ const popup = overlays.add(
+ new LocalOverlayController({
+ hidesOnEsc: true,
+ hidesOnOutsideClick: true,
+ placement: 'top right',
+ contentTemplate: () =>
+ html`
+
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ }),
+ );
+ return html`
+
+
+ In the ${popup.invoker}${popup.content} the weather is nice.
+
+ `;
+ })
+ .add('Single placement parameter', () => {
+ const popup = overlays.add(
+ new LocalOverlayController({
+ hidesOnEsc: true,
+ hidesOnOutsideClick: true,
+ placement: 'bottom',
+ contentTemplate: () => html`
+
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ }),
+ );
+ return html`
+
+
+ ${popup.invoker}${popup.content}
+
+ `;
+ })
+ .add('On hover', () => {
+ const popup = overlays.add(
+ new LocalOverlayController({
+ hidesOnEsc: true,
+ hidesOnOutsideClick: true,
+ placement: 'bottom',
+ contentTemplate: () =>
+ html`
+
+ `,
+ invokerTemplate: () => html`
+
+ `,
+ }),
+ );
+ return html`
+
+
+ In the beautiful ${popup.invoker}${popup.content} the weather is nice.
+
+ `;
+ })
+ .add('On an input', () => {
+ const popup = overlays.add(
+ new LocalOverlayController({
+ contentTemplate: () =>
+ html`
+
+ `,
+ invokerTemplate: () =>
+ html`
+
popup.show()}
+ @blur=${() => popup.hide()}
+ />
+ `,
+ }),
+ );
+ return html`
+
+
+
+ ${popup.invoker}${popup.content}
+
+ `;
+ })
+ .add('On toggle', () => {
+ const popup = overlays.add(
+ new LocalOverlayController({
+ hidesOnEsc: true,
+ hidesOnOutsideClick: true,
+ contentTemplate: () =>
+ html`
+
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ }),
+ );
+ return html`
+
+
+
+
+ `;
+ })
+ .add('trapsKeyboardFocus', () => {
+ const popup = overlays.add(
+ new LocalOverlayController({
+ hidesOnEsc: true,
+ hidesOnOutsideClick: true,
+ trapsKeyboardFocus: true,
+ contentTemplate: () => html`
+
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ }),
+ );
+ return html`
+
+
+ ${popup.invoker}${popup.content}
+
+ `;
+ });
diff --git a/packages/overlays/stories/modal-dialog.stories.js b/packages/overlays/stories/modal-dialog.stories.js
new file mode 100644
index 000000000..2c9c6854e
--- /dev/null
+++ b/packages/overlays/stories/modal-dialog.stories.js
@@ -0,0 +1,99 @@
+/* eslint-disable no-underscore-dangle, class-methods-use-this */
+import { storiesOf, html } from '@open-wc/storybook';
+
+import { css } from '@lion/core';
+import { overlays } from '../src/overlays.js';
+import { ModalDialogController } from '../src/ModalDialogController.js';
+
+const modalDialogDemoStyle = css`
+ .demo-overlay {
+ background-color: white;
+ position: fixed;
+ top: 20px;
+ left: 20px;
+ width: 200px;
+ border: 1px solid blue;
+ }
+
+ .demo-overlay--2 {
+ left: 240px;
+ }
+`;
+
+storiesOf('Overlay System|Global/Modal Dialog', module)
+ .add('Default', () => {
+ const dialogCtrl = overlays.add(
+ new ModalDialogController({
+ contentTemplate: () => html`
+
+
Modal dialog
+
+
+ `,
+ }),
+ );
+
+ return html`
+
+
Anchor 1
+
+
Anchor 2
+ ${Array(50).fill(
+ html`
+
Lorem ipsum
+ `,
+ )}
+ `;
+ })
+ .add('Option "isBlocking"', () => {
+ const blockingDialogCtrl = overlays.add(
+ new ModalDialogController({
+ isBlocking: true,
+ contentTemplate: () => html`
+
+
Hides other dialogs
+
+
+ `,
+ }),
+ );
+
+ const normalDialogCtrl = overlays.add(
+ new ModalDialogController({
+ contentTemplate: () => html`
+
+
Normal dialog
+
+
+
+ `,
+ }),
+ );
+
+ return html`
+
+
+ `;
+ });
diff --git a/packages/overlays/test/GlobalOverlayController.test.js b/packages/overlays/test/GlobalOverlayController.test.js
new file mode 100644
index 000000000..b59f37deb
--- /dev/null
+++ b/packages/overlays/test/GlobalOverlayController.test.js
@@ -0,0 +1,706 @@
+/* eslint-env mocha */
+/* eslint-disable no-underscore-dangle, no-unused-expressions */
+
+import { expect, fixture, html } from '@open-wc/testing';
+import { keyUpOn } from '@polymer/iron-test-helpers/mock-interactions.js';
+import { keyCodes } from '../src/utils/key-codes.js';
+import { simulateTab } from '../src/utils/simulate-tab.js';
+import { getDeepActiveElement } from '../src/utils/get-deep-active-element.js';
+
+import { GlobalOverlayController } from '../src/GlobalOverlayController.js';
+
+function getRootNode() {
+ return document.querySelector('.global-overlays');
+}
+
+function getRenderedContainers() {
+ const rootNode = getRootNode();
+ return rootNode ? Array.from(rootNode.children) : [];
+}
+
+function isEqualOrHasParent(element, parentElement) {
+ if (!parentElement) {
+ return false;
+ }
+
+ if (element === parentElement) {
+ return true;
+ }
+
+ return isEqualOrHasParent(element, parentElement.parentElement);
+}
+
+function getTopContainer() {
+ return getRenderedContainers().find(container => {
+ const rect = container.getBoundingClientRect();
+ const topElement = document.elementFromPoint(Math.ceil(rect.left), Math.ceil(rect.top));
+ return isEqualOrHasParent(container, topElement);
+ });
+}
+
+function getTopOverlay() {
+ const topContainer = getTopContainer();
+ return topContainer ? topContainer.children[0] : null;
+}
+
+function getRenderedContainer(index) {
+ return getRenderedContainers()[index];
+}
+
+function getRenderedOverlay(index) {
+ const container = getRenderedContainer(index);
+ return container ? container.children[0] : null;
+}
+
+function cleanup() {
+ document.body.removeAttribute('style');
+ if (GlobalOverlayController._rootNode) {
+ GlobalOverlayController._rootNode.parentElement.removeChild(GlobalOverlayController._rootNode);
+ GlobalOverlayController._rootNode = undefined;
+ }
+}
+
+describe('GlobalOverlayController', () => {
+ afterEach(cleanup);
+
+ describe('basics', () => {
+ it('creates a controller with methods: show, hide, sync', () => {
+ const controller = new GlobalOverlayController();
+ expect(controller.show).to.be.a('function');
+ expect(controller.hide).to.be.a('function');
+ expect(controller.sync).to.be.a('function');
+ });
+
+ it('creates a root node in body when first controller is shown', () => {
+ const controller = new GlobalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ });
+ expect(document.body.querySelectorAll('.global-overlays').length).to.equal(0);
+ controller.show();
+ expect(document.body.querySelectorAll('.global-overlays').length).to.equal(1);
+ expect(document.body.querySelector('.global-overlays')).to.equal(
+ GlobalOverlayController._rootNode,
+ );
+ expect(document.body.querySelector('.global-overlays').parentElement).to.equal(document.body);
+ expect(GlobalOverlayController._rootNode.children.length).to.equal(1);
+ });
+
+ it('renders an overlay from the lit-html based contentTemplate when showing', () => {
+ const controller = new GlobalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ });
+ controller.show();
+ expect(getRootNode().children.length).to.equal(1);
+ expect(getRootNode().children[0].classList.contains('global-overlays__overlay')).to.be.true;
+ expect(getRootNode().children[0].children.length).to.equal(1);
+ expect(getRootNode().children[0].children[0].tagName).to.equal('P');
+ expect(getRootNode().children[0].children[0].textContent).to.equal('Content');
+ });
+
+ it('removes the overlay from DOM when hiding', () => {
+ const controller = new GlobalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ });
+
+ controller.show();
+ expect(getRenderedContainers().length).to.equal(1);
+ expect(getRenderedOverlay(0).tagName).to.equal('P');
+ expect(getRenderedOverlay(0).textContent).to.equal('Content');
+ expect(getTopContainer()).to.equal(getRenderedContainer(0));
+
+ controller.hide();
+ expect(getRenderedContainers().length).to.equal(0);
+ expect(getTopContainer()).to.not.exist;
+ });
+
+ it('exposes isShown state for reading', () => {
+ const controller = new GlobalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ });
+
+ expect(controller.isShown).to.equal(false);
+
+ controller.show();
+ expect(controller.isShown).to.equal(true);
+
+ controller.hide();
+ expect(controller.isShown).to.equal(false);
+ });
+
+ it('puts the latest shown overlay always on top', () => {
+ const controller0 = new GlobalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content0
+ `,
+ });
+ const controller1 = new GlobalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content1
+ `,
+ });
+
+ controller0.show();
+ controller1.show();
+ controller0.show();
+
+ expect(getRenderedContainers().length).to.equal(2);
+ expect(getRenderedOverlay(0).tagName).to.equal('P');
+ expect(getRenderedOverlay(0).textContent).to.equal('Content0');
+ expect(getRenderedOverlay(1).tagName).to.equal('P');
+ expect(getRenderedOverlay(1).textContent).to.equal('Content1');
+ expect(getTopOverlay().textContent).to.equal('Content0');
+ });
+
+ it('does not recreate the overlay elements when calling show multiple times', () => {
+ const controller = new GlobalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ });
+
+ controller.show();
+ expect(getRenderedContainers().length).to.equal(1);
+ const initialContainer = getRenderedContainer(0);
+ const initialOverlay = getRenderedOverlay(0);
+
+ controller.show();
+ expect(getRenderedContainers().length).to.equal(1);
+ expect(getRenderedContainer(0)).to.equal(initialContainer);
+ expect(getRenderedOverlay(0)).to.equal(initialOverlay);
+ });
+
+ it('recreates the overlay elements when hiding and showing again', () => {
+ const controller = new GlobalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ });
+
+ controller.show();
+ expect(getRenderedContainers().length).to.equal(1);
+ const initialContainer = getRenderedContainer(0);
+ const initialOverlay = getRenderedOverlay(0);
+
+ controller.hide();
+ controller.show();
+ expect(getRenderedContainers().length).to.equal(1);
+ expect(getRenderedContainer(0)).to.not.equal(initialContainer);
+ expect(getRenderedOverlay(0)).to.not.equal(initialOverlay);
+ });
+
+ it('supports syncing of shown state, data', () => {
+ const controller = new GlobalOverlayController({
+ contentTemplate: data =>
+ html`
+
${data.text}
+ `,
+ });
+
+ controller.sync({ isShown: true, data: { text: 'hello world' } });
+ expect(getRenderedContainers().length).to.equal(1);
+ expect(getRenderedOverlay(0).textContent).to.equal('hello world');
+
+ controller.sync({ isShown: true, data: { text: 'goodbye world' } });
+ expect(getRenderedContainers().length).to.equal(1);
+ expect(getRenderedOverlay(0).textContent).to.equal('goodbye world');
+
+ controller.sync({ isShown: false, data: { text: 'goodbye world' } });
+ expect(getRenderedContainers().length).to.equal(0);
+ });
+ });
+
+ describe('elementToFocusAfterHide', () => {
+ it('focuses body when hiding by default', () => {
+ const controller = new GlobalOverlayController({
+ contentTemplate: () =>
+ html`
+
=
+ `,
+ });
+
+ controller.show();
+ const input = getTopOverlay().querySelector('input');
+ input.focus();
+ expect(document.activeElement).to.equal(input);
+
+ controller.hide();
+ expect(document.activeElement).to.equal(document.body);
+ });
+
+ it('supports elementToFocusAfterHide option to focus it when hiding', async () => {
+ const input = await fixture(
+ html`
+
+ `,
+ );
+
+ const controller = new GlobalOverlayController({
+ elementToFocusAfterHide: input,
+ contentTemplate: () =>
+ html`
+
+ `,
+ });
+
+ controller.show();
+ const textarea = getTopOverlay().querySelector('textarea');
+ textarea.focus();
+ expect(document.activeElement).to.equal(textarea);
+
+ controller.hide();
+ expect(document.activeElement).to.equal(input);
+ });
+
+ it('allows to set elementToFocusAfterHide on show', async () => {
+ const input = await fixture(
+ html`
+
+ `,
+ );
+
+ const controller = new GlobalOverlayController({
+ contentTemplate: () =>
+ html`
+
+ `,
+ });
+
+ controller.show(input);
+ const textarea = getTopOverlay().querySelector('textarea');
+ textarea.focus();
+ expect(document.activeElement).to.equal(textarea);
+
+ controller.hide();
+ expect(document.activeElement).to.equal(input);
+ });
+
+ it('allows to set elementToFocusAfterHide on sync', async () => {
+ const input = await fixture(
+ html`
+
+ `,
+ );
+
+ const controller = new GlobalOverlayController({
+ contentTemplate: () =>
+ html`
+
+ `,
+ });
+
+ controller.sync({ isShown: true, elementToFocusAfterHide: input });
+ const textarea = getTopOverlay().querySelector('textarea');
+ textarea.focus();
+ expect(document.activeElement).to.equal(textarea);
+
+ controller.hide();
+ expect(document.activeElement).to.equal(input);
+
+ controller.sync({ isShown: true, elementToFocusAfterHide: input });
+ const textarea2 = getTopOverlay().querySelector('textarea');
+ textarea2.focus();
+ expect(document.activeElement).to.equal(textarea2);
+
+ controller.sync({ isShown: false });
+ expect(document.activeElement).to.equal(input);
+ });
+ });
+
+ describe('hasBackdrop', () => {
+ it('has no backdrop by default', () => {
+ const controllerWithoutBackdrop = new GlobalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ });
+ controllerWithoutBackdrop.show();
+ expect(getRenderedContainer(0).classList.contains('global-overlays__backdrop')).to.be.false;
+ });
+
+ it('supports a backdrop option', () => {
+ const controllerWithoutBackdrop = new GlobalOverlayController({
+ hasBackdrop: false,
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ });
+ controllerWithoutBackdrop.show();
+ expect(getRenderedContainer(0).classList.contains('global-overlays__backdrop')).to.be.false;
+ controllerWithoutBackdrop.hide();
+
+ const controllerWithBackdrop = new GlobalOverlayController({
+ hasBackdrop: true,
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ });
+ controllerWithBackdrop.show();
+ expect(getRenderedContainer(0).classList.contains('global-overlays__backdrop')).to.be.true;
+ });
+
+ it('adds a backdrop to the top most overlay with hasBackdrop enabled', () => {
+ const controller0 = new GlobalOverlayController({
+ hasBackdrop: true,
+ contentTemplate: () =>
+ html`
+
Content0
+ `,
+ });
+ controller0.show();
+ expect(getRenderedContainer(0).classList.contains('global-overlays__backdrop')).to.be.true;
+
+ const controller1 = new GlobalOverlayController({
+ hasBackdrop: false,
+ contentTemplate: () =>
+ html`
+
Content1
+ `,
+ });
+ controller1.show();
+ expect(getRenderedContainer(0).classList.contains('global-overlays__backdrop')).to.be.true;
+ expect(getRenderedContainer(1).classList.contains('global-overlays__backdrop')).to.be.false;
+
+ const controller2 = new GlobalOverlayController({
+ hasBackdrop: true,
+ contentTemplate: () =>
+ html`
+
Content2
+ `,
+ });
+ controller2.show();
+ expect(getRenderedContainer(0).classList.contains('global-overlays__backdrop')).to.be.false;
+ expect(getRenderedContainer(1).classList.contains('global-overlays__backdrop')).to.be.false;
+ expect(getRenderedContainer(2).classList.contains('global-overlays__backdrop')).to.be.true;
+ });
+
+ it('restores the backdrop to the next element with hasBackdrop when hiding', () => {
+ const controller0 = new GlobalOverlayController({
+ hasBackdrop: true,
+ contentTemplate: () =>
+ html`
+
Content0
+ `,
+ });
+ controller0.show();
+
+ const controller1 = new GlobalOverlayController({
+ hasBackdrop: false,
+ contentTemplate: () =>
+ html`
+
Content1
+ `,
+ });
+ controller1.show();
+
+ const controller2 = new GlobalOverlayController({
+ hasBackdrop: true,
+ contentTemplate: () =>
+ html`
+
Content2
+ `,
+ });
+ controller2.show();
+
+ controller2.hide();
+
+ expect(getRenderedContainer(0).classList.contains('global-overlays__backdrop')).to.be.true;
+ expect(getRenderedContainer(1).classList.contains('global-overlays__backdrop')).to.be.false;
+ });
+ });
+
+ describe('isBlocking', () => {
+ it('prevents showing of other overlays', () => {
+ const controller0 = new GlobalOverlayController({
+ isBlocking: false,
+ contentTemplate: () =>
+ html`
+
Content0
+ `,
+ });
+ controller0.show();
+
+ const controller1 = new GlobalOverlayController({
+ isBlocking: false,
+ contentTemplate: () =>
+ html`
+
Content1
+ `,
+ });
+ controller1.show();
+
+ const controller2 = new GlobalOverlayController({
+ isBlocking: true,
+ contentTemplate: () =>
+ html`
+
Content2
+ `,
+ });
+ controller2.show();
+
+ const controller3 = new GlobalOverlayController({
+ isBlocking: false,
+ contentTemplate: () =>
+ html`
+
Content3
+ `,
+ });
+ controller3.show();
+
+ expect(window.getComputedStyle(getRenderedContainer(0)).display).to.equal('none');
+ expect(window.getComputedStyle(getRenderedContainer(1)).display).to.equal('none');
+ expect(window.getComputedStyle(getRenderedContainer(2)).display).to.equal('block');
+ expect(window.getComputedStyle(getRenderedContainer(3)).display).to.equal('none');
+ });
+ });
+
+ describe('trapsKeyboardFocus (for a11y)', () => {
+ it('adds attributes inert and aria-hidden="true" on all siblings of rootNode if an overlay is shown', () => {
+ const controller = new GlobalOverlayController({
+ trapsKeyboardFocus: true,
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ });
+
+ // show+hide are needed to create a root node
+ controller.show();
+ controller.hide();
+
+ const sibling1 = document.createElement('div');
+ const sibling2 = document.createElement('div');
+ document.body.insertBefore(sibling1, getRootNode());
+ document.body.appendChild(sibling2);
+
+ controller.show();
+
+ [sibling1, sibling2].forEach(sibling => {
+ expect(sibling.getAttribute('aria-hidden')).to.equal('true');
+ expect(sibling.hasAttribute('inert')).to.be.true;
+ });
+ expect(getRenderedOverlay(0).hasAttribute('aria-hidden')).to.be.false;
+ expect(getRenderedOverlay(0).hasAttribute('inert')).to.be.false;
+
+ controller.hide();
+
+ [sibling1, sibling2].forEach(sibling => {
+ expect(sibling.hasAttribute('aria-hidden')).to.be.false;
+ expect(sibling.hasAttribute('inert')).to.be.false;
+ });
+
+ // cleanup
+ document.body.removeChild(sibling1);
+ document.body.removeChild(sibling2);
+ });
+
+ /**
+ * style.userSelect:
+ * - chrome: 'none'
+ * - rest: undefined
+ *
+ * style.pointerEvents:
+ * - chrome: auto
+ * - IE11: visiblePainted
+ */
+ it('disables pointer events and selection on inert elements', async () => {
+ const controller = new GlobalOverlayController({
+ trapsKeyboardFocus: true,
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ });
+
+ // show+hide are needed to create a root node
+ controller.show();
+ controller.hide();
+
+ const sibling1 = document.createElement('div');
+ const sibling2 = document.createElement('div');
+ document.body.insertBefore(sibling1, getRootNode());
+ document.body.appendChild(sibling2);
+
+ controller.show();
+
+ [sibling1, sibling2].forEach(sibling => {
+ expect(window.getComputedStyle(sibling).userSelect).to.be.oneOf(['none', undefined]);
+ expect(window.getComputedStyle(sibling).pointerEvents).to.equal('none');
+ });
+ expect(window.getComputedStyle(getRenderedOverlay(0)).userSelect).to.be.oneOf([
+ 'auto',
+ undefined,
+ ]);
+ expect(window.getComputedStyle(getRenderedOverlay(0)).pointerEvents).to.be.oneOf([
+ 'auto',
+ 'visiblePainted',
+ ]);
+
+ controller.hide();
+
+ [sibling1, sibling2].forEach(sibling => {
+ expect(window.getComputedStyle(sibling).userSelect).to.be.oneOf(['auto', undefined]);
+ expect(window.getComputedStyle(sibling).pointerEvents).to.be.oneOf([
+ 'auto',
+ 'visiblePainted',
+ ]);
+ });
+
+ // cleanup
+ document.body.removeChild(sibling1);
+ document.body.removeChild(sibling2);
+ });
+
+ it('focuses the overlay on show', () => {
+ const controller = new GlobalOverlayController({
+ trapsKeyboardFocus: true,
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ });
+ controller.show();
+ expect(getRenderedOverlay(0)).to.equal(document.activeElement);
+ });
+
+ it('keeps focus within the overlay e.g. you can not tab out by accident', async () => {
+ const controller = new GlobalOverlayController({
+ trapsKeyboardFocus: true,
+ contentTemplate: () =>
+ html`
+
+ `,
+ });
+ controller.show();
+
+ const elOutside = await fixture(
+ html`
+
+ `,
+ );
+ const input1 = getRenderedOverlay(0).querySelectorAll('input')[0];
+ const input2 = getRenderedOverlay(0).querySelectorAll('input')[1];
+
+ input2.focus();
+ // this mimics a tab within the contain-focus system used
+ const event = new CustomEvent('keydown', { detail: 0, bubbles: true });
+ event.keyCode = keyCodes.tab;
+ window.dispatchEvent(event);
+
+ expect(elOutside).to.not.equal(document.activeElement);
+ expect(input1).to.equal(document.activeElement);
+ });
+
+ it('allows to move the focus outside of the overlay if trapsKeyboardFocus is disabled', async () => {
+ const controller = new GlobalOverlayController({
+ trapsKeyboardFocus: false,
+ contentTemplate: () =>
+ html`
+
+ `,
+ });
+ controller.show();
+
+ const elOutside = await fixture(
+ html`
+
+ `,
+ );
+ const input = getRenderedOverlay(0).querySelector('input');
+
+ input.focus();
+ simulateTab();
+
+ expect(elOutside).to.equal(document.activeElement);
+ });
+
+ it.skip('keeps focus within overlay with multiple overlays with all traps on true', async () => {
+ // TODO: find a way to test it
+ const controller0 = new GlobalOverlayController({
+ trapsKeyboardFocus: true,
+ contentTemplate: () =>
+ html`
+
+ `,
+ });
+
+ const controller1 = new GlobalOverlayController({
+ trapsKeyboardFocus: true,
+ contentTemplate: () =>
+ html`
+
+ `,
+ });
+
+ controller0.show();
+ controller1.show();
+
+ simulateTab();
+ expect(getDeepActiveElement().id).to.equal('input1');
+ simulateTab();
+ expect(getDeepActiveElement().id).to.equal('button1');
+ simulateTab();
+ expect(getDeepActiveElement().id).to.equal('input1');
+ });
+ });
+
+ describe('preventsScroll', () => {
+ it('prevent scrolling the background', async () => {
+ const controller = new GlobalOverlayController({
+ preventsScroll: true,
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ });
+
+ controller.show();
+ controller.updateComplete;
+ expect(getComputedStyle(document.body).overflow).to.equal('hidden');
+
+ controller.hide();
+ controller.updateComplete;
+ expect(getComputedStyle(document.body).overflow).to.equal('visible');
+ });
+ });
+
+ describe('hidesOnEsc', () => {
+ it('hides when Escape is pressed', async () => {
+ const controller = new GlobalOverlayController({
+ hidesOnEsc: true,
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ });
+
+ controller.show();
+ expect(getRenderedContainers().length).to.equal(1);
+
+ keyUpOn(getRenderedContainer(0), keyCodes.escape);
+ expect(getRenderedContainers().length).to.equal(0);
+ });
+ });
+});
diff --git a/packages/overlays/test/LocalOverlayController.test.js b/packages/overlays/test/LocalOverlayController.test.js
new file mode 100644
index 000000000..7a2b48f7a
--- /dev/null
+++ b/packages/overlays/test/LocalOverlayController.test.js
@@ -0,0 +1,639 @@
+/* eslint-env mocha */
+/* eslint-disable no-underscore-dangle, no-unused-expressions */
+
+import { expect, fixture, html, aTimeout, defineCE, unsafeStatic } from '@open-wc/testing';
+
+import { keyUpOn } from '@polymer/iron-test-helpers/mock-interactions.js';
+import { LionLitElement } from '@lion/core/src/LionLitElement.js';
+import { keyCodes } from '../src/utils/key-codes.js';
+import { simulateTab } from '../src/utils/simulate-tab.js';
+
+import { LocalOverlayController } from '../src/LocalOverlayController.js';
+
+import { overlays } from '../src/overlays.js';
+
+describe('LocalOverlayController', () => {
+ describe('templates', () => {
+ it('creates a controller with methods: show, hide, sync and syncInvoker', () => {
+ const controller = new LocalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ });
+ expect(controller.show).to.be.a('function');
+ expect(controller.hide).to.be.a('function');
+ expect(controller.sync).to.be.a('function');
+ expect(controller.syncInvoker).to.be.a('function');
+ });
+
+ it('will render holders for invoker and content', async () => {
+ const controller = new LocalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ });
+ const el = await fixture(html`
+
+ ${controller.invoker} ${controller.content}
+
+ `);
+ expect(el.querySelectorAll('div')[0].textContent.trim()).to.equal('Invoker');
+ controller.show();
+ expect(el.querySelectorAll('div')[1].textContent.trim()).to.equal('Content');
+ });
+
+ it('will add/remove the content on show/hide', async () => {
+ const controller = new LocalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ });
+ const el = await fixture(html`
+
+ ${controller.invoker} ${controller.content}
+
+ `);
+
+ expect(el.querySelectorAll('div')[1].textContent.trim()).to.equal('');
+
+ controller.show();
+ expect(el.querySelectorAll('div')[1].textContent.trim()).to.equal('Content');
+
+ controller.hide();
+ expect(el.querySelectorAll('div')[1].textContent.trim()).to.equal('');
+ });
+
+ it('will hide and show html nodes provided to overlay', async () => {
+ const tagString = defineCE(
+ class extends LionLitElement {
+ // eslint-disable-next-line class-methods-use-this
+ render() {
+ return html`
+
+ `;
+ }
+ },
+ );
+
+ const element = unsafeStatic(tagString);
+ const elem = await fixture(html`
+ <${element}>
+
content
+
+ <${element}> ${element}
+ >${element}>
+ `);
+
+ const controller = overlays.add(
+ new LocalOverlayController({
+ hidesOnEsc: true,
+ hidesOnOutsideClick: true,
+ contentNode: elem.querySelector('[slot="content"]'),
+ invokerNode: elem.querySelector('[slot="invoker"]'),
+ }),
+ );
+
+ expect(elem.querySelector('[slot="content"]').style.display).to.equal('none');
+ controller.show();
+ expect(elem.querySelector('[slot="content"]').style.display).to.equal('inline-block');
+ controller.hide();
+ expect(elem.querySelector('[slot="content"]').style.display).to.equal('none');
+ });
+
+ it('exposes isShown state for reading', async () => {
+ const controller = new LocalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ });
+ await fixture(html`
+
+ ${controller.invoker} ${controller.content}
+
+ `);
+ expect(controller.isShown).to.equal(false);
+ controller.show();
+ expect(controller.isShown).to.equal(true);
+ controller.hide();
+ expect(controller.isShown).to.equal(false);
+ });
+
+ it('can update the invoker data', async () => {
+ const controller = new LocalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerTemplate: (data = { text: 'foo' }) =>
+ html`
+
+ `,
+ });
+
+ expect(controller.invoker.textContent.trim()).to.equal('foo');
+ controller.syncInvoker({ data: { text: 'bar' } });
+ expect(controller.invoker.textContent.trim()).to.equal('bar');
+ });
+
+ it('can synchronize the content data', async () => {
+ const controller = new LocalOverlayController({
+ contentTemplate: data =>
+ html`
+
${data.text}
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ });
+
+ controller.show();
+ controller.sync({ data: { text: 'foo' } });
+ expect(controller.content.textContent.trim()).to.equal('foo');
+
+ controller.sync({ data: { text: 'bar' } });
+ expect(controller.content.textContent.trim()).to.equal('bar');
+ });
+
+ it.skip('can reuse an existing node for the invoker (disables syncInvoker())', async () => {
+ const controller = new LocalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerReference: null, // TODO: invokerReference
+ });
+ await fixture(`
+
+
+ ${controller.content}
+
+ `);
+ expect(controller.invoker.textContent.trim()).to.equal('Invoker');
+ controller.show();
+ expect(controller.content.textContent.trim()).to.equal('Content');
+ });
+ });
+
+ // Please use absolute positions in the tests below to prevent the HTML generated by
+ // the test runner from interfering.
+ describe('positioning', () => {
+ it('positions correctly', async () => {
+ // smoke test for integration of positioning system
+ const controller = new LocalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ });
+
+ controller.show();
+ expect(controller.content.firstElementChild.style.top).to.equal('8px');
+ });
+
+ it('uses top as the default placement', async () => {
+ const controller = new LocalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerTemplate: () => html`
+
+ `,
+ });
+ await fixture(html`
+
+ ${controller.invoker} ${controller.content}
+
+ `);
+ controller.show();
+ const invokerChild = controller.content.firstElementChild;
+ expect(invokerChild.getAttribute('js-positioning-vertical')).to.equal('top');
+ expect(invokerChild.getAttribute('js-positioning-horizontal')).to.equal('centerHorizontal');
+ });
+
+ it('positions to preferred place if placement is set and space is available', async () => {
+ const controller = new LocalOverlayController({
+ invokerTemplate: () => html`
+
+ `,
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ placement: 'top right',
+ });
+ await fixture(html`
+
+ ${controller.invoker} ${controller.content}
+
+ `);
+
+ controller.show();
+ const contentChild = controller.content.firstElementChild;
+ expect(contentChild.getAttribute('js-positioning-vertical')).to.equal('top');
+ expect(contentChild.getAttribute('js-positioning-horizontal')).to.equal('right');
+ });
+
+ it('positions to different place if placement is set and no space is available', async () => {
+ const controller = new LocalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerTemplate: () => html`
+
+ `,
+ placement: 'top right',
+ });
+ await fixture(`
+
+ ${controller.invoker} ${controller.content}
+
+ `);
+
+ controller.show();
+ const invokerChild = controller.content.firstElementChild;
+ expect(invokerChild.getAttribute('js-positioning-vertical')).to.equal('bottom');
+ expect(invokerChild.getAttribute('js-positioning-horizontal')).to.equal('right');
+ });
+ });
+
+ describe('a11y', () => {
+ it('adds and removes aria-expanded on invoker', async () => {
+ const controller = new LocalOverlayController({
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ });
+
+ expect(controller.invoker.firstElementChild.getAttribute('aria-controls')).to.contain(
+ controller.content.id,
+ );
+ expect(controller.invoker.firstElementChild.getAttribute('aria-expanded')).to.equal('false');
+ controller.show();
+ expect(controller.invoker.firstElementChild.getAttribute('aria-expanded')).to.equal('true');
+ controller.hide();
+ expect(controller.invoker.firstElementChild.getAttribute('aria-expanded')).to.equal('false');
+ });
+
+ it('traps the focus via option { trapsKeyboardFocus: true }', async () => {
+ const controller = new LocalOverlayController({
+ contentTemplate: () => html`
+
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ trapsKeyboardFocus: true,
+ });
+ // make sure we're connected to the dom
+ await fixture(
+ html`
+ ${controller.invoker}${controller.content}
+ `,
+ );
+ controller.show();
+
+ const elOutside = await fixture(`
`);
+ const [el1, el2] = [].slice.call(
+ controller.content.firstElementChild.querySelectorAll('[id]'),
+ );
+
+ el2.focus();
+ // this mimics a tab within the contain-focus system used
+ const event = new CustomEvent('keydown', { detail: 0, bubbles: true });
+ event.keyCode = keyCodes.tab;
+ window.dispatchEvent(event);
+
+ expect(elOutside).to.not.equal(document.activeElement);
+ expect(el1).to.equal(document.activeElement);
+ });
+
+ it('allows to move the focus outside of the overlay if trapsKeyboardFocus is disabled', async () => {
+ const controller = new LocalOverlayController({
+ contentTemplate: () => html`
+
+
+
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ trapsKeyboardFocus: false,
+ });
+ // make sure we're connected to the dom
+ await fixture(
+ html`
+ ${controller.invoker}${controller.content}
+ `,
+ );
+ const elOutside = await fixture(`
`);
+ controller.show();
+ const el1 = controller.content.firstElementChild.querySelector('button');
+
+ el1.focus();
+ simulateTab();
+ expect(elOutside).to.equal(document.activeElement);
+ });
+ });
+
+ describe('hidesOnEsc', () => {
+ it('hides when [escape] is pressed', async () => {
+ const ctrl = new LocalOverlayController({
+ hidesOnEsc: true,
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ });
+ await fixture(
+ html`
+ ${ctrl.invoker}${ctrl.content}
+ `,
+ );
+ ctrl.show();
+
+ keyUpOn(ctrl.content, keyCodes.escape);
+ ctrl.updateComplete;
+ expect(ctrl.isShown).to.equal(false);
+ });
+
+ it('stays shown when [escape] is pressed on outside element', async () => {
+ const ctrl = new LocalOverlayController({
+ hidesOnEsc: true,
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ });
+ await fixture(
+ html`
+ ${ctrl.invoker}${ctrl.content}
+ `,
+ );
+ ctrl.show();
+
+ keyUpOn(document, keyCodes.escape);
+ ctrl.updateComplete;
+ expect(ctrl.isShown).to.equal(true);
+ });
+ });
+
+ describe('hidesOnOutsideClick', () => {
+ it('hides on outside click', async () => {
+ const controller = new LocalOverlayController({
+ hidesOnOutsideClick: true,
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ });
+ await fixture(
+ html`
+ ${controller.invoker}${controller.content}
+ `,
+ );
+ const { content } = controller;
+ controller.show();
+ expect(content.textContent.trim()).to.equal('Content');
+
+ document.body.click();
+ await aTimeout();
+ expect(content.textContent.trim()).to.equal('');
+ });
+
+ it('doesn\'t hide on "inside" click', async () => {
+ const ctrl = new LocalOverlayController({
+ hidesOnOutsideClick: true,
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ });
+ const { content, invoker, invokerNode } = ctrl;
+ await fixture(
+ html`
+ ${invoker}${content}
+ `,
+ );
+
+ // Don't hide on first invoker click
+ invokerNode.click();
+ await aTimeout();
+ expect(ctrl.isShown).to.equal(true);
+
+ // Don't hide on inside (content) click
+ content.click();
+ await aTimeout();
+ expect(ctrl.isShown).to.equal(true);
+
+ // Don't hide on invoker click when shown
+ invokerNode.click();
+ await aTimeout();
+ expect(ctrl.isShown).to.equal(true);
+
+ // Works as well when clicked content element lives in shadow dom
+ ctrl.show();
+ await aTimeout();
+ const tag = defineCE(
+ class extends HTMLElement {
+ constructor() {
+ super();
+ this.attachShadow({ mode: 'open' });
+ }
+
+ connectedCallback() {
+ this.shadowRoot.innerHTML = '
';
+ }
+ },
+ );
+ const shadowEl = document.createElement(tag);
+ content.appendChild(shadowEl);
+ shadowEl.shadowRoot.querySelector('button').click();
+ await aTimeout();
+ expect(ctrl.isShown).to.equal(true);
+
+ // Important to check if it can be still shown after, because we do some hacks inside
+ ctrl.hide();
+ expect(ctrl.isShown).to.equal(true);
+ ctrl.show();
+ expect(ctrl.isShown).to.equal(true);
+ });
+
+ it('works with 3rd party code using "event.stopPropagation()" on bubble phase', async () => {
+ const ctrl = new LocalOverlayController({
+ hidesOnOutsideClick: true,
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ });
+ const { content, invoker } = ctrl;
+ const dom = await fixture(`
+
+
+
{
+ /* propagates */
+ }}"
+ >
+
e.stopPropagation()}">
+ This element prevents our handlers from reaching the document click handler.
+
+
+ `);
+
+ ctrl.show();
+ expect(ctrl.isShown).to.equal(true);
+
+ dom.querySelector('third-party-noise').click();
+ await aTimeout();
+ expect(ctrl.isShown).to.equal(false);
+
+ // Important to check if it can be still shown after, because we do some hacks inside
+ ctrl.show();
+ expect(ctrl.isShown).to.equal(true);
+ });
+
+ it('works with 3rd party code using "event.stopPropagation()" on capture phase', async () => {
+ const ctrl = new LocalOverlayController({
+ hidesOnOutsideClick: true,
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ });
+ const { content, invoker } = ctrl;
+ const dom = await fixture(`
+
+
+
{
+ /* propagates */
+ }}"
+ >
+
+ This element prevents our handlers from reaching the document click handler.
+
+
+ `);
+
+ dom.querySelector('third-party-noise').addEventListener(
+ 'click',
+ event => {
+ event.stopPropagation();
+ },
+ true,
+ );
+
+ ctrl.show();
+ expect(ctrl.isShown).to.equal(true);
+
+ dom.querySelector('third-party-noise').click();
+ await aTimeout();
+ expect(ctrl.isShown).to.equal(false);
+
+ // Important to check if it can be still shown after, because we do some hacks inside
+ ctrl.show();
+ expect(ctrl.isShown).to.equal(true);
+ });
+ });
+
+ describe('toggles', () => {
+ it('toggles on clicks', async () => {
+ const ctrl = new LocalOverlayController({
+ hidesOnOutsideClick: true,
+ contentTemplate: () =>
+ html`
+
Content
+ `,
+ invokerTemplate: () =>
+ html`
+
+ `,
+ });
+ const { content, invoker, invokerNode } = ctrl;
+ await fixture(
+ html`
+ ${invoker}${content}
+ `,
+ );
+
+ // Show content on first invoker click
+ invokerNode.click();
+ await aTimeout();
+ expect(ctrl.isShown).to.equal(true);
+
+ // Hide content on click when shown
+ invokerNode.click();
+ await aTimeout();
+ expect(ctrl.isShown).to.equal(false);
+
+ // Show contnet on invoker click when hidden
+ invokerNode.click();
+ await aTimeout();
+ expect(ctrl.isShown).to.equal(true);
+ });
+ });
+});
diff --git a/packages/overlays/test/ModalDialogController.test.js b/packages/overlays/test/ModalDialogController.test.js
new file mode 100644
index 000000000..cce7ac979
--- /dev/null
+++ b/packages/overlays/test/ModalDialogController.test.js
@@ -0,0 +1,21 @@
+/* eslint-env mocha */
+
+import { expect } from '@open-wc/testing';
+
+import { GlobalOverlayController } from '../src/GlobalOverlayController.js';
+import { ModalDialogController } from '../src/ModalDialogController.js';
+
+describe('ModalDialogController', () => {
+ it('extends GlobalOverlayController', () => {
+ expect(new ModalDialogController()).to.be.instanceof(GlobalOverlayController);
+ });
+
+ it('has correct defaults', () => {
+ const controller = new ModalDialogController();
+ expect(controller.hasBackdrop).to.equal(true);
+ expect(controller.isBlocking).to.equal(false);
+ expect(controller.preventsScroll).to.equal(true);
+ expect(controller.trapsKeyboardFocus).to.equal(true);
+ expect(controller.hidesOnEsc).to.equal(true);
+ });
+});
diff --git a/packages/overlays/test/OverlaysManager.test.js b/packages/overlays/test/OverlaysManager.test.js
new file mode 100644
index 000000000..2d2ceff72
--- /dev/null
+++ b/packages/overlays/test/OverlaysManager.test.js
@@ -0,0 +1,22 @@
+/* eslint-disable no-unused-expressions, no-underscore-dangle */
+import { expect } from '@open-wc/testing';
+import sinon from 'sinon';
+
+import { OverlaysManager } from '../src/OverlaysManager.js';
+
+function createGlobalOverlayControllerMock() {
+ return {
+ sync: sinon.spy(),
+ update: sinon.spy(),
+ show: sinon.spy(),
+ hide: sinon.spy(),
+ };
+}
+
+describe('OverlaysManager', () => {
+ it('returns the newly added overlay', () => {
+ const myOverlays = new OverlaysManager();
+ const myController = createGlobalOverlayControllerMock();
+ expect(myOverlays.add(myController)).to.equal(myController);
+ });
+});
diff --git a/packages/overlays/test/utils-tests/active-element.test.js b/packages/overlays/test/utils-tests/active-element.test.js
new file mode 100644
index 000000000..fb66909d5
--- /dev/null
+++ b/packages/overlays/test/utils-tests/active-element.test.js
@@ -0,0 +1,87 @@
+/* eslint-env mocha */
+/* eslint-disable no-underscore-dangle, no-unused-expressions, class-methods-use-this */
+import { expect, fixture, defineCE } from '@open-wc/testing';
+import { LitElement, html } from '@lion/core';
+
+import { getDeepActiveElement } from '../../src/utils/get-deep-active-element.js';
+
+describe('getDeepActiveElement()', () => {
+ it('handles document level active elements', async () => {
+ const element = await fixture(`
+
+ `);
+
+ const el1 = element.querySelector('#el-1');
+ const el2 = element.querySelector('#el-2');
+ const el3 = element.querySelector('#el-3');
+
+ el1.focus();
+ expect(getDeepActiveElement()).to.eql(el1);
+
+ el2.focus();
+ expect(getDeepActiveElement()).to.eql(el2);
+
+ el3.focus();
+ expect(getDeepActiveElement()).to.eql(el3);
+ });
+
+ it('handles active element inside shadowroots', async () => {
+ const elNestedTag = defineCE(
+ class extends LitElement {
+ render() {
+ return html`
+
Button
+
Href
+ `;
+ }
+ },
+ );
+
+ const elTag = defineCE(
+ class extends LitElement {
+ render() {
+ const elNested = document.createElement(elNestedTag);
+ return html`
+
+
+ ${elNested}
+ `;
+ }
+ },
+ );
+
+ const element = await fixture(`
+
+ <${elTag}>${elTag}>
+
+
+ `);
+
+ const elA = element.querySelector(elTag).shadowRoot;
+ const elB = elA.querySelector(elNestedTag).shadowRoot;
+ const elA1 = elA.querySelector('#el-a-1');
+ const elA2 = elA.querySelector('#el-a-2');
+ const elB1 = elB.querySelector('#el-b-1');
+ const elB2 = elB.querySelector('#el-b-1');
+ const el1 = element.querySelector('#el-1');
+
+ elA1.focus();
+ expect(getDeepActiveElement()).to.eql(elA1);
+
+ elA2.focus();
+ expect(getDeepActiveElement()).to.eql(elA2);
+
+ elB1.focus();
+ expect(getDeepActiveElement()).to.eql(elB1);
+
+ elB2.focus();
+ expect(getDeepActiveElement()).to.eql(elB2);
+
+ el1.focus();
+ expect(getDeepActiveElement()).to.eql(el1);
+ });
+});
diff --git a/packages/overlays/test/utils-tests/contain-focus.test.js b/packages/overlays/test/utils-tests/contain-focus.test.js
new file mode 100644
index 000000000..9c55ba2f8
--- /dev/null
+++ b/packages/overlays/test/utils-tests/contain-focus.test.js
@@ -0,0 +1,116 @@
+/* eslint-env mocha */
+/* eslint-disable no-underscore-dangle, no-unused-expressions */
+import { expect, fixture } from '@open-wc/testing';
+
+import { getDeepActiveElement } from '../../src/utils/get-deep-active-element.js';
+import { getFocusableElements } from '../../src/utils/get-focusable-elements.js';
+import { keyCodes } from '../../src/utils/key-codes.js';
+
+import { containFocus } from '../../src/utils/contain-focus.js';
+
+function simulateTabWithinContainFocus() {
+ const event = new CustomEvent('keydown', { detail: 0, bubbles: true });
+ event.keyCode = keyCodes.tab;
+ window.dispatchEvent(event);
+}
+
+const lightDomTemplate = `
+
+
+
+
+
+
+
+`;
+
+const lightDomAutofocusTemplate = `
+
+
+
+
+
+
+
+`;
+
+describe('containFocus()', () => {
+ it('starts focus at the root element when there is no element with autofocus', async () => {
+ await fixture(lightDomTemplate);
+ const root = document.getElementById('rootElement');
+ containFocus(root);
+
+ expect(getDeepActiveElement()).to.equal(root);
+ expect(root.getAttribute('tabindex')).to.equal('-1');
+ expect(root.style.getPropertyValue('outline-style')).to.equal('none');
+ });
+
+ it('starts focus at the element with the autofocus attribute', async () => {
+ await fixture(lightDomAutofocusTemplate);
+ const el = document.querySelector('input[autofocus]');
+ containFocus(el);
+
+ expect(getDeepActiveElement()).to.equal(el);
+ });
+
+ it('on tab, focuses first focusable element if focus was on element outside root element', async () => {
+ await fixture(lightDomTemplate);
+ const root = document.getElementById('rootElement');
+ const focusableElements = getFocusableElements(root);
+
+ containFocus(root);
+ document.getElementById('outside-1').focus();
+
+ simulateTabWithinContainFocus();
+ expect(getDeepActiveElement()).to.equal(focusableElements[0]);
+ });
+
+ it('on tab, focuses first focusable element if focus was on the last focusable element', async () => {
+ await fixture(lightDomTemplate);
+ const root = document.getElementById('rootElement');
+ const focusableElements = getFocusableElements(root);
+
+ containFocus(root);
+ focusableElements[focusableElements.length - 1].focus();
+
+ simulateTabWithinContainFocus();
+ expect(getDeepActiveElement()).to.equal(focusableElements[0]);
+ });
+
+ it('on tab, does not interfere if focus remains within the root element', async () => {
+ await fixture(lightDomTemplate);
+ const root = document.getElementById('rootElement');
+ const focusableElements = getFocusableElements(root);
+
+ containFocus(root);
+ focusableElements[2].focus();
+
+ simulateTabWithinContainFocus();
+ /**
+ * We test if focus remained on the same element because we cannot simulate
+ * actual tab key press. So the best we can do is if we didn't redirect focus
+ * to the first element.
+ */
+ expect(getDeepActiveElement()).to.equal(focusableElements[2]);
+ });
+});
diff --git a/packages/overlays/test/utils-tests/get-focusable-elements.test.js b/packages/overlays/test/utils-tests/get-focusable-elements.test.js
new file mode 100644
index 000000000..059a47484
--- /dev/null
+++ b/packages/overlays/test/utils-tests/get-focusable-elements.test.js
@@ -0,0 +1,194 @@
+/* eslint-env mocha */
+/* eslint-disable no-underscore-dangle, no-unused-expressions, class-methods-use-this */
+import { expect, fixture, defineCE } from '@open-wc/testing';
+import { LitElement, html } from '@lion/core';
+
+import { getFocusableElements } from '../../src/utils/get-focusable-elements.js';
+
+class ElementB extends LitElement {
+ render() {
+ const marker = this.getAttribute('marker') || '';
+ return html`
+
+
+
+ `;
+ }
+}
+
+customElements.define('element-b', ElementB);
+
+describe('getFocusableElements()', () => {
+ it('collects focusable nodes', async () => {
+ const element = await fixture(`
+
+ `);
+ const nodes = getFocusableElements(element);
+ const ids = nodes.map(n => n.id);
+
+ expect(ids).eql(['el1', 'el2', 'el3', 'el4', 'el5', 'el6', 'el7']);
+ });
+
+ it('handles nested nodes', async () => {
+ const element = await fixture(`
+
+ `);
+ const nodes = getFocusableElements(element);
+ const ids = nodes.map(n => n.id);
+
+ expect(ids).eql(['el1', 'el2', 'el3', 'el4']);
+ });
+
+ it('skips elements that should not receive focus', async () => {
+ const element = await fixture(`
+
+
+
+
+
foo
+
foo
+
+
foo
+
foo
+
+
+
+ `);
+ const nodes = getFocusableElements(element);
+ const ids = nodes.map(n => n.id);
+
+ expect(ids).eql(['el1', 'el3', 'el8', 'el11']);
+ });
+
+ it('respects tabindex order', async () => {
+ const element = await fixture(`
+
+ `);
+ const nodes = getFocusableElements(element);
+ const ids = nodes.map(n => n.id);
+
+ expect(ids).eql(['el8', 'el10', 'el5', 'el6', 'el7', 'el9', 'el1', 'el2', 'el3']);
+ });
+
+ it('handles shadow dom', async () => {
+ const element = await fixture(`
+
+
+
+ `);
+
+ const nodes = getFocusableElements(element);
+ const ids = nodes.map(n => n.id);
+
+ expect(ids).eql(['el-b-marker-1', 'el-b-marker-2', 'el-b-marker-3']);
+ });
+
+ it('handles slotted elements', async () => {
+ const elTag = defineCE(
+ class extends LitElement {
+ render() {
+ return html`
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ }
+ },
+ );
+
+ const element = await fixture(`
+
+ <${elTag}>
+
+
+
+
+
+
+
+
+
+
+
+ ${elTag}>
+
+ `);
+ const nodes = getFocusableElements(element);
+ const ids = nodes.map(n => n.id);
+
+ expect(ids).eql([
+ 'el-a-1',
+ 'el-b-marker-1-1',
+ 'el-b-marker-1-2',
+ 'el-b-marker-1-3',
+ 'el-3',
+ 'el-4',
+ 'el-6',
+ 'el-a-2',
+ 'el-2',
+ 'el-1',
+ 'el-5',
+ 'el-a-3',
+ 'el-b-marker-2-1',
+ 'el-b-marker-2-2',
+ 'el-b-marker-2-3',
+ ]);
+ });
+});
diff --git a/packages/overlays/test/utils-tests/get-position.test.js b/packages/overlays/test/utils-tests/get-position.test.js
new file mode 100644
index 000000000..699256184
--- /dev/null
+++ b/packages/overlays/test/utils-tests/get-position.test.js
@@ -0,0 +1,345 @@
+/* eslint-env mocha */
+/* eslint-disable no-unused-expressions, no-underscore-dangle */
+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
new file mode 100644
index 000000000..1be5e9893
--- /dev/null
+++ b/packages/overlays/test/utils-tests/manage-position.test.js
@@ -0,0 +1,114 @@
+/* eslint-env mocha */
+/* eslint-disable no-unused-expressions, no-underscore-dangle */
+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/packages/overlays/test/utils-tests/visibility.test.js b/packages/overlays/test/utils-tests/visibility.test.js
new file mode 100644
index 000000000..c967b5d62
--- /dev/null
+++ b/packages/overlays/test/utils-tests/visibility.test.js
@@ -0,0 +1,123 @@
+/* eslint-env mocha */
+/* eslint-disable no-underscore-dangle, no-unused-expressions */
+import { expect, fixture } from '@open-wc/testing';
+
+import { isVisible } from '../../src/utils/is-visible.js';
+
+describe('isVisible()', () => {
+ it('returns true for static block elements', async () => {
+ const element = await fixture(`
`);
+
+ expect(isVisible(element)).to.equal(true);
+ });
+
+ it('returns false for hidden static block elements', async () => {
+ const element = await fixture(`
`);
+
+ expect(isVisible(element)).to.equal(false);
+ });
+
+ it('returns true for relative block elements', async () => {
+ const element = await fixture(
+ `
`,
+ );
+
+ expect(isVisible(element)).to.equal(true);
+ });
+
+ it('returns false for hidden relative block elements', async () => {
+ const element = await fixture(
+ `
`,
+ );
+
+ expect(isVisible(element)).to.equal(false);
+ });
+
+ it('returns true for absolute block elements', async () => {
+ const element = await fixture(`
+
+ `);
+
+ expect(isVisible(element)).to.equal(true);
+ });
+
+ it('returns false for hidden absolute block elements', async () => {
+ const element = await fixture(`
+
+ `);
+
+ expect(isVisible(element)).to.equal(false);
+ });
+
+ it('returns true for relative block elements', async () => {
+ const element = await fixture(`
+
+ `);
+
+ expect(isVisible(element)).to.equal(true);
+ });
+
+ it('returns true for relative block elements', async () => {
+ const element = await fixture(`
+
+ `);
+
+ expect(isVisible(element)).to.equal(false);
+ });
+
+ it('returns true for inline elements', async () => {
+ const element = await fixture(`
Inline content`);
+
+ expect(isVisible(element)).to.equal(true);
+ });
+
+ it('returns true for inline elements without content', async () => {
+ const element = await fixture(`
`);
+
+ expect(isVisible(element)).to.equal(true);
+ });
+
+ it('returns true for static block elements with 0 dimensions', async () => {
+ const element = await fixture(`
`);
+
+ expect(isVisible(element)).to.equal(true);
+ });
+
+ it('returns false for hidden inline elements', async () => {
+ const element = await fixture(`
Inline content`);
+
+ expect(isVisible(element)).to.equal(false);
+ });
+
+ it('returns false invisible elements', async () => {
+ const element = await fixture(
+ `
`,
+ );
+
+ expect(isVisible(element)).to.equal(false);
+ });
+
+ it('returns false when hidden by parent', async () => {
+ const element = await fixture(`
+
+ `);
+
+ const target = element.querySelector('#target');
+ expect(isVisible(target)).to.equal(false);
+ });
+
+ it('returns false when invisible by parent', async () => {
+ const element = await fixture(`
+
+ `);
+
+ const target = element.querySelector('#target');
+ expect(isVisible(target)).to.equal(false);
+ });
+});
diff --git a/packages/popup/README.md b/packages/popup/README.md
new file mode 100644
index 000000000..ea6de6ae1
--- /dev/null
+++ b/packages/popup/README.md
@@ -0,0 +1,34 @@
+# Popup
+
+[//]: # (AUTO INSERT HEADER PREPUBLISH)
+
+`lion-popup` is a component used for basic popups on click.
+Its purpose is to show content appearing when the user clicks an invoker element with the cursor or with the keyboard.
+
+## Features
+
+- Show content when clicking the invoker
+- Use the position property to position the content popup relative to the invoker
+
+## How to use
+
+### Installation
+
+```sh
+npm i --save @lion/popup
+```
+
+```js
+import '@lion/popup/lion-popup.js';
+```
+
+### Example
+
+```html
+
+