import { css, dedupeMixin, html, nothing, SlotMixin, DisabledMixin } from '@lion/core'; import { getAriaElementsInRightDomOrder } from './utils/getAriaElementsInRightDomOrder.js'; import { Unparseable } from './validate/Unparseable.js'; import { FormRegisteringMixin } from './registration/FormRegisteringMixin.js'; /** * @typedef {import('@lion/core').TemplateResult} TemplateResult * @typedef {import('@lion/core').CSSResult} CSSResult * @typedef {import('@lion/core').CSSResultArray} CSSResultArray * @typedef {import('@lion/core').nothing} nothing * @typedef {import('@lion/core/types/SlotMixinTypes').SlotsMap} SlotsMap * @typedef {import('./validate/LionValidationFeedback').LionValidationFeedback} LionValidationFeedback * @typedef {import('../types/choice-group/ChoiceInputMixinTypes').ChoiceInputHost} ChoiceInputHost * @typedef {import('../types/FormControlMixinTypes.js').FormControlHost} FormControlHost * @typedef {import('../types/FormControlMixinTypes.js').HTMLElementWithValue} HTMLElementWithValue * @typedef {import('../types/FormControlMixinTypes.js').FormControlMixin} FormControlMixin * @typedef {import('../types/FormControlMixinTypes.js').ModelValueEventDetails} ModelValueEventDetails */ /** * Generates random unique identifier (for dom elements) * @param {string} prefix */ function uuid(prefix) { return `${prefix}-${Math.random().toString(36).substr(2, 10)}`; } /** * #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) * @param {import('@open-wc/dedupe-mixin').Constructor} superclass * @type {FormControlMixin} */ const FormControlMixinImplementation = superclass => // eslint-disable-next-line no-shadow, no-unused-vars class FormControlMixin extends FormRegisteringMixin(DisabledMixin(SlotMixin(superclass))) { /** @type {any} */ static get properties() { return { name: { type: String, reflect: true }, readOnly: { type: Boolean, attribute: 'readonly', reflect: true }, label: String, // FIXME: { attribute: false } breaks a bunch of tests, but shouldn't... helpText: { type: String, attribute: 'help-text' }, modelValue: { attribute: false }, _ariaLabelledNodes: { attribute: false }, _ariaDescribedNodes: { attribute: false }, _repropagationRole: { attribute: false }, _isRepropagationEndpoint: { attribute: false }, }; } /** * The label text for the input node. * When no light dom defined via [slot=label], this value will be used. * @type {string} */ get label() { return this.__label || (this._labelNode && this._labelNode.textContent) || ''; } /** * @param {string} newValue */ set label(newValue) { const oldValue = this.label; /** @type {string} */ this.__label = newValue; this.requestUpdate('label', oldValue); } /** * The helpt text for the input node. * When no light dom defined via [slot=help-text], this value will be used * @type {string} */ get helpText() { return this.__helpText || (this._helpTextNode && this._helpTextNode.textContent) || ''; } /** * @param {string} newValue */ set helpText(newValue) { const oldValue = this.helpText; /** @type {string} */ this.__helpText = newValue; this.requestUpdate('helpText', oldValue); } /** * Will be used in validation messages to refer to the current field * @type {string} */ get fieldName() { return this.__fieldName || this.label || this.name || ''; } /** * @param {string} value */ set fieldName(value) { /** @type {string} */ this.__fieldName = value; } /** * @configure SlotMixin */ 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; }, }; } /** * The interactive (form) element. Can be a native element like input/textarea/select or * an element with tabindex > -1 * @protected */ get _inputNode() { return /** @type {HTMLElementWithValue} */ (this.__getDirectSlotChild('input')); } /** * Element where label will be rendered to * @protected */ get _labelNode() { return /** @type {HTMLElement} */ (this.__getDirectSlotChild('label')); } /** * Element where help text will be rendered to * @protected */ get _helpTextNode() { return /** @type {HTMLElement} */ (this.__getDirectSlotChild('help-text')); } /** * Element where validation feedback will be rendered to * @protected */ get _feedbackNode() { return /** @type {LionValidationFeedback} */ (this.__getDirectSlotChild('feedback')); } constructor() { super(); /** * The name the element will be registered with to the .formElements collection * of the parent. Also, it serves as the key of key/value pairs in * modelValue/serializedValue objects * @type {string} */ this.name = ''; /** * A Boolean attribute which, if present, indicates that the user should not be able to edit * the value of the input. The difference between disabled and readonly is that read-only * controls can still function, whereas disabled controls generally do not function as * controls until they are enabled. * (From: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-readonly) * @type {boolean} */ this.readOnly = false; /** * The label text for the input node. * When no value is defined, textContent of [slot=label] will be used * @type {string} */ this.label = ''; /** * The helpt text for the input node. * When no value is defined, textContent of [slot=help-text] will be used * @type {string} */ this.helpText = ''; /** * The model value is the result of the parser function(when available). * It should be considered as the internal value used for validation and reasoning/logic. * The model value is 'ready for consumption' by the outside world (think of a Date * object or a float). The modelValue can(and is recommended to) be used as both input * value and output value of the `LionField`. * * Examples: * - For a date input: a String '20/01/1999' will be converted to new Date('1999/01/20') * - For a number input: a formatted String '1.234,56' will be converted to a Number: * 1234.56 */ // TODO: we can probably set this up properly once propert effects run from firstUpdated // this.modelValue = undefined; /** * Unique id that can be used in all light dom * @type {string} * @protected */ this._inputId = uuid(this.localName); /** * Contains all elements that should end up in aria-labelledby of `._inputNode` * @type {HTMLElement[]} */ this._ariaLabelledNodes = []; /** * Contains all elements that should end up in aria-describedby of `._inputNode` * @type {HTMLElement[]} */ this._ariaDescribedNodes = []; /** * Based on the role, details of handling model-value-changed repropagation differ. * @type {'child'|'choice-group'|'fieldset'} */ this._repropagationRole = 'child'; /** * By default, a field with _repropagationRole 'choice-group' will act as an * 'endpoint'. This means it will be considered as an individual field: for * a select, individual options will not be part of the formPath. They * will. * Similarly, components that (a11y wise) need to be fieldsets, but 'interaction wise' * (from Application Developer perspective) need to be more like fields * (think of an amount-input with a currency select box next to it), can set this * to true to hide private internals in the formPath. * @type {boolean} */ this._isRepropagationEndpoint = false; this.addEventListener( 'model-value-changed', /** @type {EventListenerOrEventListenerObject} */ (this.__repropagateChildrenValues), ); /** @type {EventListener} */ this._onLabelClick = this._onLabelClick.bind(this); } connectedCallback() { super.connectedCallback(); this._enhanceLightDomClasses(); this._enhanceLightDomA11y(); this._triggerInitialModelValueChangedEvent(); if (this._labelNode) { this._labelNode.addEventListener('click', this._onLabelClick); } } disconnectedCallback() { super.disconnectedCallback(); if (this._labelNode) { this._labelNode.removeEventListener('click', this._onLabelClick); } } /** @param {import('@lion/core').PropertyValues } changedProperties */ updated(changedProperties) { super.updated(changedProperties); if (changedProperties.has('_ariaLabelledNodes')) { this.__reflectAriaAttr( 'aria-labelledby', this._ariaLabelledNodes, this.__reorderAriaLabelledNodes, ); } if (changedProperties.has('_ariaDescribedNodes')) { this.__reflectAriaAttr( 'aria-describedby', this._ariaDescribedNodes, this.__reorderAriaDescribedNodes, ); } if (changedProperties.has('label') && this._labelNode) { this._labelNode.textContent = this.label; } if (changedProperties.has('helpText') && this._helpTextNode) { this._helpTextNode.textContent = this.helpText; } if (changedProperties.has('name')) { this.dispatchEvent( /** @privateEvent */ new CustomEvent('form-element-name-changed', { detail: { oldName: changedProperties.get('name'), newName: this.name }, bubbles: true, }), ); } } /** @protected */ _triggerInitialModelValueChangedEvent() { this._dispatchInitialModelValueChangedEvent(); } /** @protected */ _enhanceLightDomClasses() { if (this._inputNode) { this._inputNode.classList.add('form-control'); } } /** @protected */ _enhanceLightDomA11y() { const { _inputNode, _labelNode, _helpTextNode, _feedbackNode } = this; if (_inputNode) { _inputNode.id = _inputNode.id || this._inputId; } if (_labelNode) { _labelNode.setAttribute('for', this._inputId); this.addToAriaLabelledBy(_labelNode, { idPrefix: 'label' }); } if (_helpTextNode) { this.addToAriaDescribedBy(_helpTextNode, { idPrefix: 'help-text' }); } if (_feedbackNode) { // Generic focus/blur handling that works for both Fields/FormGroups this.addEventListener('focusin', () => { _feedbackNode.setAttribute('aria-live', 'polite'); }); this.addEventListener('focusout', () => { _feedbackNode.setAttribute('aria-live', 'assertive'); }); this.addToAriaDescribedBy(_feedbackNode, { idPrefix: 'feedback' }); } this._enhanceLightDomA11yForAdditionalSlots(); } /** * 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 * @param {string[]} additionalSlots * @protected */ _enhanceLightDomA11yForAdditionalSlots( additionalSlots = ['prefix', 'suffix', 'before', 'after'], ) { additionalSlots.forEach(additionalSlot => { const element = this.__getDirectSlotChild(additionalSlot); if (element) { if (element.hasAttribute('data-label')) { this.addToAriaLabelledBy(element, { idPrefix: additionalSlot }); } if (element.hasAttribute('data-description')) { this.addToAriaDescribedBy(element, { idPrefix: additionalSlot }); } } }); } /** * 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. * @param {string} attrName * @param {HTMLElement[]} nodes * @param {boolean|undefined} reorder */ __reflectAriaAttr(attrName, nodes, reorder) { if (this._inputNode) { if (reorder) { const insideNodes = nodes.filter(n => this.contains(n)); const outsideNodes = nodes.filter(n => !this.contains(n)); // eslint-disable-next-line no-param-reassign nodes = [...getAriaElementsInRightDomOrder(insideNodes), ...outsideNodes]; } const string = nodes.map(n => n.id).join(' '); this._inputNode.setAttribute(attrName, string); } } /** * Default Render Result: *
*
* *
* * * *
*
*
*
* *
*
*
* *
*
* *
*
* *
*
*
* *
*
* *
*/ render() { return html`
${this._groupOneTemplate()}
${this._groupTwoTemplate()}
`; } /** * @return {TemplateResult} * @protected */ _groupOneTemplate() { return html` ${this._labelTemplate()} ${this._helpTextTemplate()} `; } /** * @return {TemplateResult} * @protected */ _groupTwoTemplate() { return html` ${this._inputGroupTemplate()} ${this._feedbackTemplate()} `; } /** * @return {TemplateResult} * @protected */ // eslint-disable-next-line class-methods-use-this _labelTemplate() { return html`
`; } /** * @return {TemplateResult} * @protected */ // eslint-disable-next-line class-methods-use-this _helpTextTemplate() { return html` `; } /** * @return {TemplateResult} * @protected */ _inputGroupTemplate() { return html`
${this._inputGroupBeforeTemplate()}
${this._inputGroupPrefixTemplate()} ${this._inputGroupInputTemplate()} ${this._inputGroupSuffixTemplate()}
${this._inputGroupAfterTemplate()}
`; } /** * @return {TemplateResult} * @protected */ // eslint-disable-next-line class-methods-use-this _inputGroupBeforeTemplate() { return html`
`; } /** * @return {TemplateResult | nothing} * @protected */ _inputGroupPrefixTemplate() { return !Array.from(this.children).find(child => child.slot === 'prefix') ? nothing : html`
`; } /** * @return {TemplateResult} * @protected */ // eslint-disable-next-line class-methods-use-this _inputGroupInputTemplate() { return html`
`; } /** * @return {TemplateResult | nothing} * @protected */ _inputGroupSuffixTemplate() { return !Array.from(this.children).find(child => child.slot === 'suffix') ? nothing : html`
`; } /** * @return {TemplateResult} * @protected */ // eslint-disable-next-line class-methods-use-this _inputGroupAfterTemplate() { return html`
`; } /** * @return {TemplateResult} * @protected */ // eslint-disable-next-line class-methods-use-this _feedbackTemplate() { return html` `; } /** * Used for Required validation and computation of interaction states * @param {any} modelValue * @return {boolean} * @protected */ _isEmpty(modelValue = /** @type {any} */ (this).modelValue) { let value = modelValue; if (/** @type {any} */ (this).modelValue instanceof Unparseable) { value = /** @type {any} */ (this).modelValue.viewValue; } // Checks for empty platform types: Objects, Arrays, Dates if (typeof value === 'object' && value !== null && !(value instanceof Date)) { return !Object.keys(value).length; } // eslint-disable-next-line no-mixed-operators // Checks for empty platform types: Numbers, Booleans const isNumberValue = typeof value === 'number' && (value === 0 || Number.isNaN(value)); const isBooleanValue = typeof value === 'boolean' && value === false; return !value && !isNumberValue && !isBooleanValue; } /** * 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 */ /** * {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} [disabled] when .form-control (,