panel
Version:
Web Components with Virtual DOM: lightweight composable web apps
1,146 lines (940 loc) • 37.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
var _cuid = _interopRequireDefault(require("cuid"));
var _webcomponent = _interopRequireDefault(require("webcomponent"));
var _domPatcher = require("./dom-patcher");
var _router = _interopRequireDefault(require("./router"));
var hookHelpers = _interopRequireWildcard(require("./component-utils/hook-helpers"));
var _perf = require("./component-utils/perf");
var _shallowEqual = _interopRequireDefault(require("./component-utils/shallowEqual"));
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
const DOCUMENT_FRAGMENT_NODE = 11;
const ATTR_TYPE_DEFAULTS = {
string: ``,
boolean: false,
number: 0,
json: null
};
const PARAM_TYPES = new Set([Array, String, Boolean, Number, Object, Function, Map, Set]);
const stylesheetCache = new Map(); // key is the component constructor, value is a CSSStyleSheet instance
/**
* Definition of a Panel component/app, implemented as an HTML custom element.
* App logic and configuration is defined by extending this class. Instantiating
* a component is typically not done by calling the constructor directly, but
* either by including the tag in HTML markup, or by using the DOM API method
* [document.createElement]{@link https://developer.mozilla.org/en-US/docs/Web/API/Document/createElement}.
*
* @example <caption>Defining a Panel component</caption>
* class MyWidget extends Component {
* get config() {
* return {
* // options go here
* };
* }
*
* myMethod() {
* // etc
* }
* }
*
* @example <caption>Registering the custom element definition for the DOM</caption>
* customElements.define('my-widget', MyWidget);
*
* @example <caption>Adding an instance of the element to the DOM</caption>
* <my-widget some-attr></my-widget>
*
* @extends WebComponent
*/
class Component extends _webcomponent.default {
/**
* Defines standard component configuration.
* @type {object}
* @property {function} template - function transforming state object to virtual dom tree
* @property {object} [helpers={}] - properties and functions injected automatically into template state object
* @property {object} [routes={}] - object mapping string route expressions to handler functions
* @property {object} [appState={}] - (app root component only) state object to share with nested descendant components;
* if not set, root component shares entire state object with all descendants
* @property {object} [defaultState={}] - default entries for component state
* @property {object} [hooks={}] - extra rendering/lifecycle callbacks
* @property {function} [hooks.preUpdate] - called before an update is applied
* @property {function} [hooks.postUpdate] - called after an update is applied
* @property {boolean} [updateSync=false] - whether to apply updates to DOM
* immediately, instead of batching to one update per frame
* @property {boolean} [useShadowDom=false] - whether to use Shadow DOM
* @property {string} [css=''] - component-specific Shadow DOM stylesheet
* @example
* get config() {
* return {
* template: state => h('.name', `My name is ${name}`),
* routes: {
* 'wombat/:wombatId': (stateUpdate={}, wombatId) => {
* // route handler implementation
* },
* },
* };
* }
*/
get config() {
return {};
}
/**
* Template helper functions defined in config object, and exposed to template code
* as $helpers. This getter uses the component's internal config cache.
* @type {object}
* @example
* {
* myHelper: () => 'some return value',
* }
*/
get helpers() {
return this.getConfig(`helpers`);
}
/**
* For use inside view templates, to create a child Panel component nested under this
* component, which will share its state object and update cycle.
* @param {string} tagName - the HTML element tag name of the custom element
* to be created
* @param {object} [config={}] - snabbdom node config (second argument of h())
* @returns {object} snabbdom vnode
* @example
* {template: state => h('.header', this.child('my-child-widget'))}
*/
child(tagName) {
let config = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
config.props = Object.assign({}, config.props, {
$panelParentID: this.panelID
});
return (0, _domPatcher.h)(tagName, config);
}
/**
* Searches the component's Panel ancestors for the first component of the
* given type (HTML tag name).
* @param {string} tagName - tag name of the parent to search for
* @returns {object} Panel component
* @throws Throws an error if no parent component with the given tag name is found.
* @example
* myWidget.findPanelParentByTagName('my-app');
*/
findPanelParentByTagName(tagName) {
tagName = tagName.toLowerCase();
for (let node = this.$panelParent; node; node = node.$panelParent) {
if (node.tagName.toLowerCase() === tagName) {
return node;
}
}
throw Error(`${tagName} not found`);
}
/**
* Fetches a value from the component's configuration map (a combination of
* values supplied in the config() getter and defaults applied automatically).
* @param {string} key - key of config item to fetch
* @returns value associated with key
* @example
* myWidget.getConfig('css');
*/
getConfig(key) {
return this._config[key];
}
/**
* Executes the route handler matching the given URL fragment, and updates
* the URL, as though the user had navigated explicitly to that address.
* @param {string} fragment - URL fragment to navigate to
* @param {object} [stateUpdate={}] - update to apply to state object when
* routing
* @example
* myApp.navigate('wombat/54', {color: 'blue'});
*/
navigate() {
this.$panelRoot.router.navigate(...arguments);
}
/**
* Helper function which will queue a function to be run once the component has been
* initialized and added to the DOM. If the component has already had its connectedCallback
* run, the function will run immediately.
*
* It can optionally return a function to be enqueued to be run just before the component is
* removed from the DOM. This occurs during the disconnectedCallback lifecycle.
* @param {function} fn - callback to be run after the component has been added to the DOM. If this
* callback returns another function, the returned function will be run when the component disconnects from the DOM.
* @example
* myApp.onConnected(() => {
* const handleResize = () => calculateSize();
* document.body.addEventListener(`resize`, handleResize);
* return () => document.body.removeEventListener(`resize`, handleResize);
* });
*/
onConnected(fn) {
if (this.initialized) {
this._maybeEnqueueResult(fn.call(this));
}
this._connectedQueue.push(fn);
}
_maybeEnqueueResult(result) {
if (result && typeof result === `function`) {
result.removeAfterExec = true;
this._disconnectedQueue.push(result);
}
}
/**
* Helper function which will queue a function to be run just before the component is
* removed from the DOM. This occurs during the disconnectedCallback lifecycle.
*
* @param {function} fn - callback to be run just before the component is removed from the DOM
* @example
* connectedCallback() {
* const shiftKeyListener = () => {
* if (ev.keyCode === SHIFT_KEY_CODE) {
* const doingRangeSelect = ev.type === `keydown` && this.isMouseOver && this.lastSelectedRowIdx !== null;
* if (this.state.doingRangeSelect !== doingRangeSelect) {
* this.update({doingRangeSelect});
* }
* }
* }
* document.body.addEventListener(`keydown`, shiftKeyListener);
* this.onDisconnected(() => {
* document.body.removeEventListener(`keydown`, shiftKeyListener);
* });
* }
*/
onDisconnected(fn) {
this._disconnectedQueue.push(fn);
}
/**
* Sets a value in the component's configuration map after element
* initialization.
* @param {string} key - key of config item to set
* @param val - value to associate with key
* @example
* myWidget.setConfig('template', () => h('.new-template', 'Hi'));
*/
setConfig(key, val) {
this._config[key] = val;
}
/**
* To be overridden by subclasses, defining conditional logic for whether
* a component should rerender its template given the state to be applied.
* In most cases this method can be left untouched, but can provide improved
* performance when dealing with very many DOM elements.
*
* @deprecated use shouldComponentUpdate instead
* @param {object} state - state object to be used when rendering
* @returns {boolean} whether or not to render/update this component
* @example
* shouldUpdate(state) {
* // don't need to rerender if result set ID hasn't changed
* return state.largeResultSetID !== this._cachedResultID;
* }
*/
// eslint-disable-next-line no-unused-vars
shouldUpdate(state) {
return true;
}
/**
*
* Same API as react's `shouldComponentUpdate` usage
* if child component implements this method, parent implmentation wil be discarded
* NOTE: never call `super` in child `shouldComponentUpdate`
*
* there a slight difference with react: `params` or `state` could sometimes be null indicating that
* the update is not related to `params` or `state`
*
* @param {object} params - new params object to be used when rendering
* @param {object} state - state object to be used when rendering
* @return {boolean}
* @example
* shouldComponentUpdate(params, state) {
* if (params.bookmark.id === this.params.bookmark.id) {
* return false;
* }
* return !shallowEqual(params, this.params);
* }
*/
shouldComponentUpdate(params, state) {
if (params) {
return !(0, _shallowEqual.default)(params, this.params);
}
return this.shouldUpdate(state);
}
/**
* Applies a state update, triggering a re-render check of the component as
* well as any other components sharing the same state. This is the primary
* means of updating the DOM in a Panel application.
* @param {object|function} [stateUpdate={}] - keys and values of entries to update in
* the component's state object
* @example
* myWidget.update({name: 'Bob'});
*/
update() {
let stateUpdate = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
this.timings.lastUpdateAt = _perf.Perf.getNow();
const stateUpdateResult = typeof stateUpdate === `function` ? stateUpdate(this.state) : stateUpdate;
return this._updateStore(stateUpdateResult, {
store: `state`,
cascade: this.isStateShared
});
}
/**
* Applies a state update specifically to app state shared across components.
* In apps which don't specify `appState` in the root component config, all
* state is shared across all parent and child components and the standard
* update() method should be used instead.
* @param {object} [stateUpdate={}] - keys and values of entries to update in
* the app's appState object
* @example
* myWidget.updateApp({name: 'Bob'});
*/
updateApp() {
let stateUpdate = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
return this._updateStore(stateUpdate, {
store: `appState`,
cascade: true
});
}
constructor() {
super();
this.timings = {
createdAt: _perf.Perf.getNow()
};
this.panelID = (0, _cuid.default)();
this._connectedQueue = [];
this._disconnectedQueue = [];
this._attrs = {};
this._syncAttrs(); // constructor sync ensures default properties are present on this._attrs
this._config = Object.assign({}, {
css: ``,
params: {},
defaultParams: {},
defaultContexts: {},
contexts: [],
helpers: {},
routes: {},
template: () => {
throw Error(`No template provided by Component subclass`);
},
updateSync: false,
useShadowDom: false,
slowThreshold: 20
}, this.config);
this._initializeParams();
this._contexts = new Set(this.getConfig(`contexts`)); // initialize shared state store, either in `appState` or default to `state`
// appState and isStateShared of child components will be overwritten by parent/root
// when the component is connected to the hierarchy
this.state = Object.assign({}, this.getConfig(`defaultState`));
this.appState = this.getConfig(`appState`);
if (!this.appState) {
this.appState = {};
this.isStateShared = true;
} else {
this.isStateShared = false;
}
if (this.getConfig(`useShadowDom`)) {
this.el = this.attachShadow({
mode: `open`
});
this.applyStaticStyle(this.getConfig(`css`));
} else if (this.getConfig(`css`)) {
throw Error(`"useShadowDom" config option must be set in order to use "css" config.`);
} else {
this.el = this;
}
this.postRenderCallback = elapsedMs => {
this.timings.lastRenderAt = _perf.Perf.getNow();
if (elapsedMs > this.getConfig(`slowThreshold`)) {
const shouldBroadcast = !this.lastSlowRender || // SHOULD because we've never slow rendered
this.lastSlowRender.time - _perf.Perf.getNow() > 3000 || // SHOULD because last time was more than three seconds ago
elapsedMs > (this.slowestRenderMs || 0); // SHOULD because this time is slower
if (shouldBroadcast) {
const comparedToLast = this.lastSlowRender ? {
// bit of a hack to get the number to only 2 digits of precision
comparedToLast: +((elapsedMs - this.lastSlowRender.elapsedMs) / this.lastSlowRender.elapsedMs).toFixed(2),
comparedToSlowest: +((elapsedMs - this.slowestRenderMs) / this.slowestRenderMs).toFixed(2)
} : undefined;
this.lastSlowRender = {
time: _perf.Perf.getNow(),
elapsedMs
};
this.slowestRenderMs = Math.max(this.slowestRenderMs || 0, elapsedMs);
this.dispatchEvent(new CustomEvent(`slowRender`, {
detail: Object.assign(comparedToLast || {}, {
elapsedMs,
component: this.toString()
}),
bubbles: true,
composed: true
}));
}
}
};
}
connectedCallback() {
if (this.initialized) {
return;
} // Prevent re-entrant calls to connectedCallback.
// This can happen in some (probably erroneous) cases with Firefox+polyfills.
if (this.initializing) {
return;
}
this.initializing = true;
this.timings.initializingStartedAt = _perf.Perf.getNow();
for (const attrsSchemaKey of Object.keys(this._attrsSchema)) {
if (!Object.prototype.hasOwnProperty.call(this._attrs, attrsSchemaKey) && this._attrsSchema[attrsSchemaKey].required) {
throw new Error(`${this}: is missing required attr '${attrsSchemaKey}'`);
}
}
this.$panelChildren = new Set();
if (typeof this.$panelParentID !== `undefined`) {
this.isPanelChild = true; // find $panelParent
for (let node = this.parentNode; node && !this.$panelParent; node = node.parentNode) {
if (node.nodeType === DOCUMENT_FRAGMENT_NODE) {
// handle shadow-root
node = node.host;
}
if (node.panelID === this.$panelParentID) {
this.$panelParent = node;
this.$panelRoot = node.$panelRoot;
}
}
if (!this.$panelParent) {
throw Error(`panelParent ${this.$panelParentID} not found`);
}
this.$panelParent.$panelChildren.add(this); // share either appState or all of state
// flush any queued appState changes
this.appState = Object.assign(this.$panelRoot.appState, this.appState); // if child element state is shared, point
// state to parent's state object and flush any
// queued state changes to the parent state
this.isStateShared = this.$panelRoot.isStateShared;
if (this.isStateShared) {
this.state = Object.assign(this.$panelRoot.state, this.state);
}
} else {
this.isPanelRoot = true;
this.$panelRoot = this;
this.$panelParent = null;
}
this.app = this.$panelRoot;
Object.assign(this.state, this.getJSONAttribute(`data-state`), this._stateFromAttributes());
if (Object.keys(this.getConfig(`routes`)).length) {
this.router = new _router.default(this, {
historyMethod: this.historyMethod
});
this.navigate(window.location.hash);
}
for (const contextName of this.getConfig(`contexts`)) {
const context = this.getContext(contextName); // Context classes can implement an optional `bindToComponent` callback that executes each time the component is connected to the DOM
if (context.bindToComponent) {
context.bindToComponent(this);
}
}
this.dispatchEvent(new CustomEvent(`preComponentInitialized`, {
detail: {
el: this
},
bubbles: true,
composed: true
}));
this.domPatcher = new _domPatcher.DOMPatcher(this.state, this._render.bind(this), {
updateMode: this.getConfig(`updateSync`) ? `sync` : `async`,
postRenderCallback: this.postRenderCallback
});
this.el.appendChild(this.domPatcher.el);
for (let i = 0; i < this._connectedQueue.length; i++) {
const connectedCallbackFn = this._connectedQueue[i];
try {
this._maybeEnqueueResult(connectedCallbackFn.call(this));
} catch (err) {
console.warn(`error running onConnected function`, err);
}
}
this.initialized = true;
this.initializing = false;
this.timings.initializingCompletedAt = _perf.Perf.getNow();
this.dispatchEvent(new CustomEvent(`componentInitialized`, {
detail: {
elapsedMs: this.timings.initializingCompletedAt - this.timings.initializingStartedAt,
component: this.toString()
},
bubbles: true,
composed: true
}));
}
disconnectedCallback() {
if (!this.initialized) {
return;
}
for (let i = 0; i < this._disconnectedQueue.length; i++) {
const disconnectedCallbackFn = this._disconnectedQueue[i];
try {
disconnectedCallbackFn.call(this);
} catch (err) {
console.warn(`error running onDisconnected function`, err);
}
}
this._disconnectedQueue = this._disconnectedQueue.filter(fn => !fn.removeAfterExec);
for (const contextName of this.getConfig(`contexts`)) {
const context = this.getContext(contextName); // Context classes can implement an optional `unbindFromComponent` callback that executes each time the component is disconnected from the DOM
if (context.unbindFromComponent) {
context.unbindFromComponent(this);
}
}
if (this.router) {
this.router.unregisterListeners();
}
if (this.$panelParent) {
this.$panelParent.$panelChildren.delete(this);
}
if (this.domPatcher) {
this.el.removeChild(this.domPatcher.el);
this.domPatcher.disconnect();
}
this.domPatcher = null;
this._rendered = null;
this.initialized = false; // if a child component is added via child() and has keys, snabbdom uses parentEl.insertBefore
// which disconnects the element and immediately connects it at another position.
// usually the child's disconnectedCallback is called before the parent's
// but in that case the parents are removed from dom before the children
// which causes a $panelParent not found exception for the grandchildren.
// we clean up parent references in an async manner so we can handle that situation.
Promise.resolve().then(() => {
// only clear references if element hasn't been re-initialized
if (!this.initialized) {
this.$panelRoot = null;
this.$panelParent = null;
this.appState = null;
this.app = null;
}
});
}
/**
* Attributes schema that defines the component's html attributes and their types
* Panel auto parses attribute changes into attrs() object and $attr template helper
*
* @typedef {object} AttrSchema
* @prop {'string' | 'number' | 'boolean' | 'json'} type - type of the attribute
* if not set, the attr parser will interpret it as 'string'
* @prop {string} default - value if the attr is not defined
* @prop {number} description - description of the attribute, what it does e.t.c
*
* @type {Object.<string, AttrSchema>}
*/
static get attrsSchema() {
return {};
}
static get observedAttributes() {
return [`style-override`].concat(Object.keys(this.attrsSchema));
}
attributeChangedCallback(attr, oldVal, newVal) {
this.timings.lastAttributeChangedAt = _perf.Perf.getNow();
this._updateAttr(attr);
if (attr === `style-override`) {
this._applyStyleOverride(newVal);
}
if (this.initialized) {
this.update();
}
}
applyStaticStyle(styleSheetText) {
let {
ignoreCache = false
} = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
if (styleSheetText) {
if (this.el.adoptedStyleSheets) {
// Attempt to cache the styles using Constructible StyleSheets if the feature is supported.
// Note: this technique avoids the Flash of Unstyled Content that alternative approaches like <link> tags will encounter
const componentKey = this.constructor;
let cachedStyleSheet = stylesheetCache.get(componentKey);
if (!cachedStyleSheet) {
cachedStyleSheet = new CSSStyleSheet();
cachedStyleSheet.replaceSync(styleSheetText);
stylesheetCache.set(componentKey, cachedStyleSheet);
} else if (ignoreCache) {
cachedStyleSheet.replaceSync(styleSheetText);
}
if (!this.staticStyleSheet) {
this.staticStyleSheet = cachedStyleSheet;
this.el.adoptedStyleSheets = [this.staticStyleSheet, ...this.el.adoptedStyleSheets.slice(1)];
}
} else {
if (!this.staticStyleTag) {
this.staticStyleTag = document.createElement(`style`);
this.el.insertBefore(this.staticStyleTag, this.el.childNodes[0] || null);
}
this.staticStyleTag.innerHTML = styleSheetText;
}
}
}
_applyStyleOverride(styleOverride) {
if (this.getConfig(`useShadowDom`)) {
if (this.el.adoptedStyleSheets) {
if (!this.styleOverrideStyleSheet) {
this.styleOverrideStyleSheet = new CSSStyleSheet();
this.el.adoptedStyleSheets = this.el.adoptedStyleSheets.concat(this.styleOverrideStyleSheet);
}
this.styleOverrideStyleSheet.replaceSync(styleOverride || ``);
} else {
if (!this.styleOverrideTag) {
this.styleOverrideTag = document.createElement(`style`);
this.el.appendChild(this.styleOverrideTag);
}
this.styleOverrideTag.innerHTML = styleOverride || ``;
}
}
}
_logError() {
console.error(...arguments);
}
toString() {
try {
return `${(this.tagName || ``).toLowerCase()}#${this.panelID}`;
} catch (e) {
return `UNKNOWN COMPONENT`;
}
}
_render(state) {
if (this.shouldComponentUpdate(null, state)) {
try {
this._rendered = this.getConfig(`template`).call(this, Object.assign({}, state, {
$app: this.appState,
$component: this,
$helpers: this.helpers,
$attr: this.attr.bind(this),
$hooks: hookHelpers
}));
} catch (error) {
this._logError(`Error while rendering`, this, `\n`, error);
this.dispatchEvent(new CustomEvent(`renderError`, {
detail: {
error,
component: this
},
bubbles: true,
composed: true
}));
}
}
return this._rendered || _domPatcher.EMPTY_DIV;
} // run a user-defined hook with the given params, if configured
// cascade down tree hierarchy if option is set
runHook(hookName, options) {
if (!this.initialized) {
return;
}
const hook = (this.getConfig(`hooks`) || {})[hookName];
for (var _len = arguments.length, params = new Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) {
params[_key - 2] = arguments[_key];
}
if (hook) {
hook(...params);
}
if (options.cascade) {
for (const child of this.$panelChildren) {
if (options.exclude !== child) {
child.runHook(hookName, options, ...params);
}
}
}
}
_stateFromAttributes() {
const state = {}; // this.attributes is a NamedNodeMap, without normal iterators
for (let ai = 0; ai < this.attributes.length; ai++) {
const attr = this.attributes[ai];
const attrMatch = attr.name.match(/^state-(.+)/);
if (attrMatch) {
const num = Number(attr.value);
state[attrMatch[1]] = isNaN(num) ? attr.value : num;
}
}
return state;
}
/**
* Validates attrsSchema and syncs element attributes defined in attrsSchema
*/
_syncAttrs() {
// maintain local validated map where all schema keys are defined
this._attrsSchema = {};
const attrsSchema = this.constructor.attrsSchema;
for (const attr of Object.keys(attrsSchema)) {
// convert type shorthand to object
let attrSchema = attrsSchema[attr];
if (typeof attrSchema === `string`) {
attrSchema = {
type: attrSchema
};
} // Ensure attr type is valid
const attrType = attrSchema.type;
if (!ATTR_TYPE_DEFAULTS.hasOwnProperty(attrType)) {
throw new Error(`Invalid type: ${attrType} for attr: ${attr} in attrsSchema. ` + `Only (${Object.keys(ATTR_TYPE_DEFAULTS).map(v => `'${v}'`).join(` | `)}) is valid.`);
}
if (attrSchema.default && attrSchema.required) {
throw new Error(`${this}: attr '${attr}' cannot have both required and default`);
}
const attrSchemaObj = {
type: attrType,
default: attrSchema.hasOwnProperty(`default`) ? attrSchema.default : ATTR_TYPE_DEFAULTS[attrType],
required: attrSchema.hasOwnProperty(`required`) ? attrSchema.required : false
}; // convert enum to a set for perf
if (attrSchema.hasOwnProperty(`enum`)) {
const attrEnum = attrSchema.enum;
if (!Array.isArray(attrEnum)) {
throw new Error(`Enum not an array for attr: ${attr}`);
}
const enumSet = new Set(attrEnum);
enumSet.add(attrSchema.default);
attrSchemaObj.enumSet = enumSet;
}
this._attrsSchema[attr] = attrSchemaObj;
this._updateAttr(attr); // updated at end so we don't console.warn on initial sync
attrSchemaObj.deprecatedMsg = attrSchema.deprecatedMsg;
}
return this._attrs;
}
/**
* Parses html attribute using type information from attrsSchema and updates this._attrs
* @param {string} attr - attribute name
*/
_updateAttr(attr) {
const attrsSchema = this._attrsSchema;
if (attrsSchema.hasOwnProperty(attr)) {
const attrSchema = attrsSchema[attr];
const attrType = attrSchema.type;
let attrValue = null;
if (attrSchema.deprecatedMsg) {
console.warn(`${this}: attr '${attr}' is deprecated. ${attrSchema.deprecatedMsg}`);
}
if (!this.hasAttribute(attr)) {
if (attrType === `boolean` && (attrSchema.default || attrSchema.required)) {
throw new Error(`${this}: boolean attr '${attr}' cannot have required or default, since its value is derived from whether dom element has the attribute, not its value`);
}
if (attrSchema.required) {
// Early return because a required attribute has no explicit value
return;
}
attrValue = attrSchema.default;
} else if (attrType === `string`) {
attrValue = this.getAttribute(attr);
const enumSet = attrSchema.enumSet;
if (enumSet && !enumSet.has(attrValue)) {
throw new Error(`Invalid value: '${attrValue}' for attr: ${attr}. ` + `Only (${Array.from(enumSet).map(v => `'${v}'`).join(` | `)}) is valid.`);
}
} else if (attrType === `boolean`) {
attrValue = this.isAttributeEnabled(attr);
} else if (attrType === `number`) {
attrValue = this.getNumberAttribute(attr);
} else if (attrType === `json`) {
attrValue = this.getJSONAttribute(attr);
}
this._attrs[attr] = attrValue;
}
}
/**
* gets the parsed value of an attribute
* @param {string} attr - attribute name
*/
attr(attr) {
if (attr in this._attrs) {
return this._attrs[attr];
} else {
throw new TypeError(`${this}: attr '${attr}' is not defined in attrsSchema`);
}
}
/**
* Returns the parsed attrs as a key-value POJO
* @returns {object} parsed attribute values from attrsSchema
*/
attrs() {
return this._attrs;
}
/**
* parse and validate config.params and create a param schema on the component
*/
_initializeParams() {
// the real value for the params
this._params = {}; // maintain local validated map where all schema keys are defined
this._paramSchemas = {};
const paramSchemas = this.getConfig(`params`);
const defaultParams = this.getConfig(`defaultParams`);
for (let [paramName, paramSchema] of Object.entries(paramSchemas)) {
// convert type shorthand to object
if (!paramSchema.type) {
paramSchema = {
type: paramSchema
};
} // Ensure param type is valid
const type = paramSchema.type;
if (!PARAM_TYPES.has(type)) {
const typeString = typeof type === `function` ? type.name : String(type);
throw new Error(`Invalid type: ${typeString} for param: ${paramName} in paramSchema. ` + `Only (${Array.from(PARAM_TYPES.keys()).map(v => `'${v.name}'`).join(` | `)}) is valid.`);
}
const paramSchemaObj = {
type,
required: Boolean(paramSchema.required),
default: defaultParams[paramName]
}; // set default value for the params
this._params[paramName] = paramSchemaObj.default;
this._paramSchemas[paramName] = paramSchemaObj;
}
Object.freeze(this._params);
return this._paramSchemas;
}
get params() {
return this._params;
}
setParams(params) {
const shouldComponentUpdate = this.shouldComponentUpdate(params, this.state);
const updateOptions = {
cascade: false
}; // no extra params allowed if not defined in schema
for (const paramName of Object.keys(params)) {
if (!this._paramSchemas[paramName]) {
throw new Error(`extra param '${paramName}' on ${this.constructor.name} is not defined in schema`);
}
}
for (const [paramName, paramSchema] of Object.entries(this._paramSchemas)) {
if (paramName === `styleOverride`) {
this._applyStyleOverride(params[paramName]);
continue;
} // if param defined on schema and marked required, the key must be presented on the params
if (!params.hasOwnProperty(paramName) && paramSchema.required) {
throw new Error(`param '${paramName}' on ${this.constructor.name} is defined as required param in schema but absent on component definition`);
}
const paramValue = params[paramName]; // set default value if undefined value passed in
if (paramSchema.default !== undefined && paramValue === undefined) {
params[paramName] = paramSchema.default;
}
}
const newParams = Object.freeze(Object.assign({}, params));
if (this.initialized && shouldComponentUpdate) {
this.runHook(`preUpdate`, updateOptions, null, newParams);
}
this._params = newParams;
if (this.initialized && shouldComponentUpdate) {
this.domPatcher.update(this.state);
this.runHook(`postUpdate`, updateOptions, null, newParams);
}
} // update helpers
// Update a given state store (this.state or this.appState), with option
// to 'cascade' the update across other linked components
_updateStore(stateUpdate) {
let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
const {
cascade,
store
} = options;
if (!this.initialized) {
// just update store without patching DOM etc
Object.assign(this[store], stateUpdate);
} else {
// update DOM, router, descendants etc.
const updateHash = `$fragment` in stateUpdate && stateUpdate.$fragment !== this[store].$fragment;
const cascadeFromRoot = cascade && !this.isPanelRoot;
const updateOptions = {
cascade,
store
};
const rootOptions = {
exclude: this,
cascade,
store
};
this.runHook(`preUpdate`, updateOptions, stateUpdate);
if (cascadeFromRoot) {
this.$panelRoot.runHook(`preUpdate`, rootOptions, stateUpdate);
}
this.updateSelfAndChildren(stateUpdate, updateOptions);
if (cascadeFromRoot) {
this.$panelRoot.updateSelfAndChildren(stateUpdate, rootOptions);
}
if (updateHash) {
this.router.replaceHash(this[store].$fragment);
}
this.runHook(`postUpdate`, updateOptions, stateUpdate);
if (cascadeFromRoot) {
this.$panelRoot.runHook(`postUpdate`, rootOptions, stateUpdate);
}
}
} // Apply the given update down the component hierarchy from this node,
// optionally excluding one node's subtree. This is useful for applying
// a full state update to one component while sending only "shared" state
// updates to the app root.
updateSelfAndChildren(stateUpdate) {
let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
if (!this.initialized) {
return;
}
const {
store,
cascade
} = options;
Object.assign(this[store], stateUpdate);
if (store !== `state` || this.shouldComponentUpdate(null, this[store])) {
this.domPatcher.update(this.state);
if (cascade) {
for (const child of this.$panelChildren) {
if (options.exclude !== child) {
child.updateSelfAndChildren(stateUpdate, options);
}
}
}
}
}
_findNearestContextAncestor() {
if (!this.isConnected) {
throw new Error(`Cannot determine context before component is connected to the DOM`);
}
let node = this.parentNode;
while (node) {
if (node._getAvailableContexts) {
return node;
}
if (node.nodeType === DOCUMENT_FRAGMENT_NODE) {
// handle shadow-root
node = node.host;
} else {
node = node.parentNode;
}
}
return null;
}
_findAndMergeContextsFromAncestors() {
const contextAncestor = this._findNearestContextAncestor();
const defaultContexts = Object.assign({}, this.getConfig(`defaultContexts`));
if (contextAncestor) {
// ancestor contexts must override locally defined defaults
return Object.assign(defaultContexts, contextAncestor._getAvailableContexts());
}
return defaultContexts;
}
_getAvailableContexts() {
if (!this._cachedContexts) {
this._cachedContexts = this._findAndMergeContextsFromAncestors();
}
return this._cachedContexts;
}
/**
* Returns the default context of the highest (ie. closest to the document root) ancestor component
* that has configured a default context for the context name. If no ancestor context is found, it will
* return the component's own default context.
*
* @param {string} contextName - name of context
* @returns {object} context object
*/
getContext(contextName) {
if (!contextName) {
throw new Error(` is null or empty`);
}
if (!this._contexts.has(contextName)) {
throw new Error(` must be declared in the "contexts" config array`);
}
const availableContexts = this._getAvailableContexts();
if (!(contextName in availableContexts)) {
throw new Error(`A "${contextName}" context is not available. Check that this component or a DOM ancestor has provided this context in its "defaultContexts" Panel config.`);
}
return availableContexts[contextName];
}
}
var _default = Component;
exports.default = _default;