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:
John Cowen 2022-03-07 09:51:47 +00:00 committed by GitHub
parent d598a0a92d
commit a563c121fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 369 additions and 10 deletions

View File

@ -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 |

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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 |

View File

@ -0,0 +1,5 @@
{{yield (hash
host=(fn this.attachShadow)
root=this.shadowRoot
Template=(component 'shadow-template' shadowRoot=this.shadowRoot)
)}}

View File

@ -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' });
}
}

View File

@ -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 |

View File

@ -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}}

View File

@ -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);
} }
} }

View File

@ -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[])]` | | |

View File

@ -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(' ');

View File

@ -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);