@lion/ui
Version:
A package of extendable web components
278 lines (251 loc) • 10.1 kB
JavaScript
// eslint-disable-next-line max-classes-per-file
import { dedupeMixin } from '@open-wc/dedupe-mixin';
import { FormControlsCollection } from './FormControlsCollection.js';
import { FormRegisteringMixin } from './FormRegisteringMixin.js';
/**
* @typedef {import('../../types/FormControlMixinTypes.js').FormControlHost} FormControlHost
* @typedef {import('../../types/registration/FormRegistrarMixinTypes.js').FormRegistrarMixin} FormRegistrarMixin
* @typedef {import('../../types/registration/FormRegistrarMixinTypes.js').FormRegistrarHost} FormRegistrarHost
* @typedef {import('../../types/registration/FormRegistrarMixinTypes.js').ElementWithParentFormGroup} ElementWithParentFormGroup
* @typedef {import('../../types/registration/FormRegisteringMixinTypes.js').FormRegisteringHost} FormRegisteringHost
* @typedef {FormControlHost & HTMLElement & {_parentFormGroup?:HTMLElement, checked?:boolean}} FormControl
*/
/**
* @desc This allows an element to become the manager of a register.
* It basically keeps track of a FormControlsCollection that it stores in .formElements
* This will always be an array of all elements.
* In case of a form or fieldset(sub form), it will also act as a key based object with FormControl
* (fields, choice groups or fieldsets)as keys.
* For choice groups, the value will only stay an array.
* See FormControlsCollection for more information
* @type {FormRegistrarMixin}
* @param {import('@open-wc/dedupe-mixin').Constructor<import('lit').LitElement>} superclass
*/
const FormRegistrarMixinImplementation = superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars
// @ts-ignore https://github.com/microsoft/TypeScript/issues/36821#issuecomment-588375051
class extends FormRegisteringMixin(superclass) {
/** @type {any} */
static get properties() {
return {
_isFormOrFieldset: { type: Boolean },
};
}
constructor() {
super();
/**
* Closely mimics the natively supported HTMLFormControlsCollection. It can be accessed
* both like an array and an object (based on control/element names).
* @type {FormControlsCollection}
*/
this.formElements = new FormControlsCollection();
/**
* Flag that determines how ".formElements" should behave.
* For a regular fieldset (see LionFieldset) we expect ".formElements"
* to be accessible as an object.
* In case of a radio-group, a checkbox-group or a select/listbox,
* it should act like an array (see ChoiceGroupMixin).
* Usually, when false, we deal with a choice-group (radio-group, checkbox-group,
* (multi)select)
* @type {boolean}
* @protected
*/
this._isFormOrFieldset = false;
this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this);
this._onRequestToChangeFormElementName = this._onRequestToChangeFormElementName.bind(this);
this.addEventListener(
'form-element-register',
/** @type {EventListenerOrEventListenerObject} */ (this._onRequestToAddFormElement),
);
this.addEventListener(
'form-element-name-changed',
/** @type {EventListenerOrEventListenerObject} */ (this._onRequestToChangeFormElementName),
);
/**
* initComplete resolves after all pending initialization logic
* (for instance `<form-group .serializedValue=${{ child1: 'a', child2: 'b' }}>`)
* is executed
* @type {Promise<any>}
*/
this.initComplete = new Promise((resolve, reject) => {
this.__resolveInitComplete = resolve;
this.__rejectInitComplete = reject;
});
/**
* registrationComplete waits for all children formElements to have registered
* @type {Promise<any> & {done?:boolean}}
*/
this.registrationComplete = new Promise((resolve, reject) => {
this.__resolveRegistrationComplete = resolve;
this.__rejectRegistrationComplete = reject;
});
this.registrationComplete.done = false;
this.registrationComplete.then(
() => {
this.registrationComplete.done = true;
this.__resolveInitComplete(undefined);
},
() => {
this.registrationComplete.done = true;
this.__rejectInitComplete(undefined);
throw new Error(
'Registration could not finish. Please use await el.registrationComplete;',
);
},
);
}
connectedCallback() {
super.connectedCallback();
this._completeRegistration();
}
/**
* Resolves the registrationComplete promise. Subclassers can delay if needed
* @overridable
*/
_completeRegistration() {
Promise.resolve().then(() => this.__resolveRegistrationComplete(undefined));
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.registrationComplete.done === false) {
Promise.resolve().then(() => {
Promise.resolve().then(() => {
this.__rejectRegistrationComplete();
});
});
}
}
/**
*
* @param {ElementWithParentFormGroup} el
*/
isRegisteredFormElement(el) {
return this.formElements.some(exitingEl => exitingEl === el);
}
/**
* @param {FormControl} child the child element (field)
* @param {number} indexToInsertAt index to insert the form element at
*/
addFormElement(child, indexToInsertAt) {
// This is a way to let the child element (a lion-fieldset or lion-field) know, about its parent
// eslint-disable-next-line no-param-reassign
child._parentFormGroup = /** @type {* & FormRegistrarHost} */ (this);
// 1. Add children as array element
if (indexToInsertAt >= 0) {
this.formElements.splice(indexToInsertAt, 0, child);
} else {
this.formElements.push(child);
}
// 2. Add children as object key
if (this._isFormOrFieldset) {
const { name } = child;
if (name === this.name) {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError(`You can not have the same name "${name}" as your parent`);
}
if (name.substr(-2) === '[]') {
if (!Array.isArray(this.formElements[name])) {
this.formElements[name] = new FormControlsCollection();
}
if (indexToInsertAt > 0) {
this.formElements[name].splice(indexToInsertAt, 0, child);
} else {
this.formElements[name].push(child);
}
} else if (!this.formElements[name]) {
this.formElements[name] = child;
} else {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError(
`Name "${name}" is already registered - if you want an array add [] to the end`,
);
}
}
}
/**
* @param {FormControlHost} child the child element (field)
*/
removeFormElement(child) {
// 1. Handle array based children
const index = this.formElements.indexOf(child);
if (index > -1) {
this.formElements.splice(index, 1);
}
// 2. Handle name based object keys
if (this._isFormOrFieldset) {
const { name } = child; // FIXME: <-- ElementWithParentFormGroup should become LionFieldWithParentFormGroup so that "name" exists
if (name.substr(-2) === '[]' && this.formElements[name]) {
const idx = this.formElements[name].indexOf(child);
if (idx > -1) {
this.formElements[name].splice(idx, 1);
}
} else if (this.formElements[name]) {
delete this.formElements[name];
}
}
}
/**
* Hook for Subclassers to perform logic before an element is added
* @param {CustomEvent} ev
* @protected
*/
_onRequestToAddFormElement(ev) {
const child = ev.detail.element;
if (child === this) {
// as we fire and listen - don't add ourselves
return;
}
if (this.isRegisteredFormElement(child)) {
// do not readd already existing elements
return;
}
ev.stopPropagation();
// Check for DOM order to determine the right order to insert into formElements
// If there is no other element, index is -1 (e.g. add it to the end)
let indexToInsertAt = -1;
if (this.formElements && Array.isArray(this.formElements)) {
// we start comparing from the end of the array as it's the most likely position where the element will be added
for (const [i, formElement] of this.formElements.entries()) {
// compareDocumentPosition returns a bitmask
// eslint-disable-next-line no-bitwise
if (formElement.compareDocumentPosition(child) & Node.DOCUMENT_POSITION_FOLLOWING) {
// nothing as child is after formElement in DOM
} else {
// first time child is NOT after formElement in DOM we insert it
indexToInsertAt = i;
break;
}
}
}
this.addFormElement(child, indexToInsertAt);
}
/**
* @param {CustomEvent} ev
* @protected
*/
_onRequestToChangeFormElementName(ev) {
const element = this.formElements[ev.detail.oldName];
if (element) {
this.formElements[ev.detail.newName] = element;
delete this.formElements[ev.detail.oldName];
}
}
/**
* @param {CustomEvent} ev
* @protected
*/
_onRequestToRemoveFormElement(ev) {
const child = ev.detail.element;
if (child === this) {
// as we fire and listen - don't remove ourselves
return;
}
if (!this.isRegisteredFormElement(child)) {
// do not remove non existing elements
return;
}
ev.stopPropagation();
this.removeFormElement(child);
}
};
export const FormRegistrarMixin = dedupeMixin(FormRegistrarMixinImplementation);