@joinbox/overlay
Advanced tools
Comparing version 1.0.1 to 1.0.2
@@ -1,6 +0,228 @@ | ||
import OverlayButton from './OverlayButton.js'; | ||
(function () { | ||
'use strict'; | ||
/* global window */ | ||
if (!window.customElements.get('overlay-button-component')) { | ||
window.customElements.define('overlay-button-component', OverlayButton); | ||
} | ||
/** | ||
* Gets/validates attribute of a HTML element. | ||
* OUTDATED - use canReadAttributes instead. | ||
* @param {HTMLElement} options.element | ||
* @param {name} options.name Name of the attribute | ||
* @param {function} options.validate Validate function; return true if attribute is valid | ||
* @param {boolean} options.isSet True if you only want to know if the attribute is | ||
* set (but do not care about its value). | ||
* @param {string} errorMessage Additional error message | ||
* @return {*} String if isSet is false, else boolean | ||
*/ | ||
var getAndValidateAttribute = ({ | ||
element, | ||
name, | ||
validate = () => true, | ||
isSet = false, | ||
errorMessage = 'HTML attribute not valid', | ||
} = {}) => { | ||
if (!name) { | ||
throw new Error(`getAndValidateAttribute: Pass an argument { name }; you passed ${name} instead.`); | ||
} | ||
/* global HTMLElement */ | ||
if (!element || !(element instanceof HTMLElement)) { | ||
throw new Error(`getAndValidateAttribute: Pass an argument { element } that is a HTMLElement; you passed ${element} instead.`); | ||
} | ||
if (isSet) { | ||
const hasAttribute = element.hasAttribute(name); | ||
if (validate(hasAttribute) !== true) throw new Error(`getAndValidateAttribute: Attribute ${name} did not pass validation, is ${hasAttribute}: ${errorMessage}.`); | ||
return hasAttribute; | ||
} | ||
// Do not use dataset as it's slower | ||
// (https://calendar.perfplanet.com/2012/efficient-html5-data-attributes/) and provides | ||
// less flexibility (in case we don't want the data- prefix) | ||
const value = element.getAttribute(name); | ||
if (validate(value) !== true) throw new Error(`getAndValidateAttribute: Attribute ${name} did not pass validation, is ${value}: ${errorMessage}.`); | ||
return value; | ||
}; | ||
/** | ||
* Mixin for a component that announces itself by dispatching an event. If another element handles | ||
* the event, it may pass the model to the current component by calling setModel(). The model will | ||
* be stored in this.model. After model is set, this.onModelChange is called, if available. | ||
* @example | ||
* class extends HTMLElement { | ||
* constructor() { | ||
* Object.assign(this, canAnnounceElement); | ||
* } | ||
* async connectedCallback() { | ||
* await this.announce(); | ||
* // Now this.model is ready | ||
* this.model.on('change', this.update.bind(this)); | ||
* } | ||
* } | ||
*/ | ||
var canAnnounceElement = ({ eventName = 'announce-element', eventType, eventIdentifier } = {}) => { | ||
// Create a (private) promise that is resolved when the model changes (see setModel). Do not | ||
// define it within the object to not pollute its scope. | ||
let resolveModelInitializedPromise; | ||
const modelInitializedPromise = new Promise((resolve) => { | ||
resolveModelInitializedPromise = resolve; | ||
}); | ||
return { | ||
model: undefined, | ||
/** | ||
* Dispatches announce event with a short delay; returns a promise that resolves after | ||
* the event was dispatched. | ||
*/ | ||
announce() { | ||
/* global CustomEvent */ | ||
const event = new CustomEvent(eventName, { | ||
bubbles: true, | ||
detail: { | ||
element: this, | ||
eventType, | ||
eventIdentifier, | ||
}, | ||
}); | ||
// Short delay to make sure event listeners on parent elements (where the event bubbles | ||
// to) are ready | ||
setTimeout(() => { | ||
this.dispatchEvent(event); | ||
}); | ||
// Return promise that is resolved as soon as setModel is called for the first time | ||
return modelInitializedPromise; | ||
}, | ||
setModel(model) { | ||
this.model = model; | ||
resolveModelInitializedPromise(); | ||
}, | ||
}; | ||
}; | ||
/** | ||
* Adds event listener to an element and returns removeEventListener function that only needs to | ||
* be called to de-register an event. | ||
* @example | ||
* const disposer = createListener(window, 'click', () => {}); | ||
*/ | ||
var createListener = (element, eventName, handler) => { | ||
// Takes this from execution context which must be the custom element | ||
element.addEventListener(eventName, handler); | ||
return () => element.removeEventListener(eventName, handler); | ||
}; | ||
/* global HTMLElement */ | ||
/** | ||
* Button that opens or closes or toggles an overlay. Requires | ||
* attributes data-button-type (open/close/toggle) and data-overlay-name. | ||
*/ | ||
class OverlayButton extends HTMLElement { | ||
constructor() { | ||
super(); | ||
this.readAttributes(); | ||
Object.assign( | ||
this, | ||
canAnnounceElement({ eventType: 'overlay-button', eventIdentifier: this.name }), | ||
); | ||
this.setupClickListener(); | ||
} | ||
async connectedCallback() { | ||
await this.announce(); | ||
this.handleModelChanges(); | ||
this.updateDOM(); | ||
} | ||
readAttributes() { | ||
this.name = this.getName(); | ||
this.type = this.getType(); | ||
const [openClass, closedClass] = this.getClassNames(); | ||
this.openClass = openClass; | ||
this.closedClass = closedClass; | ||
} | ||
getClassNames() { | ||
return [ | ||
getAndValidateAttribute({ | ||
element: this, | ||
name: 'data-open-class-name', | ||
}), | ||
getAndValidateAttribute({ | ||
element: this, | ||
name: 'data-closed-class-name', | ||
}), | ||
]; | ||
} | ||
/** | ||
* Reads overlay name from DOM, stores it in this.name | ||
* @private | ||
*/ | ||
getName() { | ||
return getAndValidateAttribute({ | ||
element: this, | ||
name: 'data-overlay-name', | ||
validate: value => value && typeof value === 'string', | ||
}); | ||
} | ||
/** | ||
* Reads button type from DOM, stores it in this.type. Defaults to 'toggle'. | ||
* @private | ||
*/ | ||
getType() { | ||
return getAndValidateAttribute({ | ||
element: this, | ||
name: 'data-type', | ||
validate: value => !value || ['toggle', 'open', 'close'].includes(value), | ||
}) || 'toggle'; | ||
} | ||
/** | ||
* @private | ||
*/ | ||
setupClickListener() { | ||
createListener(this, 'click', this.handleClick.bind(this)); | ||
} | ||
/** | ||
* @private | ||
*/ | ||
handleClick() { | ||
this.model[this.type](); | ||
} | ||
/** | ||
* @private | ||
*/ | ||
handleModelChanges() { | ||
this.model.on('change', this.updateDOM.bind(this)); | ||
} | ||
/** | ||
* @private | ||
*/ | ||
updateDOM() { | ||
/* global requestAnimationFrame */ | ||
requestAnimationFrame(() => { | ||
if (this.model.isOpen) { | ||
this.classList.remove(this.closedClass); | ||
this.classList.add(this.openClass); | ||
} else { | ||
this.classList.remove(this.openClass); | ||
this.classList.add(this.closedClass); | ||
} | ||
}); | ||
} | ||
} | ||
/* global window */ | ||
if (!window.customElements.get('overlay-button-component')) { | ||
window.customElements.define('overlay-button-component', OverlayButton); | ||
} | ||
}()); |
@@ -1,6 +0,309 @@ | ||
import Overlay from './Overlay.js'; | ||
(function () { | ||
'use strict'; | ||
/* global window */ | ||
if (!window.customElements.get('overlay-component')) { | ||
window.customElements.define('overlay-component', Overlay); | ||
} | ||
/** | ||
* Simplifies watching attributes; pass in a config and this mixin will automatically store | ||
* attribute values in a component to reduce DOM reads and simplify validation. | ||
* IMPORTANT: We might want to use observable attributes in the future; we did not do so now, | ||
* because | ||
* a) it's hard to add the static method to he class that consumes the mixin | ||
* b) there is no JSDOM support for observable attributes, which makes testing a pain | ||
* @param {object[]} config Attribute config; each entry may consist of the following | ||
* properties: | ||
* - name (string, mandatory): Name of the attribute to watch | ||
* - validate (function, optional): Validation function; return a | ||
* falsy value if validation is not passed | ||
* - property (string, optional): Class property that the value | ||
* should be stored in; if not set, name will be used instead | ||
* - transform (function): Transforms value before it is saved as a | ||
* property | ||
*/ | ||
var canReadAttributes = (config) => { | ||
if (!config.every(item => item.name)) { | ||
throw new Error(`canReadAttribute: Every config entry must be an object with property name; you passed ${JSON.stringify(config)} instead.`); | ||
} | ||
return { | ||
readAttributes() { | ||
config.forEach((attributeConfig) => { | ||
const { | ||
name, | ||
validate, | ||
property, | ||
transform, | ||
} = attributeConfig; | ||
// Use getAttribute instead of dataset, as attribute is not guaranteed to start | ||
// with data- | ||
const value = this.getAttribute(name); | ||
if (typeof validate === 'function' && !validate(value)) { | ||
throw new Error(`canWatchAttribute: Attribute ${name} does not match validation rules`); | ||
} | ||
const transformFunction = transform || (initialValue => initialValue); | ||
const propertyName = property || name; | ||
this[propertyName] = transformFunction(value); | ||
}); | ||
}, | ||
}; | ||
}; | ||
/** | ||
* Adds event listener to an element and returns removeEventListener function that only needs to | ||
* be called to de-register an event. | ||
* @example | ||
* const disposer = createListener(window, 'click', () => {}); | ||
*/ | ||
var createListener = (element, eventName, handler) => { | ||
// Takes this from execution context which must be the custom element | ||
element.addEventListener(eventName, handler); | ||
return () => element.removeEventListener(eventName, handler); | ||
}; | ||
/** | ||
* Mixin for a component that announces itself by dispatching an event | ||
* @example | ||
* class extends HTMLElement { | ||
* constructor() { | ||
* Object.assign(this, canRegisterElements({ eventTarget: this })); | ||
* } | ||
* connectedCallback() { | ||
* this.registerAnnouncements(); | ||
* } | ||
* } | ||
* */ | ||
var canRegisterElements = ({ | ||
eventName = 'announce-element', | ||
eventTarget = window, // Does that work? | ||
eventType, | ||
eventIdentifier, | ||
model, | ||
} = {}) => ( | ||
{ | ||
registerAnnouncements() { | ||
eventTarget.addEventListener(eventName, (ev) => { | ||
const { detail } = ev; | ||
if (eventType && detail.eventType !== eventType) return; | ||
if (eventIdentifier && detail.eventIdentifier !== eventIdentifier) return; | ||
const { element } = ev.detail; | ||
if (typeof element.setModel !== 'function') { | ||
console.warn(`canRegisterElement: setModel is not a function on announcing element, but ${element.setModel}.`); | ||
} else { | ||
element.setModel(model); | ||
} | ||
}); | ||
}, | ||
} | ||
); | ||
/** | ||
* Simple EventEmitter mixin; use our own implementation as a) most NPM modules don't provide an | ||
* ES6 export and b) they're not made to be used as mixins. | ||
* Export a function for all mixins, even if not needed here (consistency). | ||
*/ | ||
var canEmitEvents = () => { | ||
return { | ||
/** | ||
* Map that holds all callbacks for all types | ||
* @type Map.<*, function[]> | ||
*/ | ||
eventHandlers: new Map(), | ||
/** | ||
* Adds event handler for a given type | ||
* @param {*} type Name of the event handler | ||
* @param {function} callback Callback to call if corresponding event is emitted | ||
*/ | ||
on(type, callback) { | ||
if (!this.eventHandlers.has(type)) this.eventHandlers.set(type, [callback]); | ||
else this.eventHandlers.get(type).push(callback); | ||
}, | ||
/** | ||
* Removes an event handler; if only type is given, all callbacks of the type will be | ||
* removed. If type and callback are given, only the specific callbacks for the given type | ||
* will be removed. | ||
* @param {*} type Type of event handler to remove | ||
* @param {function} callback Callback to remove | ||
*/ | ||
off(type, callback) { | ||
if (!this.eventHandlers.has(type)) return; | ||
if (!callback) this.eventHandlers.delete(type); | ||
else { | ||
this.eventHandlers.set( | ||
type, | ||
this.eventHandlers.get(type).filter(cb => cb !== callback), | ||
); | ||
} | ||
}, | ||
/** | ||
* Calls all callbacks of the provided type with the given parameters. | ||
* @param {*} type Type of eventHandler to call | ||
* @param {...*} params Parameters to pass to callbacks | ||
*/ | ||
emit(type, ...params) { | ||
(this.eventHandlers.get(type) || []).forEach(handler => handler(...params)); | ||
}, | ||
}; | ||
}; | ||
class OverlayModel { | ||
isOverlayOpen = false; | ||
constructor() { | ||
Object.assign(this, canEmitEvents()); | ||
} | ||
open() { | ||
// Prevent unnecessarily emitted event | ||
if (this.isOverlayOpen) return; | ||
this.isOverlayOpen = true; | ||
this.emit('change'); | ||
} | ||
close() { | ||
// Prevent unnecessarily emitted event | ||
if (!this.isOverlayOpen) return; | ||
this.isOverlayOpen = false; | ||
this.emit('change'); | ||
} | ||
toggle() { | ||
this.isOverlayOpen = !this.isOverlayOpen; | ||
this.emit('change'); | ||
} | ||
get isOpen() { | ||
return this.isOverlayOpen; | ||
} | ||
} | ||
/* global HTMLElement, window, document, CustomEvent */ | ||
/** | ||
* Overlay that is opened/closed by open/closeoverlay events. Optionally closes on esc or | ||
* click outside and always locks background (prevents scrolling). | ||
*/ | ||
class Overlay extends HTMLElement { | ||
constructor() { | ||
super(); | ||
this.model = new OverlayModel(); | ||
Object.assign( | ||
this, | ||
canReadAttributes([{ | ||
name: 'data-name', | ||
validate: value => !!value, | ||
property: 'name', | ||
}, { | ||
name: 'data-background-selector', | ||
property: 'backgroundSelector', | ||
}, { | ||
name: 'data-background-visible-class-name', | ||
property: 'backgroundVisibleClassName', | ||
}, { | ||
name: 'data-visible-class-name', | ||
validate: value => !!value, | ||
property: 'visibleClassName', | ||
}, { | ||
name: 'data-disable-esc', | ||
property: 'disableEsc', | ||
// Create bool | ||
transform: value => !!value, | ||
}, { | ||
name: 'data-disable-click-outside', | ||
property: 'disableClickOutside', | ||
transform: value => !!value, | ||
}]), | ||
canRegisterElements({ | ||
eventType: 'overlay-button', | ||
eventIdentifier: this.getAttribute('data-name'), | ||
eventTarget: window, | ||
model: this.model, | ||
}), | ||
); | ||
this.readAttributes(); | ||
this.registerAnnouncements(); | ||
this.setupModelListeners(); | ||
this.updateDOM(); | ||
} | ||
connectedCallback() { | ||
if (this.backgroundSelector) { | ||
this.background = document.querySelector(this.backgroundSelector); | ||
} | ||
} | ||
disconnectedCallback() { | ||
this.background = null; | ||
} | ||
handleKeyDown(event) { | ||
if (event.keyCode === 27 && !this.disableEsc) this.model.close(); | ||
} | ||
handleClickOutside(event) { | ||
if (this.disableClickOutside) return; | ||
const { target } = event; | ||
// Test if target is a child of overlay | ||
if (this.contains(target)) return; | ||
this.model.close(); | ||
} | ||
/** | ||
* Listens to model | ||
* @private | ||
*/ | ||
setupModelListeners() { | ||
this.model.on('change', this.updateDOM.bind(this)); | ||
} | ||
updateDOM() { | ||
window.requestAnimationFrame(() => { | ||
const visible = this.model.isOpen; | ||
if (visible) { | ||
this.classList.add(this.visibleClassName); | ||
if (this.background && this.backgroundVisibleClassName) { | ||
this.background.classList.add(this.backgroundVisibleClassName); | ||
} | ||
this.dispatchEvent(new CustomEvent('open')); | ||
} else { | ||
this.classList.remove(this.visibleClassName); | ||
if (this.background && this.backgroundVisibleClassName) { | ||
this.background.classList.remove(this.backgroundVisibleClassName); | ||
} | ||
this.dispatchEvent(new CustomEvent('close')); | ||
} | ||
}); | ||
setTimeout(() => { | ||
if (this.model.isOpen) { | ||
// Only add esc/click on open or click on open button will at the same time close | ||
// the overlay | ||
this.disconnectEsc = createListener(window, 'keydown', this.handleKeyDown.bind(this)); | ||
this.disconnectClick = createListener(window, 'click', this.handleClickOutside.bind(this)); | ||
} else { | ||
if (this.disconnectEsc) this.disconnectEsc(); | ||
if (this.disconnectClick) this.disconnectClick(); | ||
} | ||
}); | ||
} | ||
} | ||
/* global window */ | ||
if (!window.customElements.get('overlay-component')) { | ||
window.customElements.define('overlay-component', Overlay); | ||
} | ||
}()); |
{ | ||
"name": "@joinbox/overlay", | ||
"version": "1.0.1", | ||
"version": "1.0.2", | ||
"description": "Overlay component that can be opened and closed via OverlayButtons", | ||
"main": "Overlay.js", | ||
"scripts": { | ||
"test": "npx ava --verbose" | ||
"test": "npx ava --verbose", | ||
"build": "npx rollup --c rollup.config.js" | ||
}, | ||
@@ -26,3 +27,3 @@ "author": "Felix Steiner <felix@joinbox.com>", | ||
"lerna": "^4.0.0", | ||
"rollup": "^2.26.6" | ||
"rollup": "^2.50.5" | ||
}, | ||
@@ -32,3 +33,3 @@ "publishConfig": { | ||
}, | ||
"gitHead": "1c9840334b6fdac6d2f947acaa84457df104a655" | ||
"gitHead": "f39707c916e54530c32827a26639ef5a3090ecd2" | ||
} |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
46845
16
978
1