generated from garrettmills/template-npm-typescript
Start modal, page, form, form fields, logical components, session service, login page
This commit is contained in:
parent
a238136d94
commit
bf581dc584
10
index.html
10
index.html
@ -22,6 +22,16 @@
|
|||||||
<body>
|
<body>
|
||||||
<ex-page>
|
<ex-page>
|
||||||
<ex-nav appName="Extollo"></ex-nav>
|
<ex-nav appName="Extollo"></ex-nav>
|
||||||
|
<h1>Test Form!</h1>
|
||||||
|
<ex-form>
|
||||||
|
<ex-input name="test" placeholder="Name"></ex-input>
|
||||||
|
<ex-input-password name="pw" placeholder="Password" confirmed></ex-input-password>
|
||||||
|
<ex-input-url name="url" placeholder="URL"></ex-input-url>
|
||||||
|
<ex-input-email name="email" placeholder="E-Mail"></ex-input-email>
|
||||||
|
<ex-input-phone name="phone" placeholder="Phone"></ex-input-phone>
|
||||||
|
<ex-input-search name="search" placeholder="Search"></ex-input-search>
|
||||||
|
<ex-input-number name="number" min="40" step="10" placeholder="Number"></ex-input-number>
|
||||||
|
</ex-form>
|
||||||
<h1>Header 1</h1>
|
<h1>Header 1</h1>
|
||||||
<p>
|
<p>
|
||||||
Example anchor tag: <a href="#">look no further</a>
|
Example anchor tag: <a href="#">look no further</a>
|
||||||
|
26
login.html
Normal file
26
login.html
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Test Page</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
if ( typeof $ !== 'function' ) {
|
||||||
|
window.$ = (...args) => document.querySelector(...args)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="dist/extollo-ui.dist.js" type="module"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<ex-session>{"app.name": "Extollo"}</ex-session>
|
||||||
|
<ex-page-login></ex-page-login>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -1,7 +1,23 @@
|
|||||||
|
import {SessionService} from './service/Session.service.js'
|
||||||
|
|
||||||
export abstract class ExComponent extends HTMLElement {
|
export abstract class ExComponent extends HTMLElement {
|
||||||
|
protected static styles = `
|
||||||
|
<style>
|
||||||
|
input {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px solid var(--color-accent-text);
|
||||||
|
font-size: 1em;
|
||||||
|
background: var(--color-background-darkened);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`
|
||||||
|
|
||||||
protected static html = ``
|
protected static html = ``
|
||||||
|
|
||||||
|
protected static inheritStyles = true
|
||||||
|
|
||||||
static get observedAttributes() {
|
static get observedAttributes() {
|
||||||
const map: any = (this as any).exPropertyAttributeMapping
|
const map: any = (this as any).exPropertyAttributeMapping
|
||||||
if ( Array.isArray(map) ) {
|
if ( Array.isArray(map) ) {
|
||||||
@ -23,8 +39,13 @@ export abstract class ExComponent extends HTMLElement {
|
|||||||
|
|
||||||
private didMount = false
|
private didMount = false
|
||||||
|
|
||||||
|
protected uuid: string
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
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.setPropertyAttributeMappings()
|
||||||
this.setPropertyRendersMappings()
|
this.setPropertyRendersMappings()
|
||||||
this.attachTemplate()
|
this.attachTemplate()
|
||||||
@ -46,7 +67,21 @@ export abstract class ExComponent extends HTMLElement {
|
|||||||
|
|
||||||
attachTemplate() {
|
attachTemplate() {
|
||||||
const template = document.createElement('template')
|
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))
|
this.shadow.appendChild(template.content.cloneNode(true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,7 +127,7 @@ export abstract class ExComponent extends HTMLElement {
|
|||||||
delete this[mapping.property]
|
delete this[mapping.property]
|
||||||
|
|
||||||
Object.defineProperty(this, mapping.property, {
|
Object.defineProperty(this, mapping.property, {
|
||||||
get: () => this.getAttribute(mapping.attribute),
|
get: () => this.getOptionalAttribute(mapping.attribute),
|
||||||
set: value => {
|
set: value => {
|
||||||
if ( !this.hadFirstRender ) {
|
if ( !this.hadFirstRender ) {
|
||||||
this.preFirstRenderDefaultAttributes[mapping.attribute] = value
|
this.preFirstRenderDefaultAttributes[mapping.attribute] = value
|
||||||
@ -136,4 +171,26 @@ export abstract class ExComponent extends HTMLElement {
|
|||||||
|
|
||||||
this.hadFirstRender = true
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
69
src/auth/LoginPage.component.ts
Normal file
69
src/auth/LoginPage.component.ts
Normal file
@ -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 = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding-top: 200px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-message {
|
||||||
|
margin-bottom: 50px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`
|
||||||
|
|
||||||
|
protected static html = `
|
||||||
|
<div class="container">
|
||||||
|
<ex-page>
|
||||||
|
<h1 class="app-name"></h1>
|
||||||
|
<h2 class="login-message"></h2>
|
||||||
|
<ex-form>
|
||||||
|
<ex-input name="username" placeholder="Username" required></ex-input>
|
||||||
|
<ex-input-password name="password" placeholder="Password" required></ex-input-password>
|
||||||
|
<ex-submit label="Login"></ex-submit>
|
||||||
|
</ex-form>
|
||||||
|
</ex-page>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
62
src/form/Form.component.ts
Normal file
62
src/form/Form.component.ts
Normal file
@ -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 = `
|
||||||
|
<style>
|
||||||
|
slot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
(3 + 4)
|
||||||
|
}
|
||||||
|
}
|
63
src/form/SubmitButton.component.ts
Normal file
63
src/form/SubmitButton.component.ts
Normal file
@ -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 = `
|
||||||
|
<style>
|
||||||
|
button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 7px 12px;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: var(--color-text-on-accent);
|
||||||
|
border: 1px solid var(--color-accent-darkened);
|
||||||
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 7px;
|
||||||
|
|
||||||
|
transition: background-color 0.1s linear;
|
||||||
|
width: unset;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
background-color: var(--color-accent-darkened);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 0 20px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`
|
||||||
|
|
||||||
|
protected static html = `
|
||||||
|
<div class="container">
|
||||||
|
<button type="submit"></button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
}
|
28
src/form/fields/EmailInputField.component.ts
Normal file
28
src/form/fields/EmailInputField.component.ts
Normal file
@ -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 = `
|
||||||
|
<label>
|
||||||
|
<span class="label"></span>
|
||||||
|
<input class="input" type="email">
|
||||||
|
<small class="error"></small>
|
||||||
|
</label>
|
||||||
|
`
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
28
src/form/fields/Input.component.ts
Normal file
28
src/form/fields/Input.component.ts
Normal file
@ -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
|
||||||
|
}
|
68
src/form/fields/NumberInputField.component.ts
Normal file
68
src/form/fields/NumberInputField.component.ts
Normal file
@ -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 = `
|
||||||
|
<label>
|
||||||
|
<span class="label"></span>
|
||||||
|
<input class="input" type="number">
|
||||||
|
<small class="error"></small>
|
||||||
|
</label>
|
||||||
|
`
|
||||||
|
|
||||||
|
@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())
|
||||||
|
}
|
||||||
|
}
|
62
src/form/fields/PasswordInputField.component.ts
Normal file
62
src/form/fields/PasswordInputField.component.ts
Normal file
@ -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 = `
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`
|
||||||
|
|
||||||
|
protected static html = `
|
||||||
|
<div class="container">
|
||||||
|
<label>
|
||||||
|
<span class="label"></span>
|
||||||
|
<input class="input" type="password">
|
||||||
|
<small class="error"></small>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input class="input confirm" type="password">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
}
|
13
src/form/fields/PhoneInputField.component.ts
Normal file
13
src/form/fields/PhoneInputField.component.ts
Normal file
@ -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 = `
|
||||||
|
<label>
|
||||||
|
<span class="label"></span>
|
||||||
|
<input class="input" type="tel">
|
||||||
|
<small class="error"></small>
|
||||||
|
</label>
|
||||||
|
`
|
||||||
|
}
|
19
src/form/fields/SearchInputField.component.ts
Normal file
19
src/form/fields/SearchInputField.component.ts
Normal file
@ -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 = `
|
||||||
|
<label>
|
||||||
|
<span class="label"></span>
|
||||||
|
<input class="input" type="search">
|
||||||
|
<small class="error"></small>
|
||||||
|
</label>
|
||||||
|
`
|
||||||
|
|
||||||
|
render() {
|
||||||
|
super.render()
|
||||||
|
|
||||||
|
this.inputEl.setAttribute('placeholder', `🔎︎ ${this.inputEl.getAttribute('placeholder') || ''}`)
|
||||||
|
}
|
||||||
|
}
|
105
src/form/fields/TextInputField.component.ts
Normal file
105
src/form/fields/TextInputField.component.ts
Normal file
@ -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 = `
|
||||||
|
<style>
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
small.error {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`
|
||||||
|
|
||||||
|
protected static html = `
|
||||||
|
<label>
|
||||||
|
<span class="label"></span>
|
||||||
|
<input class="input" type="text">
|
||||||
|
<small class="error"></small>
|
||||||
|
</label>
|
||||||
|
`
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
}
|
13
src/form/fields/URLInputField.component.ts
Normal file
13
src/form/fields/URLInputField.component.ts
Normal file
@ -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 = `
|
||||||
|
<label>
|
||||||
|
<span class="label"></span>
|
||||||
|
<input class="input" type="url">
|
||||||
|
<small class="error"></small>
|
||||||
|
</label>
|
||||||
|
`
|
||||||
|
}
|
52
src/form/types.ts
Normal file
52
src/form/types.ts
Normal file
@ -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<FormData>
|
||||||
|
|
||||||
|
submit(): void | Promise<void>
|
||||||
|
|
||||||
|
isValid(): boolean
|
||||||
|
}
|
19
src/index.ts
19
src/index.ts
@ -1,6 +1,25 @@
|
|||||||
import './resources/theme.css'
|
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 './Test.component.js'
|
||||||
export * from './layout/Page.component.js'
|
export * from './layout/Page.component.js'
|
||||||
export * from './layout/Section.component.js'
|
export * from './layout/Section.component.js'
|
||||||
export * from './layout/Modal.component.js'
|
export * from './layout/Modal.component.js'
|
||||||
export * from './nav/NavBar.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'
|
||||||
|
@ -3,7 +3,7 @@ import {Attribute, Component, Element, Renders} from '../decorators.js'
|
|||||||
|
|
||||||
@Component('ex-modal')
|
@Component('ex-modal')
|
||||||
export class ModalComponent extends ExComponent {
|
export class ModalComponent extends ExComponent {
|
||||||
protected static html = `
|
protected static styles = `
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
display: none;
|
display: none;
|
||||||
@ -87,7 +87,9 @@ export class ModalComponent extends ExComponent {
|
|||||||
margin-right: 14px;
|
margin-right: 14px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
`
|
||||||
|
|
||||||
|
protected static html = `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="ex-modal" tabindex="0">
|
<div class="ex-modal" tabindex="0">
|
||||||
<header>
|
<header>
|
||||||
|
@ -3,7 +3,7 @@ import {Component} from '../decorators.js'
|
|||||||
|
|
||||||
@Component('ex-page')
|
@Component('ex-page')
|
||||||
export class PageComponent extends ExComponent {
|
export class PageComponent extends ExComponent {
|
||||||
protected static html = `
|
protected static styles = `
|
||||||
<style>
|
<style>
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -26,6 +26,9 @@ export class PageComponent extends ExComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
`
|
||||||
|
|
||||||
|
protected static html = `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="inner">
|
<div class="inner">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
|
@ -3,12 +3,15 @@ import {Component} from '../decorators.js'
|
|||||||
|
|
||||||
@Component('ex-section')
|
@Component('ex-section')
|
||||||
export class SectionComponent extends ExComponent {
|
export class SectionComponent extends ExComponent {
|
||||||
protected static html = `
|
protected static styles = `
|
||||||
<style>
|
<style>
|
||||||
.container {
|
.container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
`
|
||||||
|
|
||||||
|
protected static html = `
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
|
15
src/logical/Logical.component.ts
Normal file
15
src/logical/Logical.component.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import {ExComponent} from '../ExComponent.js'
|
||||||
|
|
||||||
|
export abstract class LogicalComponent extends ExComponent {
|
||||||
|
protected static styles = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`
|
||||||
|
|
||||||
|
public value(): string {
|
||||||
|
return this.innerText.trim()
|
||||||
|
}
|
||||||
|
}
|
19
src/logical/Session.component.ts
Normal file
19
src/logical/Session.component.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -10,7 +10,7 @@ export interface NavBarItem {
|
|||||||
|
|
||||||
@Component('ex-nav')
|
@Component('ex-nav')
|
||||||
export class NavBarComponent extends ExComponent {
|
export class NavBarComponent extends ExComponent {
|
||||||
protected static html = `
|
protected static styles = `
|
||||||
<style>
|
<style>
|
||||||
nav {
|
nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -63,6 +63,9 @@ export class NavBarComponent extends ExComponent {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
`
|
||||||
|
|
||||||
|
protected static html = `
|
||||||
<nav>
|
<nav>
|
||||||
<slot name="branding"></slot>
|
<slot name="branding"></slot>
|
||||||
<span class="appName"></span>
|
<span class="appName"></span>
|
||||||
|
26
src/service/Session.service.ts
Normal file
26
src/service/Session.service.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
export class SessionService {
|
||||||
|
private static instance?: SessionService
|
||||||
|
|
||||||
|
public static get(): SessionService {
|
||||||
|
if ( !this.instance ) {
|
||||||
|
this.instance = new SessionService()
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
protected data: {[key: string]: any} = {}
|
||||||
|
|
||||||
|
populate(data: {[key: string]: any}) {
|
||||||
|
this.data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string, fallback?: any): any {
|
||||||
|
return this.data[key] ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, value: any) {
|
||||||
|
this.data[key] = value
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user