@janiking/base-component
v2.4.12
Published
A base for more powerful web components
Downloads
316
Readme
BaseComponent
A base for more powerful web components
Description
Use BaseComponent as a way to make developing web components faster and easier.
A good resource to learn about web components is Google Developers
To Install
yarn add @janiking/base-component
You may also use npm
if you prefer.
Adding to a Project
import BaseComponent from '@janiking/base-component';
class MyWidget extends BaseComponent {
// your code here
}
customElements.define('my-widget', MyWidget);
Browser and ES Version Support
BaseComponent works out of the box with Chrome.
Using polyfills, this will work in all modern browsers including IE11. It might work in IE10 but it's not tested.
Custom elements use ES6 classes, so that is how this library is written, and how your code should be written.
Docs
Basic element creation and usage:
// create
class MyCustom extends BaseComponent {
get templateString () {
return `<div>This is MyCustom</div>`;
}
}
customElements.define('my-custom', MyCustom);
// In your HTML:
<my-custom></my-custom>
// programmatic usage:
var element = document.createElement('my-custom');
If using janiking/dom you could use shorthand:
dom('my-custom', {}, parentNode);
Lifecycle
BaseComponent follows the v1 spec for lifecycle methods under the hood, and exposes them via shorthand methods:
connectedCallback()
->connected()
disconnectedCallback()
->disconnected()
- a
ttributeChangedCallback()
->attributeChanged()
Note that connected and disconnected (as well as their under-the-hood callers) are not very useful, since they are called multiple times if the element is added and removed multiple times from the document, as some frameworks tend to do. Because of this, BaseComponent provides additional lifecycle methods:
domReady()
destroy()
connected
connected
is called after the node is built. Note that it may not be attached to the document yet (and therefore not have a parentNode) and any children may not yet be added or built.
domReady
domReady
is called after the following criteria has been met:
- Element is attached to the document
- An asynchronous amount of time has passed to allow for children to be added programmatically
- The element's children are in a 'domready' state
domReady
has to be triggered asynchronously because of the following:
var element = dom('my-parent', {}, document.body);
var child = dom('my-child', {}, element);
In that scenario, connected
will be called synchronously before the child has been added. Typically an element needs to
know about its children in order to initialize its structure. This setup can be done in domReady
, which is called after a
requestAnimationFrame
.
domReady
is guaranteed to only be called once in a custom element's lifecycle.
destroy
destroy
is not called automatically, it must be explicitly called. Under the hood, all eventListeners will be
disconnected. In your code, other cleanup can be done, like destroying child custom elements.
You can set a global static property, BaseComponent.destroyOnDisconnect
, which will can destroy
automatically for you
when the element is removed from the document. The reason this optional is because some frameworks remove elements but keep
them in memory to be added again later. destroyOnDisconnect
will fire destroy
after a default of 300 ms (or whatever you
set destroyOnDisconnect
) to give you time to move elements around.
Handling Asynchronous lifecycle
Because a majority of setup happens in domReady
, there needs to be a way to know when the element is done setting up.
Ideally it could be done like this:
var element = dom('my-custom', {}, document.body);
element.on('domready', function () {
// continue work here
});
However, that does not always work with the custom-elements-polyfill in browsers outside of Chrome. Due to the limitations of
the shim, element hydration (moving from UnknownElement to a custom element with lifecycle methods) happens asynchronously,
and helper methods like element.on
were not been added immediately. This could be solved without the shorthand:
var element = dom('my-custom', {}, document.body);
element.addEventListener('domready', function () {
// can continue work here
});
Or the convenience function (inserted globally from BaseComponent) can be used:
var element = dom('my-custom', {}, document.body);
onDomReady(element, function (element) {
// can continue work here
});
onDomReady
also works with a list of nodes:
var n1 = dom('my-custom', {}, document.body);
var n2 = dom('my-custom', {}, document.body);
var n3 = dom('my-custom', {}, document.body);
onDomReady([n1,n2,n3], function (nodes) {
// can continue work here
});
The benefit of onDomReady
over element.addEventListener
is that if the element is already in the domready
state the callback will still fire. Also, the event listener is cleaned up under the hood, while using
element.addEventListener
leaves that up to you.
Event Handling
BaseComponent uses the janiking/on library to handle events. To add even more power
to custom elements, on
is included, and its context set to itself. For example:
myCustomElement.on('click', function (event) {
// handle click
});
this.on('click', (e) => {
this.myMethod();
});
The power happens by functionality that remembers the events, and when destroy()
is called, they are all removed. So
all event cleanup is a matter of calling destroy()
.
While context defaults to the element itself, you can optionally specify a different element (or window in this case):
myCustomElement.on(window, 'resize', function (event) {
// handle resize
});
You can also use the once
feature:
myCustomElement.once(img, 'load', function (event) {
// handle image loading
// this event will never fire again
});
Also mixed into the custom element are on
's emit
and fire
methods. Typically, emit
is for standard events, and
fire
is for custom events.
this.emit('change', {value: this.value});
this.fire('closed');
Plugins
BaseComponent
uses a plugin architecture, which not only helps keep the code clean and maintainable, it allows for
flexibility.
template plugin
The template plugin allows for the association of HTML, via a templateId
property, with a custom element. The template
can be created in a template element, which is not
exposed to the document until it is cloned.
<template id="my-custom-template">
<div>This will be inserted into the custom element</div>
</template>
class TestTmplId extends BaseComponent {
get templateId () {
return 'test-tmpl-id-template';
}
}
Alternatively, an HTML string can be used with the templateString
property:
class TestTmplId extends BaseComponent {
get templateString () {
return '<div>my-custom-template</div>';
}
}
refs plugin
The refs plugin allows for ref
attributes to be used in the template as shortcuts for properties. The value of the
ref
attribute will be added as a property in the node and assigned the value of the node that contained the attribute.
<template id="my-custom-template">
<div ref="coolNode">Cool</div>
<div ref="uncoolNode">Uncool</div>
</template>
class TestTmplId extends BaseComponent {
get templateId () {
return 'test-tmpl-id-template';
}
domReady () {
console.log(this.coolNode.innerHTML); // Cool
console.log(this.uncoolNode.innerHTML); // Uncool
}
}
To associate events, use an on
attribute, with a colon-delineated event-method pair:
<template id="my-custom-template">
<div on="click:onClick">Cool</div>
<div on="change:onChange">Uncool</div>
</template>
class TestTmplId extends BaseComponent {
get templateId () {
return 'test-tmpl-id-template';
}
onClick (event) {}
onChange (event) {}
}
properties plugin
The properties
plugin is used to reduce redundancy on getter/setters. The spec
is designed to make it easy to sync properties with attributes; but in doing so, the result is a get
and set
for
every property that only sets or returns its corresponding attribute.
Using the properties
plugin, and adding a props
and/or a bool
array that is the same or a subset of the observedAttributes
array
will automatically add those getters and setters.
class TestProps extends BaseComponent {
domReady () {
console.log(this.disabled);
console.log(this.foo);
}
}
BaseComponent.injectProps(TestProps, {
props: ['foo', 'bar'],
bools: ['disabled', 'readonly'],
attrs: ['value'] // to be handled manually with attributeChanged()
});
The way to do it in version 2.1.0 and older:
class TestProps extends BaseComponent {
static get observedAttributes() { return ['foo', 'bar', 'disabled', 'readonly']; }
get props () { return ['foo', 'bar']; }
get bools () { return ['disabled', 'readonly']; }
domReady () {
console.log(this.disabled);
console.log(this.foo);
}
}
A dynamic callback is generated and can be used if an operation needs to occur on an attribute or property
change. When foo
changes. onFoo
is fired, passing the value.
Because a majority of the time, properties are used to change the DOM, the dynamic callback is fired using onDomReady.
If there is a return
in the callback, that will become the new property - with the caveat that it breaks the sync
between the attribute and the property. Note this only works with props
, not with bools
.
class MyCustom extends BaseComponent {
onFoo (value) {
console.log('foo:', value); // 10
return value * 0.1; // this.foo is now 1 but this.getAttribute('foo') is still 10
}
}
customElements.define('my-custom', MyCustom);
<my-custom foo="10" />
There is a props
and a bool
array:
props
props
are strings or numbers. The value is normalized, so that the property and the value in the callback will be a
number (or whatever)
bools
bools
are naturally, always booleans. The reason these are special is attributes can work via existence, for example:
// not set strictly to "true", but as an attribute, equates to true:
<my-custom disabled />
// not set at all, but as an attribute, equates to false:
<my-custom />
Developing a Plugin
A plugin looks like this:
BaseComponent.addPlugin({
name: PLUGIN_NAME,
order: ORDER_OF_EXECUTION,
init - fires after constructor
preConnected - fires before connected is called
postConnected - fires after connected is called
preDomReady - fires before domReady is called
postDomReady - fires after domReady is called
preAttributeChanged - fires before attributeChanged is called
});
The name
should be unique, and the order
determines, if multiple plugins all have the same callback (such as
preDomReady) which plugin fires in what order.
All the callbacks fire with the custom element as an argument, with the element and possible options.
When adding one or multiple plugins, all components will have this functionality. It is not possible to have components with different plugins.
Inheritance
Use the same inheritance you would use with ES6 classes.
Shadow DOM (not used!)
BaseComponent purposely does not use the Shadow DOM. There are only a few use cases for Shadow DOM, and due to the difficulty in styling, the cons outweigh the pros. This also keeps the library simple.
This should not prevent you from using Shadow DOM in your custom elements.
ES6 FAQ
Q. Uncaught TypeError: Illegal invocation
A. The native shim is not in use, when BaseComponent is compiled with Babel, and it is being access by an extending class.
Q. Uncaught TypeError: Failed to construct 'HTMLElement': Please use the 'new' operator, this DOM object constructor cannot be called as a function.
A. The web components native-shim.js (in the custom elements polyfill) is missing. Ensure that the shim is loading before any custom element code.
Q. Uncaught TypeError:Super expression must either be null or a function, not object
A. The class is not extending the class correctly. This is because of a typo, a bad class, or when importing, getting a
wrapper around the object; ergo, instead of extend MyClass
, you may have to do extend MyClass.default
(although this is rare)
Q. Uncaught TypeError: Class constructor cannot be invoked without 'new'
A. Multiple possibilities:
- Babel is not transpiling. This could be the wrong version (try "latest" or "es2015")
- You might not be transpiling
node_modules
dependencies. Ensure they are not excluded in webpack'sexclude
. - As per the above FAQ, it is because you added
.default
to the extended class. - You might be linking to src/BaseComponent instead of dist/BaseComponent.
- You are using the native-shim from custom-elements-polyfill, with untranspiled code. If this is the purpose, use
window['no-native-shim'] = true;
before loading the polyfill, to prevent the native-shim from loading.
Q. Uncaught ReferenceError: "this" is not defined
A. super()
is required in the constructor when extending another class.
Q. What are the constructor super()
rules?
A. Super-Rules:
- Do not call
super()
if not extending a class - When extending a class and using a constructor,
super()
must be called. super()
must be called first - or at least before using thethis
keyword.- Do not try to pass arguments into a constructor - they are not passed into extended HTMLElements (at least not in the v1 spec)
Q. Why are my component methods undefined?
A. Did you remember to do: customElements.define('my-component', MyComponent)
?
Q. I get this error on build: Error: Couldn't find preset "latest" relative to directory ".../node_modules/@janiking/base-component"
A. Babel is not set up correctly. Try installing @babel/preset-env
to your package.
Developing
Clone the repository with your generic clone commands as a standalone repository or submodule.
git clone git://github.com/jani-king-international/base-component.git
To run the tests in tests/test.html
, start the webpack build and webpack-dev-server:
yarn start
To run the webpack build for distribution to be accessed by tests/test-dist.html
:
yarn deploy
A "globalized" version can be built and accessed with tests/globalES6.html
. This converts the ES6 import
and export
into window globals, but otherwise leaves
the remaining code as ES6. This way the code can be run in Chrome natively, and in Firefox and Edge with the webcomponents shim.
yarn globalize
Resources
webreflection
w3 mailing list
mdn
Classes
License
This uses the MIT license. Feel free to use, and redistribute at will.