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
|
||||
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
|
||||
Shadow DOM tree (you can also continue to use `<style>` within the template
|
||||
itself also if necessary).
|
||||
|
@ -43,7 +43,7 @@ the example below).
|
|||
>
|
||||
<ShadowTemplate
|
||||
@shadowRoot={{this.shadow}}
|
||||
@stylesheets={{css '
|
||||
@styles={{css '
|
||||
:host {
|
||||
background-color: rgb(var(--tone-strawberry-500) / 20%);
|
||||
padding: 1rem; /* 16px */
|
||||
|
@ -107,7 +107,7 @@ using Glimmer syntax i.e. `<ComponentName />` not `<component-name />` but a
|
|||
>
|
||||
<ShadowTemplate
|
||||
@shadowRoot={{this.shadow}}
|
||||
@stylesheets={{css '
|
||||
@styles={{css '
|
||||
header {
|
||||
color: purple;
|
||||
}
|
||||
|
@ -159,4 +159,4 @@ component-name::part(header)::before {
|
|||
| Argument | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `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,9 +1,9 @@
|
|||
{{#if @shadowRoot}}
|
||||
{{#in-element @shadowRoot}}
|
||||
{{#if @stylesheets}}
|
||||
{{#if @styles}}
|
||||
{{adopt-styles
|
||||
@shadowRoot
|
||||
@stylesheets
|
||||
@styles
|
||||
}}
|
||||
{{/if}}
|
||||
{{yield}}
|
||||
|
|
|
@ -6,13 +6,16 @@ export default class AdoptStylesHelper extends Helper {
|
|||
/**
|
||||
* 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) {
|
||||
assert(
|
||||
'adopt-styles can only be used to apply styles to ShadowDOM elements',
|
||||
$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 |
|
||||
| --- | --- | --- | --- |
|
||||
| `params` | `[ShadowRoot, CSSResultGroup]` | | |
|
||||
| `params` | `[ShadowRoot, (CSSResultGroup \| CSSResultGroup[])]` | | |
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import { helper } from '@ember/component/helper';
|
|||
*/
|
||||
const classMap = entries => {
|
||||
const str = entries
|
||||
.filter(Boolean)
|
||||
.filter(entry => (typeof entry === 'string' ? true : entry[entry.length - 1]))
|
||||
.map(entry => (typeof entry === 'string' ? entry : entry[0]))
|
||||
.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