ui: CustomElement component (#12451)
Builds on attach-shadow, adopt-styles and ShadowTemplate, this commit adds ShadowHost and finally CustomElement. CustomElement is a renderless component to help with the creation of native HTML Custom Elements along with runtime type checking and self-documentation for attributes, slots, cssprops and cssparts. As you will probably see there is a little more work to come here. But in the same breath, everything would be fine to go in as is.
This commit is contained in:
parent
d598a0a92d
commit
a563c121fc
|
@ -0,0 +1,87 @@
|
||||||
|
# CustomElement
|
||||||
|
|
||||||
|
A renderless component to aid with the creation of HTML custom elements a.k.a
|
||||||
|
WebComponents.
|
||||||
|
|
||||||
|
All of the CustomElement component arguments are only used at construction
|
||||||
|
time (within the components constructor) therefore they are, by design, static.
|
||||||
|
You shouldn't be dynamically updating these values at all. They are only for
|
||||||
|
type checking and documention purposes and therefore once defined/hardcoded
|
||||||
|
they should only change if you as the author wishes to change them.
|
||||||
|
|
||||||
|
The component is built from various other components, also see their documentaton
|
||||||
|
for further details (`<ShadowHost />`, `<ShadowTemplate />`).
|
||||||
|
|
||||||
|
```hbs preview-template
|
||||||
|
<CustomElement
|
||||||
|
@element="x-component"
|
||||||
|
@attrs={{array
|
||||||
|
(array 'type' '"awesome" | "sauce"' 'awesome' 'Set the type of the x-component')
|
||||||
|
(array 'x' 'number' 0 'The x-ness of the x-component')
|
||||||
|
}}
|
||||||
|
@cssprops={{array
|
||||||
|
(array '--awesome-x-sauce' 'length' '[x]' 'Makes the x-ness of the sauce available to CSS, automatically synced/tracked from the x attributes')
|
||||||
|
(array '--awesome-color' 'color' undefined 'This CSS property can be used to set the color of the awesome')
|
||||||
|
}}
|
||||||
|
@cssparts={{array
|
||||||
|
(array 'base' 'Style base from The Outside via ::part(base)')
|
||||||
|
}}
|
||||||
|
@slots={{array
|
||||||
|
(array 'header' "You'll want to document the slots also")
|
||||||
|
(array '' 'Including the default slot')
|
||||||
|
}}
|
||||||
|
as |custom element|>
|
||||||
|
<x-component
|
||||||
|
{{did-insert custom.connect}}
|
||||||
|
{{will-destroy custom.disconnect}}
|
||||||
|
aria-hidden="true"
|
||||||
|
...attributes
|
||||||
|
>
|
||||||
|
<custom.Template
|
||||||
|
@styles={{css-map
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div part="base"
|
||||||
|
data-x={{element.attrs.x}}
|
||||||
|
data-type={{element.attrs.type}}
|
||||||
|
>
|
||||||
|
<slot name="header"></slot>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</custom.Template>
|
||||||
|
</x-component>
|
||||||
|
</CustomElement>
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Arguments
|
||||||
|
|
||||||
|
All `descriptions` in attributes will be compiled out at build time as well as the `@description` attribute itself.
|
||||||
|
|
||||||
|
| Attribute | Type | Default | Description |
|
||||||
|
| :------------ | :------------- | :------ | :------------------------------------------------------------------------- |
|
||||||
|
| element | string | | The custom tag to be used for the custom element. Must include a dash |
|
||||||
|
| description | string | | Short 1 line description for the element. Think "git commit title" style |
|
||||||
|
| attrs | attrInfo[] | | An array of attributes that can be used on the element |
|
||||||
|
| slots | slotsInfo[] | | An array of slots that can be used for the element (100% compiled out) |
|
||||||
|
| cssprops | cssPropsInfo[] | | An array of CSS properties that are relevant to the component |
|
||||||
|
| cssparts | cssPartsInfo[] | | An array of CSS parts that can be used for the element (100% compiled out) |
|
||||||
|
| args | argsInfo[] | | An array of Glimmer arguments used for the component (100% compiled out) |
|
||||||
|
|
||||||
|
## Exports
|
||||||
|
|
||||||
|
### custom
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
| :--------- | :------- | :---------------------------------------------------------------------------------- |
|
||||||
|
| connect | function | A did-insert-able callback for tagging an element to be used for the custom element |
|
||||||
|
| disconnect | function | A will-destroy-able callback for destroying an element used for the custom element |
|
||||||
|
|
||||||
|
### element
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
| :--------- | :------- | :------------------------------------------------------------------------------- |
|
||||||
|
| attributes | object | An object containing a reference to all the custom elements' observed properties |
|
||||||
|
| * | | All other properties proxy through to the CustomElements class |
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
<ShadowHost as |shadow|>
|
||||||
|
{{yield
|
||||||
|
(hash
|
||||||
|
root=(fn this.setHost (fn shadow.host))
|
||||||
|
connect=(fn this.setHost (fn shadow.host))
|
||||||
|
Template=shadow.Template
|
||||||
|
disconnect=(fn this.disconnect)
|
||||||
|
)
|
||||||
|
this.element
|
||||||
|
}}
|
||||||
|
</ShadowHost>
|
|
@ -0,0 +1,193 @@
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { assert } from '@ember/debug';
|
||||||
|
|
||||||
|
const ATTRIBUTE_CHANGE = 'custom-element.attributeChange';
|
||||||
|
const elements = new Map();
|
||||||
|
const proxies = new WeakMap();
|
||||||
|
|
||||||
|
const typeCast = (attributeInfo, value) => {
|
||||||
|
let type = attributeInfo.type;
|
||||||
|
const d = attributeInfo.default;
|
||||||
|
value = value == null ? attributeInfo.default : value;
|
||||||
|
if(type.indexOf('|') !== -1) {
|
||||||
|
assert(`"${value} is not of type '${type}'"`, type.split('|').map(item => item.replaceAll('"', '').trim()).includes(value));
|
||||||
|
type = 'string';
|
||||||
|
}
|
||||||
|
switch(type) {
|
||||||
|
case '<length>':
|
||||||
|
case '<percentage>':
|
||||||
|
case '<dimension>':
|
||||||
|
case 'number': {
|
||||||
|
const num = parseFloat(value);
|
||||||
|
if(isNaN(num)) {
|
||||||
|
return typeof d === 'undefined' ? 0 : d;
|
||||||
|
} else {
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case '<integer>':
|
||||||
|
return parseInt(value);
|
||||||
|
case '<string>':
|
||||||
|
case 'string':
|
||||||
|
return (value || '').toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attributeChangingElement = (name, Cls = HTMLElement, attributes = {}, cssprops = {}) => {
|
||||||
|
const attrs = Object.keys(attributes);
|
||||||
|
|
||||||
|
const customClass = class extends Cls {
|
||||||
|
static get observedAttributes() {
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(name, oldValue, newValue) {
|
||||||
|
const prev = typeCast(attributes[name], oldValue);
|
||||||
|
const value = typeCast(attributes[name], newValue);
|
||||||
|
|
||||||
|
const cssProp = cssprops[`--${name}`];
|
||||||
|
if(typeof cssProp !== 'undefined' && cssProp.track === `[${name}]`) {
|
||||||
|
this.style.setProperty(
|
||||||
|
`--${name}`,
|
||||||
|
value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(typeof super.attributeChangedCallback === 'function') {
|
||||||
|
super.attributeChangedCallback(name, prev, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent(
|
||||||
|
ATTRIBUTE_CHANGE,
|
||||||
|
{
|
||||||
|
detail: {
|
||||||
|
name: name,
|
||||||
|
previousValue: prev,
|
||||||
|
value: value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define(name, customClass);
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoFromArray = (arr, keys) => {
|
||||||
|
return (arr || []).reduce((prev, info) => {
|
||||||
|
let key;
|
||||||
|
const obj = {};
|
||||||
|
keys.forEach((item, i) => {
|
||||||
|
if(item === '_') {
|
||||||
|
key = i;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
obj[item] = info[i]
|
||||||
|
});
|
||||||
|
prev[info[key]] = obj;
|
||||||
|
return prev;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
const debounceRAF = (cb, prev) => {
|
||||||
|
if(typeof prev !== 'undefined') {
|
||||||
|
cancelAnimationFrame(prev);
|
||||||
|
}
|
||||||
|
return requestAnimationFrame(cb);
|
||||||
|
}
|
||||||
|
const createElementProxy = ($element, component) => {
|
||||||
|
return new Proxy($element, {
|
||||||
|
get: (target, prop, receiver) => {
|
||||||
|
switch(prop) {
|
||||||
|
case 'attrs':
|
||||||
|
return component.attributes;
|
||||||
|
default:
|
||||||
|
if(typeof target[prop] === 'function') {
|
||||||
|
// need to ensure we use a MultiWeakMap here
|
||||||
|
// if(this.methods.has(prop)) {
|
||||||
|
// return this.methods.get(prop);
|
||||||
|
// }
|
||||||
|
const method = target[prop].bind(target);
|
||||||
|
// this.methods.set(prop, method);
|
||||||
|
return method;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class CustomElementComponent extends Component {
|
||||||
|
|
||||||
|
@tracked $element;
|
||||||
|
@tracked _attributes = {};
|
||||||
|
|
||||||
|
__attributes;
|
||||||
|
_attchange;
|
||||||
|
|
||||||
|
|
||||||
|
constructor(owner, args) {
|
||||||
|
super(...arguments);
|
||||||
|
if(!elements.has(args.element)) {
|
||||||
|
const cb = attributeChangingElement(
|
||||||
|
args.element,
|
||||||
|
args.class,
|
||||||
|
infoFromArray(args.attrs, ['_', 'type', 'default', 'description']),
|
||||||
|
infoFromArray(args.cssprops, ['_', 'type', 'track', 'description'])
|
||||||
|
)
|
||||||
|
elements.set(args.element, cb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get attributes() {
|
||||||
|
return this._attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
get element() {
|
||||||
|
if(this.$element) {
|
||||||
|
if(proxies.has(this.$element)) {
|
||||||
|
return proxies.get(this.$element);
|
||||||
|
}
|
||||||
|
const proxy = createElementProxy(this.$element, this);
|
||||||
|
proxies.set(this.$element, proxy);
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
setHost(attachShadow, $element) {
|
||||||
|
attachShadow($element);
|
||||||
|
this.$element = $element;
|
||||||
|
this.$element.addEventListener(ATTRIBUTE_CHANGE, this.attributeChange);
|
||||||
|
|
||||||
|
(this.args.attrs || []).forEach(entry => {
|
||||||
|
const value = $element.getAttribute(entry[0]);
|
||||||
|
$element.attributeChangedCallback(entry[0], value, value)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
disconnect() {
|
||||||
|
this.$element.removeEventListener(ATTRIBUTE_CHANGE, this.attributeChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
attributeChange(e) {
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
// currently if one single attribute changes
|
||||||
|
// they all change
|
||||||
|
this.__attributes = {
|
||||||
|
...this.__attributes,
|
||||||
|
[e.detail.name]: e.detail.value
|
||||||
|
};
|
||||||
|
this._attchange = debounceRAF(() => {
|
||||||
|
// tell glimmer we changed the attrs
|
||||||
|
this._attributes = this.__attributes;
|
||||||
|
}, this._attchange);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
# ShadowHost
|
||||||
|
|
||||||
|
`ShadowHost` is a small renderless mainly utility component for easily attaching
|
||||||
|
ShadowDOM to any applicable DOM node. It mainly exists to provide a context for
|
||||||
|
passing around a reference to the element to be used for the shadow template,
|
||||||
|
but named appropriately for recognition.
|
||||||
|
|
||||||
|
If you are looking to write a custom element, please use the `CustomElement`
|
||||||
|
component. If you are simply attaching ShadowDOM to a native HTML element then
|
||||||
|
this is the component for you.
|
||||||
|
|
||||||
|
```hbs preview-template
|
||||||
|
<ShadowHost as |shadow|>
|
||||||
|
<div
|
||||||
|
{{did-insert shadow.host}}
|
||||||
|
>
|
||||||
|
<shadow.Template>
|
||||||
|
<p>hi</p>
|
||||||
|
</shadow.Template>
|
||||||
|
</div>
|
||||||
|
</ShadowHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exports
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
| :-------- | :---------------------- | :------------------------------------------------------------------------------- |
|
||||||
|
| host | function | A did-insert-able callback for tagging an element to be used for the shadow root |
|
||||||
|
| Template | ShadowTemplateComponent | ShadowTemplate component pre-configured with the shadow host |
|
|
@ -0,0 +1,5 @@
|
||||||
|
{{yield (hash
|
||||||
|
host=(fn this.attachShadow)
|
||||||
|
root=this.shadowRoot
|
||||||
|
Template=(component 'shadow-template' shadowRoot=this.shadowRoot)
|
||||||
|
)}}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
|
||||||
|
export default class ShadowHostComponent extends Component {
|
||||||
|
|
||||||
|
@tracked shadowRoot;
|
||||||
|
|
||||||
|
@action
|
||||||
|
attachShadow($element) {
|
||||||
|
this.shadowRoot = $element.attachShadow({ mode: 'open' });
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -9,7 +9,7 @@ Shadow DOM we have a `@shadowRoot` argument to which you would pass the actual
|
||||||
Shadow DOM element (which itself either open or closed). You can get a reference
|
Shadow DOM element (which itself either open or closed). You can get a reference
|
||||||
to this by using the `{{attach-shadow}}` modifier.
|
to this by using the `{{attach-shadow}}` modifier.
|
||||||
|
|
||||||
Additionally a `@stylesheets` argument is made available for you to optionally
|
Additionally a `@styles` argument is made available for you to optionally
|
||||||
pass completely isolated, scoped, constructable stylesheets to be used for the
|
pass completely isolated, scoped, constructable stylesheets to be used for the
|
||||||
Shadow DOM tree (you can also continue to use `<style>` within the template
|
Shadow DOM tree (you can also continue to use `<style>` within the template
|
||||||
itself also if necessary).
|
itself also if necessary).
|
||||||
|
@ -43,7 +43,7 @@ the example below).
|
||||||
>
|
>
|
||||||
<ShadowTemplate
|
<ShadowTemplate
|
||||||
@shadowRoot={{this.shadow}}
|
@shadowRoot={{this.shadow}}
|
||||||
@stylesheets={{css '
|
@styles={{css '
|
||||||
:host {
|
:host {
|
||||||
background-color: rgb(var(--tone-strawberry-500) / 20%);
|
background-color: rgb(var(--tone-strawberry-500) / 20%);
|
||||||
padding: 1rem; /* 16px */
|
padding: 1rem; /* 16px */
|
||||||
|
@ -107,7 +107,7 @@ using Glimmer syntax i.e. `<ComponentName />` not `<component-name />` but a
|
||||||
>
|
>
|
||||||
<ShadowTemplate
|
<ShadowTemplate
|
||||||
@shadowRoot={{this.shadow}}
|
@shadowRoot={{this.shadow}}
|
||||||
@stylesheets={{css '
|
@styles={{css '
|
||||||
header {
|
header {
|
||||||
color: purple;
|
color: purple;
|
||||||
}
|
}
|
||||||
|
@ -159,4 +159,4 @@ component-name::part(header)::before {
|
||||||
| Argument | Type | Default | Description |
|
| Argument | Type | Default | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `shadowRoot` | `ShadowRoot` | | A reference to a shadow root (probably retrived using the `{{attach-shadow}}` modifier |
|
| `shadowRoot` | `ShadowRoot` | | A reference to a shadow root (probably retrived using the `{{attach-shadow}}` modifier |
|
||||||
| `stylesheets` | `CSSResultGroup` | | Stylesheets to be adopted by the ShadowRoot |
|
| `styles` | `CSSResultGroup` | | Styles to be adopted by the ShadowRoot |
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
{{#if @shadowRoot}}
|
{{#if @shadowRoot}}
|
||||||
{{#in-element @shadowRoot}}
|
{{#in-element @shadowRoot}}
|
||||||
{{#if @stylesheets}}
|
{{#if @styles}}
|
||||||
{{adopt-styles
|
{{adopt-styles
|
||||||
@shadowRoot
|
@shadowRoot
|
||||||
@stylesheets
|
@styles
|
||||||
}}
|
}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{yield}}
|
{{yield}}
|
||||||
{{/in-element}}
|
{{/in-element}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
|
@ -6,13 +6,16 @@ export default class AdoptStylesHelper extends Helper {
|
||||||
/**
|
/**
|
||||||
* Adopt/apply given styles to a `ShadowRoot` using constructable styleSheets if supported
|
* Adopt/apply given styles to a `ShadowRoot` using constructable styleSheets if supported
|
||||||
*
|
*
|
||||||
* @param {[ShadowRoot, CSSResultGroup]} params
|
* @param {[ShadowRoot, (CSSResultGroup | CSSResultGroup[])]} params
|
||||||
*/
|
*/
|
||||||
compute([$shadow, styles], hash) {
|
compute([$shadow, styles], hash) {
|
||||||
assert(
|
assert(
|
||||||
'adopt-styles can only be used to apply styles to ShadowDOM elements',
|
'adopt-styles can only be used to apply styles to ShadowDOM elements',
|
||||||
$shadow instanceof ShadowRoot
|
$shadow instanceof ShadowRoot
|
||||||
);
|
);
|
||||||
adoptStyles($shadow, [styles]);
|
if(!Array.isArray(styles)) {
|
||||||
|
styles = [styles];
|
||||||
|
}
|
||||||
|
adoptStyles($shadow, styles);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,5 +24,5 @@ Adopt/apply given styles to a `ShadowRoot` using constructable styleSheets if su
|
||||||
|
|
||||||
| Argument | Type | Default | Description |
|
| Argument | Type | Default | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `params` | `[ShadowRoot, CSSResultGroup]` | | |
|
| `params` | `[ShadowRoot, (CSSResultGroup \| CSSResultGroup[])]` | | |
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { helper } from '@ember/component/helper';
|
||||||
*/
|
*/
|
||||||
const classMap = entries => {
|
const classMap = entries => {
|
||||||
const str = entries
|
const str = entries
|
||||||
|
.filter(Boolean)
|
||||||
.filter(entry => (typeof entry === 'string' ? true : entry[entry.length - 1]))
|
.filter(entry => (typeof entry === 'string' ? true : entry[entry.length - 1]))
|
||||||
.map(entry => (typeof entry === 'string' ? entry : entry[0]))
|
.map(entry => (typeof entry === 'string' ? entry : entry[0]))
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { helper } from '@ember/component/helper';
|
||||||
|
import { CSSResult } from '@lit/reactive-element';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conditionally maps cssInfos to an array ready for ShadowDom::styles
|
||||||
|
* usage.
|
||||||
|
*
|
||||||
|
* @typedef {([CSSResult, boolean] | [CSSResult])} cssInfo
|
||||||
|
* @param {(cssInfo | string)[]} entries - An array of 'entry-like' arrays of `cssInfo`s to map
|
||||||
|
*/
|
||||||
|
const cssMap = entries => {
|
||||||
|
return entries
|
||||||
|
.filter(entry => (entry instanceof CSSResult ? true : entry[entry.length - 1]))
|
||||||
|
.map(entry => (entry instanceof CSSResult ? entry : entry[0]))
|
||||||
|
};
|
||||||
|
export default helper(cssMap);
|
Loading…
Reference in New Issue