diff --git a/packages/temple/src/client.ts b/packages/temple/src/client.ts index 167a77e..c06fd7f 100644 --- a/packages/temple/src/client.ts +++ b/packages/temple/src/client.ts @@ -7,6 +7,7 @@ export type { } from './types'; import TempleException from './Exception'; +import TempleField from './client/TempleField'; import TempleComponent from './client/TempleComponent'; import TempleRegistry from './client/TempleRegistry'; import TempleElement from './client/TempleElement'; @@ -32,6 +33,7 @@ export { signal, emitter, TempleDataMap, + TempleField, TempleComponent, TempleRegistry, TempleElement, diff --git a/packages/temple/src/client/TempleComponent.ts b/packages/temple/src/client/TempleComponent.ts index a6a55e6..aae68e6 100644 --- a/packages/temple/src/client/TempleComponent.ts +++ b/packages/temple/src/client/TempleComponent.ts @@ -143,6 +143,37 @@ export default abstract class TempleComponent extends HTMLElement { emitter.emit('adopt', this); } + /** + * Called when static observedAttributes is set and: + * 1. setAttribute is called + * 2. removeAttribute is called + * 3. element.[attribute] is changed + * 4. element.[attribute] is removed + * 5. element.attributes is changed + * 6. Attribute is changed via browser developer tools + */ + public attributeChangedCallback( + name: string, + prev: string|null, + next: string|null + ) { + //if it's rendering, do nothing + if (this._rendering) { + return; + } + //determine action + const action = prev === null + ? 'add' : next === null + ? 'remove' : 'update'; + if (next === null && this.hasAttribute(name)) { + this.element.removeAttribute(name); + } else { + this.element.setAttribute(name, next); + } + //emit the attr event + emitter.emit('attr', { action, name, prev, value: next, target: this }); + } + /** * Called when the element is inserted into a document, */ @@ -289,7 +320,7 @@ export default abstract class TempleComponent extends HTMLElement { } else { //if shadow root is not set, create it if (!this.shadowRoot) { - this.attachShadow({ mode: 'open' }); + this.attachShadow({ mode: 'open', delegatesFocus: true }); } const shadowRoot = this.shadowRoot as ShadowRoot; diff --git a/packages/temple/src/client/TempleEmitter.ts b/packages/temple/src/client/TempleEmitter.ts index 10587d1..a742d7a 100644 --- a/packages/temple/src/client/TempleEmitter.ts +++ b/packages/temple/src/client/TempleEmitter.ts @@ -187,7 +187,7 @@ export default (() => { //ex.
Hello World
bindAttribute('mount', element => { const callback = element.getAttribute('mount'); - if (typeof callback === 'function' ) { + if (typeof callback === 'function') { const event = new CustomEvent('mount', { detail: { node: element, @@ -201,7 +201,7 @@ export default (() => { //ex.
Hello World
unbindAttribute('unmount', element => { const callback = element.getAttribute('unmount'); - if (typeof callback === 'function' ) { + if (typeof callback === 'function') { const event = new CustomEvent('unmount', { detail: { node: element, @@ -215,42 +215,63 @@ export default (() => { //ex.
Hello World
bindAttribute('connect', element => { const callback = element.getAttribute('connect'); - if (typeof callback === 'function' ) { - const event = new CustomEvent('connect', { - detail: { - node: element, - target: element.element - } - }); - callback(event); + if (typeof callback === 'function') { + emitter.unbind('connect', callback); + emitter.on('connect', callback); } }); //ex.
Hello World
bindAttribute('disconnect', element => { const callback = element.getAttribute('disconnect'); - if (typeof callback === 'function' ) { - const event = new CustomEvent('disconnect', { - detail: { - node: element, - target: element.element - } - }); - callback(event); + if (typeof callback === 'function') { + emitter.unbind('disconnect', callback); + emitter.on('disconnect', callback); } }); //ex.
Hello World
bindAttribute('adopt', element => { const callback = element.getAttribute('adopt'); + if (typeof callback === 'function') { + emitter.unbind('adopt', callback); + emitter.on('adopt', callback); + } + }); + + //ex.
Hello World
+ bindAttribute('associate', element => { + const callback = element.getAttribute('associate'); + if (typeof callback === 'function') { + emitter.unbind('associate', callback); + emitter.on('associate', callback); + } + }); + + //ex.
Hello World
+ bindAttribute('disable', element => { + const callback = element.getAttribute('disable'); + if (typeof callback === 'function') { + emitter.unbind('disable', callback); + emitter.on('disable', callback); + } + }); + + //ex.
Hello World
+ bindAttribute('reset', element => { + const callback = element.getAttribute('reset'); + if (typeof callback === 'function') { + emitter.unbind('reset', callback); + emitter.on('reset', callback); + } + }); + + //ex.
Hello World
+ bindAttribute('attr', element => { + const callback = element.getAttribute('attr'); if (typeof callback === 'function' ) { - const event = new CustomEvent('adopt', { - detail: { - node: element, - target: element.element - } - }); - callback(event); + emitter.unbind('attr', callback); + emitter.on('attr', callback); } }); diff --git a/packages/temple/src/client/TempleField.ts b/packages/temple/src/client/TempleField.ts new file mode 100644 index 0000000..f92c9a3 --- /dev/null +++ b/packages/temple/src/client/TempleField.ts @@ -0,0 +1,80 @@ +import TempleComponent from './TempleComponent'; +import emitter from './TempleEmitter'; + +export default abstract class TempleField extends TempleComponent { + //associates this component with a form + public static formAssociated = true; + //accessor to the form internals API + protected _field: ElementInternals; + + /** + * Returns the form internals API. + */ + public get field() { + return this._field; + } + + /** + * Attaches the form internals API to the element. + */ + public constructor() { + super(); + this._field = this.attachInternals(); + } + + /** + * This is called as soon as the element is associated with a form. + */ + public formAssociatedCallback(form: HTMLFormElement) { + //emit the associate event + emitter.emit('associate', this); + } + + /** + * This is called whenever the element or parent
element are disabled. + */ + public formDisabledCallback(disabled: boolean) { + //emit the disable event + emitter.emit('disable', this); + } + + /** + * This gives us the ability to control our element’s behavior when a user resets a form. + */ + public formResetCallback() { + //emit the disable event + emitter.emit('reset', this); + } +} + +// Just to note.. +// interface ValidityStateFlags { +// // `true` if the element is required, but has no value +// valueMissing?: boolean; +// // `true` if the value is not in the required syntax +// // (when the "type" is "email" or "URL") +// typeMismatch?: boolean; +// // `true` if the value does not match the specified pattern +// patternMismatch?: boolean; +// // `true` if the value exceeds the specified `maxlength` +// tooLong?: boolean; +// // `true` if the value fails to meet the specified `minlength` +// tooShort?: boolean; +// // `true` if the value is less than the +// // minimum specified by the `min` attribute +// rangeUnderflow?: boolean; +// // `true` if the value is greater than the +// // maximum specified by the `max` attribute +// rangeOverflow?: boolean; +// // `true` if the value does not fit the rules determined by the +// // `step` attribute (that is, it's not evenly divisible by the +// // step value) +// stepMismatch?: boolean; +// // `true` if the user has provided input +// // that the browser is unable to convert +// badInput?: boolean; +// // `true` if the element's custom validity message has been set to +// // a non-empty string by calling the element's `setCustomValidity()` +// // method +// customError?: boolean; +// } \ No newline at end of file diff --git a/packages/temple/src/client/classnames.ts b/packages/temple/src/client/classnames.ts index 702c76f..1875198 100644 --- a/packages/temple/src/client/classnames.ts +++ b/packages/temple/src/client/classnames.ts @@ -25,5 +25,5 @@ export function classlist(pointer: Component|null = null) { * ie. const classes = classnames(); */ export default function classnames(pointer: TempleComponent|null = null) { - return props<{'class': string}>(pointer)['class']; + return props<{'class': string}>(pointer)['class'] || ''; } \ No newline at end of file diff --git a/packages/temple/src/compiler/Component.ts b/packages/temple/src/compiler/Component.ts index e0c04ef..ceadc8a 100644 --- a/packages/temple/src/compiler/Component.ts +++ b/packages/temple/src/compiler/Component.ts @@ -160,6 +160,17 @@ export default class Component { return path.dirname(this.absolute); } + /** + * Returns true if the component should be treated as a field + */ + public get field() { + return this.ast.scripts.some( + script => script.attributes?.properties.some( + property => property.key.name === 'form' + ) + ); + } + /** * Returns the filesystem being used */ @@ -203,6 +214,29 @@ export default class Component { return this._loader; } + /** + * Returns attributes that should be observed + */ + public get observe() { + const observables = new Set(); + for (const script of this.ast.scripts) { + if (script.attributes) { + for (const property of script.attributes.properties) { + const { key, value } = property; + if (key.name === 'observe' + && value.type === 'Literal' + && typeof value.value === 'string' + ) { + value.value.split(',').forEach( + attribute => observables.add(attribute.trim()) + ); + } + } + } + } + return Array.from(observables); + } + /** * Returns the parent component */ diff --git a/packages/temple/src/compiler/Transpiler.ts b/packages/temple/src/compiler/Transpiler.ts index 9a27811..2c315ee 100644 --- a/packages/temple/src/compiler/Transpiler.ts +++ b/packages/temple/src/compiler/Transpiler.ts @@ -101,22 +101,31 @@ export default class Transpiler { classname, imports, styles, - scripts + scripts, + field, + observe } = this._component; //determine tagname const tagname = this._component.brand ? `${this._component.brand}-${this._component.tagname}` : this._component.tagname; + //determine component parent + const parent = field ? 'TempleField' : 'TempleComponent'; //get path without extension //ex. /path/to/Counter.tml -> /path/to/Counter const extname = path.extname(absolute); const filePath = absolute.slice(0, -extname.length); //create a new source file const { source } = this._createSourceFile(`${filePath}.ts`); - //import { TempleRegistry, TempleComponent } from '@ossph/temple/client'; + //import TempleRegistry from '@ossph/temple/dist/client/TempleRegistry'; source.addImportDeclaration({ - moduleSpecifier: '@ossph/temple/client', - namedImports: [ 'TempleRegistry', 'TempleComponent' ] + moduleSpecifier: '@ossph/temple/dist/client/TempleRegistry', + defaultImport: 'TempleRegistry' + }); + //import TempleComponent from '@ossph/temple/dist/client/TempleComponent'; + source.addImportDeclaration({ + moduleSpecifier: `@ossph/temple/dist/client/${parent}`, + defaultImport: parent }); //import Counter from './Counter' this._component.components.filter( @@ -156,7 +165,7 @@ export default class Transpiler { //export default class FoobarComponent extends TempleComponent const component = source.addClass({ name: classname, - extends: 'TempleComponent', + extends: parent, isDefaultExport: true, }); //public static component = ['foo-bar', 'FoobarComponent']; @@ -165,6 +174,14 @@ export default class Transpiler { isStatic: true, initializer: `[ '${tagname}', '${classname}' ] as [ string, string ]` }); + //public static observedAttributes = ["required", "value"] + if (observe.length > 0) { + component.addProperty({ + name: 'observedAttributes', + isStatic: true, + initializer: JSON.stringify(observe) + }); + } //public style() component.addMethod({ name: 'styles', diff --git a/packages/temple/src/document/Transpiler.ts b/packages/temple/src/document/Transpiler.ts index 3e35e41..9336048 100644 --- a/packages/temple/src/document/Transpiler.ts +++ b/packages/temple/src/document/Transpiler.ts @@ -121,15 +121,25 @@ export default class Transpiler extends ComponentTranspiler { 'Hash' ] }); - //import { TempleRegistry, emitter, data as __APP_DATA__ } from '@ossph/temple/client'; + //import TempleException from '@ossph/temple/dist/Exception'; source.addImportDeclaration({ - moduleSpecifier: '@ossph/temple/client', - namedImports: [ - 'TempleRegistry', - 'TempleException', - 'emitter', - 'data as __APP_DATA__' - ] + moduleSpecifier: '@ossph/temple/dist/Exception', + defaultImport: 'TempleException' + }); + //import TempleRegistry from '@ossph/temple/dist/client/TempleRegistry'; + source.addImportDeclaration({ + moduleSpecifier: '@ossph/temple/dist/client/TempleRegistry', + defaultImport: 'TempleRegistry' + }); + //import emitter from '@ossph/temple/dist/client/TempleEmitter'; + source.addImportDeclaration({ + moduleSpecifier: '@ossph/temple/dist/client/TempleEmitter', + defaultImport: 'emitter' + }); + //import __APP_DATA__ from '@ossph/temple/dist/client/data'; + source.addImportDeclaration({ + moduleSpecifier: '@ossph/temple/dist/client/data', + defaultImport: '__APP_DATA__' }); //import Counter_abc123 from './Counter_abc123' registry.forEach(component => { diff --git a/packages/temple/tests/fixtures/page.dtml b/packages/temple/tests/fixtures/page.dtml index 5791503..dc2615a 100644 --- a/packages/temple/tests/fixtures/page.dtml +++ b/packages/temple/tests/fixtures/page.dtml @@ -8,7 +8,7 @@ padding: 0; } -