From bf581dc5844b718ae46248cda2dbe6b2e9a447b5 Mon Sep 17 00:00:00 2001 From: garrettmills Date: Sun, 12 Dec 2021 23:21:47 -0600 Subject: [PATCH] Start modal, page, form, form fields, logical components, session service, login page --- index.html | 10 ++ login.html | 26 +++++ src/ExComponent.ts | 61 +++++++++- src/auth/LoginPage.component.ts | 69 ++++++++++++ src/form/Form.component.ts | 62 +++++++++++ src/form/SubmitButton.component.ts | 63 +++++++++++ src/form/fields/EmailInputField.component.ts | 28 +++++ src/form/fields/Input.component.ts | 28 +++++ src/form/fields/NumberInputField.component.ts | 68 ++++++++++++ .../fields/PasswordInputField.component.ts | 62 +++++++++++ src/form/fields/PhoneInputField.component.ts | 13 +++ src/form/fields/SearchInputField.component.ts | 19 ++++ src/form/fields/TextInputField.component.ts | 105 ++++++++++++++++++ src/form/fields/URLInputField.component.ts | 13 +++ src/form/types.ts | 52 +++++++++ src/index.ts | 19 ++++ src/layout/Modal.component.ts | 6 +- src/layout/Page.component.ts | 5 +- src/layout/Section.component.ts | 5 +- src/logical/Logical.component.ts | 15 +++ src/logical/Session.component.ts | 19 ++++ src/nav/NavBar.component.ts | 5 +- src/service/Session.service.ts | 26 +++++ 23 files changed, 772 insertions(+), 7 deletions(-) create mode 100644 login.html create mode 100644 src/auth/LoginPage.component.ts create mode 100644 src/form/Form.component.ts create mode 100644 src/form/SubmitButton.component.ts create mode 100644 src/form/fields/EmailInputField.component.ts create mode 100644 src/form/fields/Input.component.ts create mode 100644 src/form/fields/NumberInputField.component.ts create mode 100644 src/form/fields/PasswordInputField.component.ts create mode 100644 src/form/fields/PhoneInputField.component.ts create mode 100644 src/form/fields/SearchInputField.component.ts create mode 100644 src/form/fields/TextInputField.component.ts create mode 100644 src/form/fields/URLInputField.component.ts create mode 100644 src/form/types.ts create mode 100644 src/logical/Logical.component.ts create mode 100644 src/logical/Session.component.ts create mode 100644 src/service/Session.service.ts diff --git a/index.html b/index.html index 0294b6b..0051487 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,16 @@ +

Test Form!

+ + + + + + + + +

Header 1

