From a563c121fca9e6edaee1accd927a507ef91adb3a Mon Sep 17 00:00:00 2001 From: John Cowen Date: Mon, 7 Mar 2022 09:51:47 +0000 Subject: [PATCH] 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. --- .../app/components/custom-element/README.mdx | 87 ++++++++ .../app/components/custom-element/index.hbs | 11 + .../app/components/custom-element/index.js | 193 ++++++++++++++++++ .../app/components/shadow-host/README.mdx | 29 +++ .../app/components/shadow-host/index.hbs | 5 + .../app/components/shadow-host/index.js | 14 ++ .../app/components/shadow-template/README.mdx | 8 +- .../app/components/shadow-template/index.hbs | 6 +- .../consul-ui/app/helpers/adopt-styles.js | 7 +- .../consul-ui/app/helpers/adopt-styles.mdx | 2 +- .../consul-ui/app/helpers/class-map.js | 1 + ui/packages/consul-ui/app/helpers/css-map.js | 16 ++ 12 files changed, 369 insertions(+), 10 deletions(-) create mode 100644 ui/packages/consul-ui/app/components/custom-element/README.mdx create mode 100644 ui/packages/consul-ui/app/components/custom-element/index.hbs create mode 100644 ui/packages/consul-ui/app/components/custom-element/index.js create mode 100644 ui/packages/consul-ui/app/components/shadow-host/README.mdx create mode 100644 ui/packages/consul-ui/app/components/shadow-host/index.hbs create mode 100644 ui/packages/consul-ui/app/components/shadow-host/index.js create mode 100644 ui/packages/consul-ui/app/helpers/css-map.js diff --git a/ui/packages/consul-ui/app/components/custom-element/README.mdx b/ui/packages/consul-ui/app/components/custom-element/README.mdx new file mode 100644 index 000000000..4aa9d64c8 --- /dev/null +++ b/ui/packages/consul-ui/app/components/custom-element/README.mdx @@ -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 (``, ``). + +```hbs preview-template + + + + +``` + +## 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 | + + diff --git a/ui/packages/consul-ui/app/components/custom-element/index.hbs b/ui/packages/consul-ui/app/components/custom-element/index.hbs new file mode 100644 index 000000000..a040d5eca --- /dev/null +++ b/ui/packages/consul-ui/app/components/custom-element/index.hbs @@ -0,0 +1,11 @@ + + {{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 + }} + diff --git a/ui/packages/consul-ui/app/components/custom-element/index.js b/ui/packages/consul-ui/app/components/custom-element/index.js new file mode 100644 index 000000000..6d52fdfcc --- /dev/null +++ b/ui/packages/consul-ui/app/components/custom-element/index.js @@ -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 '': + case '': + case '': + case 'number': { + const num = parseFloat(value); + if(isNaN(num)) { + return typeof d === 'undefined' ? 0 : d; + } else { + return num; + } + } + case '': + return parseInt(value); + case '': + 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); + } +} diff --git a/ui/packages/consul-ui/app/components/shadow-host/README.mdx b/ui/packages/consul-ui/app/components/shadow-host/README.mdx new file mode 100644 index 000000000..c83c58eb9 --- /dev/null +++ b/ui/packages/consul-ui/app/components/shadow-host/README.mdx @@ -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 + +
+ +

hi

+
+
+
+``` + +## 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 | diff --git a/ui/packages/consul-ui/app/components/shadow-host/index.hbs b/ui/packages/consul-ui/app/components/shadow-host/index.hbs new file mode 100644 index 000000000..3c70ea8f0 --- /dev/null +++ b/ui/packages/consul-ui/app/components/shadow-host/index.hbs @@ -0,0 +1,5 @@ +{{yield (hash + host=(fn this.attachShadow) + root=this.shadowRoot + Template=(component 'shadow-template' shadowRoot=this.shadowRoot) +)}} diff --git a/ui/packages/consul-ui/app/components/shadow-host/index.js b/ui/packages/consul-ui/app/components/shadow-host/index.js new file mode 100644 index 000000000..c5eb1046e --- /dev/null +++ b/ui/packages/consul-ui/app/components/shadow-host/index.js @@ -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' }); + } + +} diff --git a/ui/packages/consul-ui/app/components/shadow-template/README.mdx b/ui/packages/consul-ui/app/components/shadow-template/README.mdx index 3f2556526..64d352a7d 100644 --- a/ui/packages/consul-ui/app/components/shadow-template/README.mdx +++ b/ui/packages/consul-ui/app/components/shadow-template/README.mdx @@ -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 `