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:
*
`;
}
/**
* 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 (,