Example anchor tag: look no further diff --git a/login.html b/login.html new file mode 100644 index 0000000..654369d --- /dev/null +++ b/login.html @@ -0,0 +1,26 @@ + + + + + Test Page + + + + + + + + {"app.name": "Extollo"} + + + diff --git a/src/ExComponent.ts b/src/ExComponent.ts index 3648ec3..02df434 100644 --- a/src/ExComponent.ts +++ b/src/ExComponent.ts @@ -1,7 +1,23 @@ +import {SessionService} from './service/Session.service.js' export abstract class ExComponent extends HTMLElement { + protected static styles = ` + + ` + protected static html = `` + protected static inheritStyles = true + static get observedAttributes() { const map: any = (this as any).exPropertyAttributeMapping if ( Array.isArray(map) ) { @@ -23,8 +39,13 @@ export abstract class ExComponent extends HTMLElement { private didMount = false + protected uuid: string + constructor() { super() + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.uuid = ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)) this.setPropertyAttributeMappings() this.setPropertyRendersMappings() this.attachTemplate() @@ -46,7 +67,21 @@ export abstract class ExComponent extends HTMLElement { attachTemplate() { const template = document.createElement('template') - template.innerHTML = ((this as any).constructor as typeof ExComponent).html + let html = ((this as any).constructor as typeof ExComponent).styles + '\n' + ((this as any).constructor as typeof ExComponent).html + + this.forEachParent(this, ctor => { + if ( !ctor.styles ) { + return + } + + html = `${ctor.styles}\n${html}` + + if ( !ctor.inheritStyles ) { + return false + } + }) + + template.innerHTML = html this.shadow.appendChild(template.content.cloneNode(true)) } @@ -92,7 +127,7 @@ export abstract class ExComponent extends HTMLElement { delete this[mapping.property] Object.defineProperty(this, mapping.property, { - get: () => this.getAttribute(mapping.attribute), + get: () => this.getOptionalAttribute(mapping.attribute), set: value => { if ( !this.hadFirstRender ) { this.preFirstRenderDefaultAttributes[mapping.attribute] = value @@ -136,4 +171,26 @@ export abstract class ExComponent extends HTMLElement { this.hadFirstRender = true } + + getOptionalAttribute(name: string): string | undefined { + const val = this.getAttribute(name) + if ( val && val !== 'undefined' ) { + return val + } + } + + forEachParent(inst: ExComponent, callback: (ctor: typeof ExComponent) => unknown) { + let ctor = inst.constructor as typeof ExComponent + do { + if ( callback(ctor) === false ) { + return + } + + ctor = Object.getPrototypeOf(ctor) + } while ( ctor && (ctor.prototype instanceof ExComponent || ctor === ExComponent) ) + } + + session(): SessionService { + return SessionService.get() + } } diff --git a/src/auth/LoginPage.component.ts b/src/auth/LoginPage.component.ts new file mode 100644 index 0000000..8f7bb6a --- /dev/null +++ b/src/auth/LoginPage.component.ts @@ -0,0 +1,69 @@ +import {ExComponent} from '../ExComponent.js' +import {Attribute, Component, Element} from '../decorators.js' +import {FormComponent} from '../form/Form.component.js' + +@Component('ex-page-login') +export class LoginPageComponent extends ExComponent { + protected static styles = ` + + ` + + protected static html = ` +

+ +

+ + + + + + +
+
+ ` + + @Attribute() + public message = 'Login to continue' + + @Attribute() + public formname = 'login' + + @Element('h1.app-name') + protected appNameEl!: HTMLHeadingElement + + @Element('h2.login-message') + protected loginMessageEl!: HTMLHeadingElement + + @Element('ex-form') + protected formEl!: FormComponent + + render() { + super.render() + + const name = this.session().get('app.name') + this.appNameEl.hidden = !name + this.appNameEl.innerText = name + + this.loginMessageEl.hidden = !this.message + this.loginMessageEl.innerText = this.message + + this.formEl.setAttribute('name', this.formname) + } +} diff --git a/src/form/Form.component.ts b/src/form/Form.component.ts new file mode 100644 index 0000000..0e84eee --- /dev/null +++ b/src/form/Form.component.ts @@ -0,0 +1,62 @@ +import {ExComponent} from '../ExComponent.js' +import {Attribute, Component} from '../decorators.js' +import {FormControl, FormField, FormData} from './types.js' + +@Component('ex-form') +export class FormComponent extends ExComponent implements FormControl { + protected static html = ` + + +
+ +
+ ` + + protected fields: FormField[] = [] + + @Attribute() + public name?: string + + hasField(field: FormField): boolean { + return this.fields.some(maybe => maybe.getFieldName() === field.getFieldName()) + } + + registerField(field: FormField): void { + this.fields.push(field) + } + + gather(): FormData { + const data: FormData = {} + + for ( const field of this.fields ) { + data[field.getFieldName()] = field.getValue() + } + + return data + } + + processChanges(): void { + for ( const field of this.fields ) { + field.validate() + } + } + + isValid(): boolean { + let valid = true + + for ( const field of this.fields ) { + valid = field.validate() && valid + } + + return valid + } + + async submit(): Promise { + (3 + 4) + } +} diff --git a/src/form/SubmitButton.component.ts b/src/form/SubmitButton.component.ts new file mode 100644 index 0000000..433d775 --- /dev/null +++ b/src/form/SubmitButton.component.ts @@ -0,0 +1,63 @@ +import {ExComponent} from '../ExComponent.js' +import {Attribute, Component, Element} from '../decorators.js' + +@Component('ex-submit') +export class SubmitButtonComponent extends ExComponent { + protected static styles = ` + + ` + + protected static html = ` +
+ +
+ ` + + @Attribute() + public label = 'Submit' + + @Element('button[type=submit]') + protected buttonEl!: HTMLButtonElement + + mount() { + super.mount() + + this.buttonEl.addEventListener('click', () => { + this.dispatchCustom('onSubmit', {}) + }) + } + + render() { + super.render() + + this.buttonEl.innerText = this.label + } +} diff --git a/src/form/fields/EmailInputField.component.ts b/src/form/fields/EmailInputField.component.ts new file mode 100644 index 0000000..4cbe819 --- /dev/null +++ b/src/form/fields/EmailInputField.component.ts @@ -0,0 +1,28 @@ +import {TextInputFieldComponent} from './TextInputField.component.js' +import {Component} from '../../decorators.js' + +@Component('ex-input-email') +export class EmailInputFieldComponent extends TextInputFieldComponent { + public static validationRex = /^\s*[\w\-+_]+(\.[\w\-+_]+)*@[\w\-+_]+\.[\w\-+_]+(\.[\w\-+_]+)*\s*$/ + + protected static html = ` + + ` + + validate(): boolean { + if ( !super.validate() ) { + return false + } + + if ( !String(this.getValue()).match(EmailInputFieldComponent.validationRex) ) { + this.error = 'Value must be a valid e-mail address' + return false + } + + return true + } +} diff --git a/src/form/fields/Input.component.ts b/src/form/fields/Input.component.ts new file mode 100644 index 0000000..d6a3815 --- /dev/null +++ b/src/form/fields/Input.component.ts @@ -0,0 +1,28 @@ +import {ExComponent} from '../../ExComponent.js' +import {FormControl, FormField} from '../types.js' +import {FormComponent} from '../Form.component.js' + +export abstract class InputComponent extends ExComponent implements FormField { + mount() { + super.mount() + this.control()?.registerField(this) + } + + protected control(): undefined | FormControl { + let parent = this.parentElement + + while ( parent ) { + if ( parent instanceof FormComponent ) { + return parent + } + + parent = parent.parentElement + } + } + + abstract getFieldName(): string + + abstract getValue(): any + + abstract validate(): boolean +} diff --git a/src/form/fields/NumberInputField.component.ts b/src/form/fields/NumberInputField.component.ts new file mode 100644 index 0000000..629f430 --- /dev/null +++ b/src/form/fields/NumberInputField.component.ts @@ -0,0 +1,68 @@ +import {TextInputFieldComponent} from './TextInputField.component.js' +import {Attribute, Component} from '../../decorators.js' + +@Component('ex-input-number') +export class NumberInputFieldComponent extends TextInputFieldComponent { + protected static html = ` + + ` + + @Attribute() + public max?: number + + @Attribute() + public min?: number + + @Attribute() + public step?: number + + render() { + super.render() + + const max = (!this.max && this.max !== 0) ? '' : String(this.max) + this.inputEl.setAttribute('max', max) + + const min = (!this.min && this.min !== 0) ? '' : String(this.min) + this.inputEl.setAttribute('min', min) + + const step = (!this.step && this.step !== 0) ? '' : String(this.step) + this.inputEl.setAttribute('step', step) + } + + validate(): boolean { + if ( !super.validate() ) { + return false + } + + const num = this.getValue() + if ( isNaN(num) ) { + this.error = 'Value must be a number' + return false + } + + if ( (this.max || this.max === 0) && num > this.max ) { + this.error = `Value must be at most ${this.max}` + return false + } + + if ( (this.min || this.min === 0) && num < this.min ) { + this.error = `Value must be at least ${this.min}` + return false + } + + if ( this.step && num % this.step !== 0 ) { + this.error = `Value must be a multiple of ${this.step}` + return false + } + + return true + } + + getValue(): number { + return parseFloat(super.getValue()) + } +} diff --git a/src/form/fields/PasswordInputField.component.ts b/src/form/fields/PasswordInputField.component.ts new file mode 100644 index 0000000..ef5748c --- /dev/null +++ b/src/form/fields/PasswordInputField.component.ts @@ -0,0 +1,62 @@ +import {TextInputFieldComponent} from './TextInputField.component.js' +import {Component, Element} from '../../decorators.js' + +@Component('ex-input-password') +export class PasswordInputFieldComponent extends TextInputFieldComponent { + protected static styles = ` + + ` + + protected static html = ` +
+ + +
+ ` + + @Element('input.confirm') + protected confirmEl!: HTMLInputElement + + render() { + super.render() + + const hasConfirm = this.hasAttribute('confirmed') + this.confirmEl.hidden = !hasConfirm + + if ( hasConfirm ) { + const label = this.label || this.placeholder + this.confirmEl.setAttribute('placeholder', (label ? label + ' ' : '') + 'Confirmation') + this.confirmEl.setAttribute('name', (this.name || this.uuid) + '_confirm') + } + + if ( this.hasAttribute('required') && hasConfirm ) { + this.confirmEl.setAttribute('required', '1') + } else { + this.confirmEl.removeAttribute('required') + } + } + + validate(): boolean { + if ( !super.validate() ) { + return false + } + + if ( this.hasAttribute('confirmed') && this.confirmEl.value !== this.getValue() ) { + this.error = 'Confirmation does not match' + return false + } + + return true + } +} diff --git a/src/form/fields/PhoneInputField.component.ts b/src/form/fields/PhoneInputField.component.ts new file mode 100644 index 0000000..1bfe56a --- /dev/null +++ b/src/form/fields/PhoneInputField.component.ts @@ -0,0 +1,13 @@ +import {TextInputFieldComponent} from './TextInputField.component.js' +import {Component} from '../../decorators.js' + +@Component('ex-input-phone') +export class PhoneInputFieldComponent extends TextInputFieldComponent { + protected static html = ` + + ` +} diff --git a/src/form/fields/SearchInputField.component.ts b/src/form/fields/SearchInputField.component.ts new file mode 100644 index 0000000..c97f0cf --- /dev/null +++ b/src/form/fields/SearchInputField.component.ts @@ -0,0 +1,19 @@ +import {TextInputFieldComponent} from './TextInputField.component.js' +import {Component} from '../../decorators.js' + +@Component('ex-input-search') +export class SearchInputFieldComponent extends TextInputFieldComponent { + protected static html = ` + + ` + + render() { + super.render() + + this.inputEl.setAttribute('placeholder', `🔎︎ ${this.inputEl.getAttribute('placeholder') || ''}`) + } +} diff --git a/src/form/fields/TextInputField.component.ts b/src/form/fields/TextInputField.component.ts new file mode 100644 index 0000000..8fecb99 --- /dev/null +++ b/src/form/fields/TextInputField.component.ts @@ -0,0 +1,105 @@ +import {Attribute, Component, Element} from '../../decorators.js' +import {InputComponent} from './Input.component.js' + +@Component('ex-input') +export class TextInputFieldComponent extends InputComponent { + protected static styles = ` + + ` + + protected static html = ` + + ` + + @Attribute() + public name?: string + + @Attribute() + public placeholder?: string + + @Attribute() + public label?: string + + @Attribute() + public error?: string + + @Attribute() + public minlength?: string + + @Attribute() + public maxlength?: string + + @Element('span.label') + protected labelEl!: HTMLSpanElement + + @Element('input.input') + protected inputEl!: HTMLInputElement + + @Element('small.error') + protected errorEl!: HTMLElement + + render() { + super.render() + + this.labelEl.innerText = this.label || '' + this.inputEl.setAttribute('placeholder', this.placeholder || '') + this.inputEl.setAttribute('name', this.name || this.uuid) + + if ( this.hasAttribute('required') ) { + this.inputEl.setAttribute('required', '1') + } else { + this.inputEl.removeAttribute('required') + } + + this.errorEl.hidden = !this.error + this.errorEl.innerText = this.error || '' + } + + getFieldName(): string { + return this.name || this.uuid + } + + getValue(): any { + return this.inputEl.value + } + + validate(): boolean { + const isValid = ( + !this.hasAttribute('required') + || this.getValue().trim() + ) + + if ( !isValid ) { + this.error = 'This field is required' + } else { + this.error = '' + } + + const min = parseInt(this.minlength || '', 10) + if ( this.minlength && !isNaN(min) && String(this.getValue()).length < min ) { + this.error = `Value must be at least ${min} characters` + } + + const max = parseInt(this.maxlength || '', 10) + if ( this.maxlength && !isNaN(max) && String(this.getValue()).length > max ) { + this.error = `Value must be at most ${max} characters` + } + + return isValid + } +} diff --git a/src/form/fields/URLInputField.component.ts b/src/form/fields/URLInputField.component.ts new file mode 100644 index 0000000..3edcf04 --- /dev/null +++ b/src/form/fields/URLInputField.component.ts @@ -0,0 +1,13 @@ +import {TextInputFieldComponent} from './TextInputField.component.js' +import {Component} from '../../decorators.js' + +@Component('ex-input-url') +export class URLInputFieldComponent extends TextInputFieldComponent { + protected static html = ` + + ` +} diff --git a/src/form/types.ts b/src/form/types.ts new file mode 100644 index 0000000..dda99e3 --- /dev/null +++ b/src/form/types.ts @@ -0,0 +1,52 @@ +export enum FormFieldType { + text, + password, + url, + email, + phone, + search, + number, + error, + select, + checkboxes, + textarea, + date, + color, +} + +export interface FormFieldConfig { + type: FormFieldType, + name: string, + title: string, + required?: boolean, +} + +export interface FormConfig { + endpoint: string, + method?: 'get'|'post'|'put'|'delete'|'patch', + fields: (FormFieldConfig)[], +} + +export type FormData = {[key: string]: any} + +export interface FormField { + getFieldName(): string + + getValue(): any + + validate(): boolean +} + +export interface FormControl { + hasField(field: FormField): boolean + + registerField(field: FormField): void + + processChanges(): void + + gather(): FormData | Promise + + submit(): void | Promise + + isValid(): boolean +} diff --git a/src/index.ts b/src/index.ts index 6f4c6ef..986e92f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,25 @@ import './resources/theme.css' + +export * from './service/Session.service.js' + +export * from './logical/Logical.component.js' +export * from './logical/Session.component.js' + export * from './Test.component.js' export * from './layout/Page.component.js' export * from './layout/Section.component.js' export * from './layout/Modal.component.js' export * from './nav/NavBar.component.js' + +export * from './form/types.js' +export * from './form/fields/TextInputField.component.js' +export * from './form/fields/PasswordInputField.component.js' +export * from './form/fields/URLInputField.component.js' +export * from './form/fields/EmailInputField.component.js' +export * from './form/fields/PhoneInputField.component.js' +export * from './form/fields/SearchInputField.component.js' +export * from './form/fields/NumberInputField.component.js' +export * from './form/SubmitButton.component.js' +export * from './form/Form.component.js' + +export * from './auth/LoginPage.component.js' diff --git a/src/layout/Modal.component.ts b/src/layout/Modal.component.ts index 6d62f7e..9d4c0ff 100644 --- a/src/layout/Modal.component.ts +++ b/src/layout/Modal.component.ts @@ -3,7 +3,7 @@ import {Attribute, Component, Element, Renders} from '../decorators.js' @Component('ex-modal') export class ModalComponent extends ExComponent { - protected static html = ` + protected static styles = ` - + ` + + protected static html = `
diff --git a/src/layout/Page.component.ts b/src/layout/Page.component.ts index e667750..426138c 100644 --- a/src/layout/Page.component.ts +++ b/src/layout/Page.component.ts @@ -3,7 +3,7 @@ import {Component} from '../decorators.js' @Component('ex-page') export class PageComponent extends ExComponent { - protected static html = ` + protected static styles = ` + ` + + protected static html = `
diff --git a/src/layout/Section.component.ts b/src/layout/Section.component.ts index f1a5913..b1e2f75 100644 --- a/src/layout/Section.component.ts +++ b/src/layout/Section.component.ts @@ -3,12 +3,15 @@ import {Component} from '../decorators.js' @Component('ex-section') export class SectionComponent extends ExComponent { - protected static html = ` + protected static styles = ` + ` + + protected static html = `
diff --git a/src/logical/Logical.component.ts b/src/logical/Logical.component.ts new file mode 100644 index 0000000..d516e80 --- /dev/null +++ b/src/logical/Logical.component.ts @@ -0,0 +1,15 @@ +import {ExComponent} from '../ExComponent.js' + +export abstract class LogicalComponent extends ExComponent { + protected static styles = ` + + ` + + public value(): string { + return this.innerText.trim() + } +} diff --git a/src/logical/Session.component.ts b/src/logical/Session.component.ts new file mode 100644 index 0000000..73dfa54 --- /dev/null +++ b/src/logical/Session.component.ts @@ -0,0 +1,19 @@ +import {LogicalComponent} from './Logical.component.js' +import {Component} from '../decorators.js' + +@Component('ex-session') +export class SessionComponent extends LogicalComponent { + mount() { + super.mount() + + ;(window as any).session = this.session() + + try { + const data = JSON.parse(this.value()) + this.session().populate(data) + } catch (e) { + console.error('Error parsing JSON session data!', this.value()) // eslint-disable-line no-console + throw e + } + } +} diff --git a/src/nav/NavBar.component.ts b/src/nav/NavBar.component.ts index 1fe1082..db486b7 100644 --- a/src/nav/NavBar.component.ts +++ b/src/nav/NavBar.component.ts @@ -10,7 +10,7 @@ export interface NavBarItem { @Component('ex-nav') export class NavBarComponent extends ExComponent { - protected static html = ` + protected static styles = ` + ` + + protected static html = `