diff --git a/README.md b/README.md index 1ab27b7..4cf4812 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# tp-element +# tp-form diff --git a/package.json b/package.json index c39fdff..d4ca9ea 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { - "name": "@tp/tp-element", - "version": "0.0.1", + "name": "@tp/tp-form", + "version": "1.0.0", "description": "", - "main": "tp-element.js", + "main": "tp-form.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { "type": "git", - "url": "https://gitea.codeblob.work/tp-elements/tp-element.git" + "url": "https://gitea.codeblob.work/tp-elements/tp-form.git" }, "author": "trading_peter", "license": "Apache-2.0", diff --git a/tp-element.js b/tp-element.js deleted file mode 100644 index 6a92a2f..0000000 --- a/tp-element.js +++ /dev/null @@ -1,35 +0,0 @@ -/** -@license -Copyright (c) 2022 trading_peter -This program is available under Apache License Version 2.0 -*/ - -import { LitElement, html, css } from 'lit'; - -class TpElement extends LitElement { - static get styles() { - return [ - css` - :host { - display: block; - } - ` - ]; - } - - render() { - const { } = this; - - return html` - - `; - } - - static get properties() { - return { }; - } - - -} - -window.customElements.define('tp-element', TpElement); diff --git a/tp-form.js b/tp-form.js new file mode 100644 index 0000000..223c0e0 --- /dev/null +++ b/tp-form.js @@ -0,0 +1,436 @@ +/** +@license +Copyright (c) 2022 trading_peter +This program is available under Apache License Version 2.0 +*/ + +import { LitElement, html, css } from 'lit'; + +class TpForm extends LitElement { + static get styles() { + return [ + css` + :host { + display: block; + } + ` + ]; + } + + render() { + const { } = this; + + return html` + + `; + } + + static get properties() { + return { + // Skip validation if true. + noValidation: { type: Boolean }, + + // When true, all child input elements get the readonly attribute set. + // Only element not currently readonly are considered and only their + // readonly state is cleared when this property goes false again. + readonly: { type: Boolean }, + + // Holds all invalid controls after the forms `validate` method was invoked. + // The array can be outdated as it is only build right after the form was + // submitted and not updated afterwards. + invalidControls: { type: Array }, + + // Holds the original values of all registered form elements. + _origValues: { + type: Array, + value: function() { + return []; + } + }, + + // Holds all elements that were set readonly by the form if `readonly` is active. + _wasSetReadonly: { type: Array }, + + _form: { type: Object } + }; + } + + get _nativeElements() { + return this.querySelectorAll('input, button, textarea') || []; + } + + get registeredControls() { + return [ ...this._controls, ...(Array.from(this._nativeElements).filter(el => !this._isWrapped(el))) ]; + } + + get submitButton() { + // Check if we have a submit button that is disabled. + // In this case, don't submit. + return this._submitButton || this.querySelector('[submit]:not([disabled])') || this.querySelector('[type="submit"]:not([disabled])'); + } + + set submitButton(btn) { + this._submitButton = btn; + } + + constructor() { + super(); + this._origValues = []; + this._wasSetReadonly = []; + + this._addElement = this._addElement.bind(this); + this._removeElement = this._removeElement.bind(this); + this._nativeFormSubmit = this._nativeFormSubmit.bind(this); + this._nativeFormReset = this._nativeFormReset.bind(this); + } + + updated(changes) { + if (changes.has('readonly')) { + this._readonlyChanged(); + } + + if (changes.has('_form')) { + this._formAttached(changes.get('_form'), this._form); + } + } + + connectedCallback() { + super.connectedCallback(); + + this.addEventListener('form-element-register', this._addElement); + this.addEventListener('form-element-unregister', this._removeElement); + + // Holds all custom elements registered. + this._controls = []; + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('form-element-register', this._addElement); + this.removeEventListener('form-element-unregister', this._removeElement); + } + + _handleSlotChange(e) { + const childNodes = e.target.assignedNodes({flatten: true}); + for (const node of childNodes) { + if (node.tagName === 'FORM') { + this._form = node; + } + } + } + + /** + * Submit the form. + * This checks if all elements are valid (except validation is turned off). + * It serializes all form control data into a object. + * It sends the data via iron-ajax. + */ + submit() { + const submitBtns = this.querySelector('[submit]') || this.querySelector('[type="submit"]'); + const enabledSubmitBtns = this.submitButton; + + // Ignore the check if the form doesn't have any submitting elements. + if (submitBtns && !enabledSubmitBtns) { + return; + } + + // Validate form controls. + if (!this.noValidation && !this.validate()) { + this.dispatchEvent(new CustomEvent('invalid', { detail: this.invalidControls, bubbles: true, composed: true })); + return; + } + + // Get data. + let data = this.serialize(); + + if (this.beforeRequest) { + const result = this.beforeRequest(data); + if (result === false) { + return; + } + + data = result; + } + + this.dispatchEvent(new CustomEvent('submit', { detail: data, bubbles: true, composed: true })); + } + + serialize() { + const json = {}; + + // Serialize all added custom elements. + for (let i = 0, li = this._controls.length; i < li; i++) { + if (this._useValue(this._controls[i])) { + this._addSerializedElement(this._controls[i], json); + } + } + + // Also go through the form's native elements. + for (let i = 0, li = this._nativeElements.length; i < li; i++) { + const el = this._nativeElements[i]; + // Skip native controls that are wrapped by custom elements already registerd (e.g. ) + if (!this._useValue(el) || + (this._isWrapped(el) && json[el.name])) { + continue; + } + this._addSerializedElement(el, json); + } + + return json; + } + + _addSerializedElement(el, json) { + // Check if the object syntax is used in the elements name. + if (el.name.indexOf('.') > -1) { + const parts = el.name.split('.'); + parts.reduce((json, field, idx) => { + if (idx < parts.length - 1) { + json[field] = json[field] || {}; + return json[field]; + } else { + this._addToJson(field, el.value, json); + } + }, json); + + return; + } + + this._addToJson(el.name, el.value, json); + } + + _addToJson(field, value, json) { + // If the name doesn't exist, add it. Otherwise, serialize it to an array. + if (json[field] === undefined || json[field] === null) { + json[field] = value !== null && value !== undefined ? value : ''; + } else { + if (!Array.isArray(json[field])) { + json[field] = [ json[field] ]; + } + json[field].push(value || ''); + } + } + + validate() { + this.invalidControls = []; + let valid = true; + let el; + + // Validate all the custom elements. + for (let i = 0, li = this._controls.length; i < li; i++) { + el = this._controls[i]; + if (this._useValue(el)) { + if (el.validate) { + const elValid = Boolean(el.validate()); + valid = elValid && valid; + + if (elValid === false) { + this.invalidControls.push(el); + } + } + } + } + + // Validate the form's native elements. + for (let i = 0, li = this._nativeElements.length; i < li; i++) { + el = this._nativeElements[i]; + if (el.willValidate && el.checkValidity && el.name && !this._isWrapped(el)) { + const elValid = Boolean(el.checkValidity()); + valid = elValid && valid; + + if (elValid === false) { + this.invalidControls.push(el); + } + } + } + + return valid; + } + + reset() { + for (let i = 0, li = this._origValues.length; i < li; ++i) { + const item = this._origValues[i]; + item.control.value = this._copyValue(item.value || null, item.control); + if (typeof item.control.reset === 'function') { + item.control.reset(); + } + } + } + + focusFirstControl() { + const controls = this.registeredControls; + if (Array.isArray(controls) && controls.length > 0) { + controls[0].focus(); + } + } + + /** + * Check if some form control's value was changed. + */ + isChanged() { + return JSON.stringify(this.serialize()) !== (this._snapshot || '{}'); + } + + /** + * Cache the current state of all form elements to be able to make a diff + * later and check if something was changed. + * `reset` will change all control values back to the last snapshot. + */ + snapshot() { + for (let i = 0, li = this._origValues.length; i < li; ++i) { + const item = this._origValues[i]; + item.value = item.control.value; + } + + this._snapshot = JSON.stringify(this.serialize()); + } + + _addElement(e) { + const target = e.composedPath()[0]; + target._parentForm = this; + this._controls.push(target); + + if (this.readonly) { + target.setAttribute('readonly', true); + this._wasSetReadonly.push(target); + } + + // Store copy of original value in case we want to reset the form. + this._origValues.push({ control: target, value: this._copyValue(target.value, target) }); + + // Stop propagation of the registration event. + // This allows for nested forms. + e.stopPropagation(); + } + + _removeElement(e) { + const target = e.detail.target; + let index = this._controls.indexOf(target); + if (index > -1) { + this._controls.splice(index, 1); + } + + if (this.readonly) { + index = this._wasSetReadonly.indexOf(target); + if (index > -1) { + this._wasSetReadonly.splice(index, 1); + } + } + + // Stop propagation of the event. + // This allows for nested forms. + e.stopPropagation(); + } + + _useValue(el) { + // Skip disabled elements or elements that don't have a `name` attribute. + if (el.disabled || !el.name) { + return false; + } + + // Checkboxes and radio buttons should only use their value if they're checked. + if (el.type === 'checkbox' || + el.type === 'radio' || + el.getAttribute('role') === 'checkbox' || + el.getAttribute('role') === 'radio') { + + if (el.required) { + return true; + } else { + return el.checked; + } + } + return true; + } + + _isWrapped(el) { + try { + const host = el.assignedSlot.getRootNode().host; + if (host._parentForm) { + return true; + } else { + return false; + } + } catch (err) { + return false; + } + } + + _copyValue(value, control) { + if (control.defaultValue !== undefined || control.hasAttribute('default-value')) { + value = control.defaultValue || control.getAttribute('default-value'); + } + + let copy; + switch (typeof value) { + case 'string': + case 'number': + case 'boolean': + copy = value; + break; + case 'object': + if (Array.isArray(value)) { + copy = value.slice(0); + } else if (value === null) { + copy = null; + } else if (value instanceof Date) { + copy = new Date(value.getTime()); + } else { + copy = Object.assign({}, value); + } + break; + } + + return copy; + } + + _readonlyChanged() { + if (this.readonly) { + this._wasSetReadonly = []; + const controls = (this._controls || []).concat(Array.from(this._nativeElements) || []); + + for (let i = 0, li = controls.length; i < li; i++) { + if (!controls[i].getAttribute('readonly') && !controls[i].readonly) { + this._wasSetReadonly.push(controls[i]); + controls[i].setAttribute('readonly', true); + } + } + } else if (this._wasSetReadonly) { + for (let i = 0, li = this._wasSetReadonly.length; i < li; i++) { + this._wasSetReadonly[i].readonly = false; + this._wasSetReadonly[i].removeAttribute('readonly'); + } + this._wasSetReadonly = []; + } + + if (this.readonly) { + this.setAttribute('readonly', ''); + } else { + this.removeAttribute('readonly'); + } + } + + _formAttached(oldFrm, newFrm) { + if (oldFrm) { + oldFrm.removeEventListener('submit', this._nativeFormSubmit); + oldFrm.removeEventListener('reset', this._nativeFormReset); + } + + newFrm.addEventListener('submit', this._nativeFormSubmit); + newFrm.addEventListener('reset', this._nativeFormReset); + } + + _nativeFormSubmit(e) { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + this.submit(); + } + + _nativeFormReset() { + this.reset(); + } +} + +window.customElements.define('tp-form', TpForm); \ No newline at end of file