import { html, css, nothing, dedupeMixin } from '@lion/core'; import { ObserverMixin } from '@lion/core/src/ObserverMixin.js'; /** * #FormControlMixin : * * This Mixin is a shared fundament for all form components, it's applied on: * - LionField (which is extended to LionInput, LionTextarea, LionSelect etc. etc.) * - LionFieldset (which is extended to LionRadioGroup, LionCheckboxGroup, LionForm) * * @polymerMixin * @mixinFunction */ export const FormControlMixin = dedupeMixin( superclass => // eslint-disable-next-line no-shadow, no-unused-vars class FormControlMixin extends ObserverMixin(superclass) { static get properties() { return { ...super.properties, /** * A list of ids that will be put on the inputElement as a serialized string */ _ariaDescribedby: { type: String, }, /** * A list of ids that will be put on the inputElement as a serialized string */ _ariaLabelledby: { type: String, }, /** * When no light dom defined and prop set */ label: { type: String, }, /** * When no light dom defined and prop set */ helpText: { type: String, attribute: 'help-text', }, }; } get slots() { return { ...super.slots, label: () => { const label = document.createElement('label'); label.textContent = this.label; return label; }, 'help-text': () => { const helpText = document.createElement('div'); helpText.textContent = this.helpText; return helpText; }, }; } static get asyncObservers() { return { ...super.asyncObservers, _onAriaLabelledbyChanged: ['_ariaLabelledby'], _onAriaDescribedbyChanged: ['_ariaDescribedby'], _onLabelChanged: ['label'], _onHelpTextChanged: ['helpText'], }; } get inputElement() { return (this.$$slot && this.$$slot('input')) || this.querySelector('[slot=input]'); // eslint-disable-line } constructor() { super(); this._inputId = `${this.localName}-${Math.random() .toString(36) .substr(2, 10)}`; this._ariaLabelledby = ''; this._ariaDescribedby = ''; } connectedCallback() { super.connectedCallback(); this._enhanceLightDomClasses(); this._enhanceLightDomA11y(); this._registerFormElement(); this._requestParentFormGroupUpdateOfResetModelValue(); } /** * Public methods */ _enhanceLightDomClasses() { if (this.inputElement) { this.inputElement.classList.add('form-control'); } } _enhanceLightDomA11y() { if (this.inputElement) { this.inputElement.id = this.inputElement.id || this._inputId; } if (this.$$slot('label')) { this.$$slot('label').setAttribute('for', this._inputId); this.$$slot('label').id = this.$$slot('label').id || `label-${this._inputId}`; const labelledById = ` ${this.$$slot('label').id}`; if (this._ariaLabelledby.indexOf(labelledById) === -1) { this._ariaLabelledby += ` ${this.$$slot('label').id}`; } } if (this.$$slot('help-text')) { this.$$slot('help-text').id = this.$$slot('help-text').id || `help-text-${this._inputId}`; const describeIdHelpText = ` ${this.$$slot('help-text').id}`; if (this._ariaDescribedby.indexOf(describeIdHelpText) === -1) { this._ariaDescribedby += ` ${this.$$slot('help-text').id}`; } } if (this.$$slot('feedback')) { this.$$slot('feedback').id = this.$$slot('feedback').id || `feedback-${this._inputId}`; const describeIdFeedback = ` ${this.$$slot('feedback').id}`; if (this._ariaDescribedby.indexOf(describeIdFeedback) === -1) { this._ariaDescribedby += ` ${this.$$slot('feedback').id}`; } } this._enhanceLightDomA11yForAdditionalSlots(); } /** * Fires a registration event in the next frame. * * Why next frame? * if ShadyDOM is used and you add a listener and fire the event in the same frame * it will not bubble and there can not be cought by a parent element * for more details see: https://github.com/Polymer/lit-element/issues/658 * will requires a `await nextFrame()` in tests */ _registerFormElement() { requestAnimationFrame(() => { this.dispatchEvent( new CustomEvent('form-element-register', { detail: { element: this }, bubbles: true, }), ); }); } /** * Makes sure our parentFormGroup has the most up to date resetModelValue * FormGroups will call the same on their parentFormGroup so the full tree gets the correct * values. * * Why next frame? * @see {@link this._registerFormElement} */ _requestParentFormGroupUpdateOfResetModelValue() { requestAnimationFrame(() => { if (this.__parentFormGroup) { this.__parentFormGroup._updateResetModelValue(); } }); } /** * Enhances additional slots(prefix, suffix, before, after) defined by developer. * * When boolean attribute data-label or data-description is found, * the slot element will be connected to the input via aria-labelledby or aria-describedby */ _enhanceLightDomA11yForAdditionalSlots( additionalSlots = ['prefix', 'suffix', 'before', 'after'], ) { additionalSlots.forEach(additionalSlot => { const element = this.$$slot(additionalSlot); if (element) { element.id = element.id || `${additionalSlot}-${this._inputId}`; if (element.hasAttribute('data-label') === true) { this._ariaLabelledby += ` ${element.id}`; } if (element.hasAttribute('data-description') === true) { this._ariaDescribedby += ` ${element.id}`; } } }); } /** * Will handle label, prefix/suffix/before/after (if they contain data-label flag attr). * Also, contents of id references that will be put in the ._ariaLabelledby property * from an external context, will be read by a screen reader. */ _onAriaLabelledbyChanged({ _ariaLabelledby }) { if (this.inputElement) { this.inputElement.setAttribute('aria-labelledby', _ariaLabelledby); } } /** * Will handle help text, validation feedback and character counter, * prefix/suffix/before/after (if they contain data-description flag attr). * Also, contents of id references that will be put in the ._ariaDescribedby property * from an external context, will be read by a screen reader. */ _onAriaDescribedbyChanged({ _ariaDescribedby }) { if (this.inputElement) { this.inputElement.setAttribute('aria-describedby', _ariaDescribedby); } } _onLabelChanged({ label }) { if (this.$$slot && this.$$slot('label')) { this.$$slot('label').textContent = label; } } _onHelpTextChanged({ helpText }) { if (this.$$slot && this.$$slot('help-text')) { this.$$slot('help-text').textContent = helpText; } } /** * * Default Render Result: *
* *
* * * *
*
* *
*
*
* *
*
* *
*
* *
*
*
* *
*
* */ render() { return html` ${this.labelTemplate()} ${this.helpTextTemplate()} ${this.inputGroupTemplate()} ${this.feedbackTemplate()} `; } // eslint-disable-next-line class-methods-use-this labelTemplate() { return html`
`; } // eslint-disable-next-line class-methods-use-this helpTextTemplate() { return html` `; } inputGroupTemplate() { return html`
${this.inputGroupBeforeTemplate()}
${this.inputGroupPrefixTemplate()} ${this.inputGroupInputTemplate()} ${this.inputGroupSuffixTemplate()}
${this.inputGroupAfterTemplate()}
`; } // eslint-disable-next-line class-methods-use-this inputGroupBeforeTemplate() { return html`
`; } inputGroupPrefixTemplate() { return !this.$$slot('prefix') ? nothing : html`
`; } // eslint-disable-next-line class-methods-use-this inputGroupInputTemplate() { return html`
`; } inputGroupSuffixTemplate() { return !this.$$slot('suffix') ? nothing : html`
`; } // eslint-disable-next-line class-methods-use-this inputGroupAfterTemplate() { return html`
`; } // eslint-disable-next-line class-methods-use-this feedbackTemplate() { return html` `; } /** * All CSS below is written from a generic mindset, following BEM conventions: * https://en.bem.info/methodology/ * Although the CSS and HTML are implemented by the component, they should be regarded as * totally decoupled. * * Not only does this force us to write better structured css, it also allows for future * reusability in many different ways like: * - disabling shadow DOM for a component (for water proof encapsulation can be combined with * a build step) * - easier translation to more flexible, WebComponents agnostic solutions like JSS * (allowing extends, mixins, reasoning, IDE integration, tree shaking etc.) * - export to a CSS module for reuse in an outer context * * * Please note that the HTML structure is purposely 'loose', allowing multiple design systems * to be compatible * with the CSS component. * Note that every occurence of '::slotted(*)' can be rewritten to '> *' for use in an other * context * * TODO: find best naming convention: https://en.bem.info/methodology/naming-convention/ * (react style would align better with JSS) */ /** * {block} .form-field * * Structure: * - {element} .form-field__label : a wrapper element around the projected label * - {element} .form-field__help-text (optional) : a wrapper element around the projected * help-text * - {block} .input-group : a container around the input element, including prefixes and * suffixes * - {element} .form-field__feedback (optional) : a wrapper element around the projected * (validation) feedback message * * Modifiers: * - {state} .state-disabled : when .form-control (,