@forter/form-section
Version:
form-section from Forter Components
356 lines (319 loc) • 10.2 kB
JavaScript
import { LitElement, html, property } from 'lit-element';
import style from './fc-form-section.css';
import bound from 'bound-decorator';
import { FcFormMixin } from '@forter/mixins/src/fc-form-mixin';
import { LifecycleEventsMixin } from '@forter/mixins/src/lifecycle-event-mixin';
import { set, clear, isEmpty, removeItemFromArray } from './fc-form-section-helper';
import { ifDefined } from 'lit-html/directives/if-defined';
const isRequired = elm => elm.required;
const isFormField = elm => elm.nodeName === 'FC-FORM-FIELD';
const hasValue = value => value || value === 0;
const isEmptyArray = arr => (arr || []).length === 0;
const hasNoValue = elm => !hasValue(elm.value) && isEmptyArray(elm.values);
export const ALIGNMENTS = {
ROW: 'row',
COLUMN: 'column',
};
/**
* An element by Forter
* <!-- Author: oweingart <oweingart@forter.com> -->
*
* ## Usage
*
* ```html
* <script>
* import '@forter/form-section';
* </script>
*
* <fc-form-section>
* <fc-form-field></fc-form-field>
* </fc-form-section>
* ```
*
* @element fc-form-section
* @cssprop --fc-form-section-color - section font color. default: "balck"
* @cssprop --fc-form-section-padding-left - section padding left. default: "5px"
* @cssprop --fc-form-section-margin-right-item - section margin right of each field (row alignment only). default: "20px"
* @cssprop --fc-form-section-label-invalid-color - section label color when the section is invalid. default: "var(--fc-red-900)"
* @cssprop --fc-form-section-label-font-size - section label font size. default: "12px"
* @cssprop --fc-form-section-label-font-weight - section label font weight. default: "normal"
* @cssprop --fc-form-section-label-margin-bottom - section label margin bottom. default: "0px"
* @cssprop --fc-form-section-arrow-color - section label arrow color (when section is collapsable) . default: "var(--fc-gray-700)"
*/
export class FcFormSection extends LifecycleEventsMixin(FcFormMixin(LitElement)) {
/**
* Alignment of the form section (row/column)
* @type {string}
*/
align = ALIGNMENTS.ROW;
/**
* If the section is collapsable
* @type {boolean}
*/
collapsable = false;
/**
* If the section is currently collapsed
* @type {boolean}
*/
collapse = false;
/**
* Section label
*/
label;
/**
*
* Internal Observables
*
*/
/**
* If the section is disabled
* @type {Boolean}
* @attr
*/
disabled = false;
/**
* If the section is valid
* @type {Boolean}
* @attr
*/
valid = false;
/**
* If the section is dirty
* @type {Boolean}
* @attr
*/
dirty = false;
/**
* If the section was touched
* @type {Boolean}
* @attr
*/
touched = false;
/** @inheritdoc */
static is = 'fc-form-section';
/** @inheritdoc */
static styles = [style];
constructor() {
super();
this.invalidFields = []; // holds the invalid fields
this.missingRequiredFields = []; // holds the fields that are required and has no data
this.model = {}; // holds the files path and it's data
}
/**
* If section is valid
* @return {Boolean|boolean}
*/
get isValid() {
if (!this.dirty) return true;
return this.valid;
}
onPressLabel(e) {
e.preventDefault();
const { key, target } = e;
if (key === 'Enter') {
target.click();
}
}
/**
* on click collapse
*/
onCollapse() {
this.collapse = !this.collapse;
}
/**
* Disable the section if section is hidden after the collapse has ended
*/
onFinishCollapse() {
if (this.collapsable) {
this.disabled = !this.collapse;
}
}
/**
* Fire event to parent when one of the field had change
* @param {String} eventName
* @param {HTMLElement} field
* @param {Object} changedDetails
*/
fireEventOnChange(eventName, field, changedDetails) {
const { invalidFields, dirty, model, valid } = this;
this.dispatchEvent(new CustomEvent(eventName, {
detail: { valid, dirty, invalidFields, model, field, changedDetails },
}));
}
/**
* Update section model after change
*/
updateModel() {
this.childNodes.forEach(field => {
const value = field.value || field.values;
const { path } = field;
if (path) {
if (isEmpty(value)) {
clear(this.model, path);
} else {
set(this.model, path, value);
}
}
});
}
/** @inheritdoc */
firstUpdated() {
this.updateModel();
}
/**
* Update invalidFields after change
* @param {Boolean} isPathValid
* @param {String} changedPath
*/
updateInvalidFields(isPathValid, changedPath) {
if (isPathValid) {
removeItemFromArray(this.invalidFields, changedPath);
} else {
changedPath
&& !this.invalidFields.includes(changedPath)
&& this.invalidFields.push(changedPath);
}
}
/**
* Update missing required fields after change
*/
updateMissingRequiredFields() {
const emptyRequiredFields = this.requiredFields.filter(hasNoValue);
if (emptyRequiredFields.length === this.requiredFields.length && !this.required) {
this.missingRequiredFields = []; // in case all fields were cleared, so section is valid
} else {
this.missingRequiredFields = emptyRequiredFields.map(e => e.path);
}
}
onFieldRemoved({ target, detail: { path } }) {
clear(this.model, path); // remove form model
this.requiredFields = this.childNodes.filter(isRequired);
this.fieldsToPathMap = this.buildFieldToPathsMap();
this.updateInvalidFields(true, path); // remove path from invalid paths if it's there
this.updateMissingRequiredFields(); // re-build list of missing required fields
this.updateDirty(); // re-build dirty filed
this.updateValid(); // re-build valid field
this.fireEventOnChange('change', target, { path });
}
/**
* All the updates perform when something changes
* @param {Boolean} valid
* @param {String} path
*/
updateSectionData(valid, path) {
this.updateModel(); // re-build the model
this.updateInvalidFields(valid, path); // re-build list of invalid fields
this.updateMissingRequiredFields(); // re-build list of missing required fields
this.updateDirty(); // re-build dirty filed
this.updateValid(); // re-build valid field
}
/**
* On change field with list of values
* @param {HTMLElement} target
* @param {Object} detail
*/
onChangeFieldWithListOfValues({ target, detail }) {
const { valid, path } = detail;
this.updateSectionData(valid, path);
this.fireEventOnChange('change', target, detail);
}
/**
* On change field single value
* @param {HTMLElement} target
* @param {Object} detail
*/
onChangeFieldWithSingleValue({ target, detail }) {
const { valid, path } = detail;
this.updateSectionData(valid, path);
this.fireEventOnChange('change', target, detail);
}
onFieldRequiredChange({ target, detail }) {
const { valid, path } = detail;
this.requiredFields = this.childNodes.filter(isRequired);
this.updateSectionData(valid, path);
this.fireEventOnChange('change', target, detail);
}
/**
* Build a map with the fields path and the field DOM object
* @return {{}}
*/
buildFieldToPathsMap() {
const map = {};
this.childNodes.filter(isFormField)
.forEach(fieldElement => set(map, fieldElement.path, fieldElement));
return map;
}
onSlotChange() {
this.passPropsToSlots();
this.childNodes
.forEach(child => child.addEventListener('change-values',
this.onChangeFieldWithListOfValues));
this.childNodes
.forEach(child => child.addEventListener('change-value',
this.onChangeFieldWithSingleValue));
this.childNodes
.forEach(child => child.addEventListener('field-removed',
this.onFieldRemoved));
this.childNodes
.forEach(child => child.addEventListener('required-change',
this.onFieldRequiredChange));
this.requiredFields = this.childNodes.filter(isRequired);
this.fieldsToPathMap = this.buildFieldToPathsMap();
this.dispatchEvent(new CustomEvent('field-added'));
}
/**
* Render the label
* @return {*}
*/
renderLabel() {
const { collapsable, collapse, isValid, label } = this;
const isSlottedLabel = this.querySelector('label');
const labelTemplate = html`
${isSlottedLabel ? html`<slot name="label" ?invalid="${!isValid}"></slot>` : ''}
${label ? html`<label ?invalid="${!isValid}">${label}</label>` : ''}`;
if (collapsable) {
// Collapsed label
const isCollapsed = collapsable && !collapse;
const arrowDirection = isCollapsed ? 'right' : 'down';
const invalid = !isValid ? 'invalid' : '';
return html`
<div class="collapsable-label-container"
="${this.onCollapse}"
="${this.onPressLabel}"
tabindex="0">
<div class="arrow ${arrowDirection} ${invalid}"></div>
${labelTemplate}
</div>`;
} else {
return html`${labelTemplate}`;
}
}
updated() {
const { align } = this;
const children = Array.from(this.children);
children.forEach(child => {
child.setAttribute('align', align);
});
}
/** @inheritdoc */
render() {
const { align, collapsable, collapse } = this;
let tabIndex = '0';
if (collapsable && !collapse) {
tabIndex = '-1';
}
return html`
${this.renderLabel()}
<section
align="${align}"
?collapsed="${collapse}"
?collapsable="${collapsable}"
="${this.onFinishCollapse}"
="${this.onFinishCollapse}">
<slot ="${this.onSlotChange}"
.tabIndex="${ifDefined(tabIndex)}">
</slot>
</section>
`;
}
}