tp-form/tp-form.js

450 lines
12 KiB
JavaScript

/**
@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`
<slot @slotchange=${this._handleSlotChange}></slot>
`;
}
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 registered (e.g. <tp-input><input></tp-input>)
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);
item.control.checked = this._copyValue(item.checked || null, item.control);
if (typeof item.control.reset === 'function') {
item.control.reset();
}
}
// 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 registered (e.g. <tp-input><input></tp-input>)
if (this._isWrapped(el)) {
continue;
}
el.value = '';
}
}
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;
item.checked = item.control.checked;
}
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), checked: this._copyValue(target.checked, 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);