coral-spectrum/coral-base-formfield/src/scripts/BaseFormField.js
/**
* Copyright 2019 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {transform, commons, validate} from '../../../coral-utils';
// https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories
let LABELLABLE_ELEMENTS_SELECTOR = 'button,input:not([type=hidden]),keygen,meter,output,progress,select,textarea';
// @polyfill ie11
// IE11 throws syntax error because of the "not()" in the selector for some reason in ColorInputColorProperties
if (navigator.userAgent.indexOf('MSIE') !== -1 || navigator.appVersion.indexOf('Trident/') > 0) {
LABELLABLE_ELEMENTS_SELECTOR = 'button,keygen,meter,output,progress,select,textarea,';
// Since we can't use :not() we have to indicate all input types
[
'text',
'password',
'submit',
'reset',
'radio',
'checkbox',
'button',
'color',
'date',
'datetime-local',
'email',
'month',
'number',
'range',
'search',
'tel',
'time',
'url',
'week'
].forEach((type) => {
LABELLABLE_ELEMENTS_SELECTOR += `input[type=${type}],`;
});
// Remove last ","
LABELLABLE_ELEMENTS_SELECTOR = LABELLABLE_ELEMENTS_SELECTOR.slice(0, -1);
}
// _onInputChange is only triggered on non-hidden inputs
const TARGET_INPUT_SELECTOR = 'input:not([type=hidden])';
/**
@base BaseFormField
@classdesc The base element for Form Field components. If not extending a {@link HTMLInputElement}, following
properties should be implemented at least :
- <code>disabled</code>. Whether this field is disabled or not.
- <code>invalid</code>. Whether the current value of this field is invalid or not.
- <code>name</code>. Name used to submit the data in a form.
- <code>readOnly</code>. Whether this field is readOnly or not. Indicating that the user cannot modify the value of the control.
- <code>required</code>. Whether this field is required or not.
- <code>value</code>. This field's current value.
*/
class BaseFormField extends superClass {
/** @ignore */
constructor() {
super();
this._events = {
'capture:change input': '_onTargetInputChange',
'global:reset': '_onFormReset'
};
}
/**
Whether this field is disabled or not.
@type {Boolean}
@default false
@htmlattribute disabled
@htmlattributereflected
@abstract
*/
/**
Whether the current value of this field is invalid or not.
@type {Boolean}
@default false
@htmlattribute invalid
@htmlattributereflected
@abstract
*/
/**
Name used to submit the data in a form.
@type {String}
@default ""
@htmlattribute name
@htmlattributereflected
@abstract
*/
/**
Whether this field is readOnly or not. Indicating that the user cannot modify the value of the control.
This is ignored for checkbox, radio or fileupload.
@type {Boolean}
@default false
@htmlattribute readonly
@htmlattributereflected
@abstract
*/
/**
Whether this field is required or not.
@type {Boolean}
@default false
@htmlattribute required
@htmlattributereflected
@abstract
*/
/**
This field's current value.
@type {String}
@default ""
@htmlattribute value
@abstract
*/
/**
Whether the current value of this field is invalid or not.
@type {Boolean}
@default false
@htmlattribute invalid
@htmlattributereflected
*/
get invalid() {
return this._invalid || false;
}
set invalid(value) {
value = transform.booleanAttr(value);
this._reflectAttribute('invalid', value);
if(validate.valueMustChange(this._invalid, value)) {
this._invalid = value;
this.setAttribute('aria-invalid', value);
this.classList.toggle('is-invalid', value);
}
}
/**
Reflects the <code>aria-describedby</code> attribute to the labellable element e.g. inner input.
@type {String}
@default null
@htmlattribute describedby
*/
get describedBy() {
return this._getLabellableElement().getAttribute('aria-describedby');
}
set describedBy(value) {
value = transform.string(value);
this._getLabellableElement()[value ? 'setAttribute' : 'removeAttribute']('aria-describedby', value);
}
/**
Reflects the <code>aria-label</code> attribute to the labellable element e.g. inner input.
@type {String}
@default null
@htmlattribute labelled
*/
get labelled() {
return this._getLabellableElement().getAttribute('aria-label');
}
set labelled(value) {
value = transform.string(value);
this._getLabellableElement()[value ? 'setAttribute' : 'removeAttribute']('aria-label', value);
}
/**
Reference to a space delimited set of ids for the HTML elements that provide a label for the formField.
Implementers should override this method to ensure that the appropriate descendant elements are labelled using the
<code>aria-labelledby</code> attribute. This will ensure that the component is properly identified for
accessibility purposes. It reflects the <code>aria-labelledby</code> attribute to the DOM.
@type {?String}
@default null
@htmlattribute labelledby
*/
get labelledBy() {
return this._getLabellableElement().getAttribute('aria-labelledby');
}
set labelledBy(value) {
value = transform.string(value);
// gets the element that will get the label assigned. the _getLabellableElement method should be overriden to
// allow other bevaviors.
const element = this._getLabellableElement();
// we get and assign the it that will be passed around
const elementId = element.id = element.id || commons.getUID();
const currentLabelledBy = element.getAttribute('aria-labelledby');
// we clear the old label assignments
if (currentLabelledBy && currentLabelledBy !== value) {
this._updateForAttributes(currentLabelledBy, elementId, true);
}
if (value) {
element.setAttribute('aria-labelledby', value);
if (element.matches(LABELLABLE_ELEMENTS_SELECTOR)) {
this._updateForAttributes(value, elementId);
}
} else {
// since no labelledby value was set, we remove everything
element.removeAttribute('aria-labelledby');
}
}
/**
Target property inside the component that will be updated when a change event is triggered.
@type {String}
@default "value"
@protected
*/
get _componentTargetProperty() {
return 'value';
}
/**
Target property that will be taken from <code>event.target</code> and set into
{@link BaseFormField#_componentTargetProperty} when a change event is triggered.
@type {String}
@default "value"
@protected
*/
get _eventTargetProperty() {
return 'value';
}
/**
Whether the change event needs to be triggered when {@link BaseFormField#_onInputChange} is called.
@type {Boolean}
@default true
@protected
*/
get _triggerChangeEvent() {
return true;
}
/**
Gets the element that should get the label. In case none of the valid labelelable items are found, the component
will be labelled instead.
@protected
@returns {HTMLElement} the labellable element.
*/
_getLabellableElement() {
// Use predefined element or query it
const element = this._labellableElement || this.querySelector(LABELLABLE_ELEMENTS_SELECTOR);
// Use the found element or the container
return element || this;
}
/**
Gets the internal input that the BaseFormField would watch for change. By default, it searches if the
<code>_getLabellableElement()</code> is an input. Components can override this function to be able to provide a
different implementation. In case the value is <code>null</code>, the change event will be handled no matter
the input that produced it.
@protected
@return {HTMLElement} the input to watch for changes.
*/
_getTargetChangeInput() {
// we use this._targetChangeInput as an internal cache to avoid querying the DOM again every time
return this._targetChangeInput ||
// assignment returns the value
(this._targetChangeInput = this._getLabellableElement().matches(TARGET_INPUT_SELECTOR) ?
this._getLabellableElement() : null);
}
/**
Function called whenever the target component triggers a change event. <code>_getTargetChangeInput</code> is used
internally to determine if the input belongs to the component. If the component decides to override this function,
the default from the base will not be called.
@protected
*/
_onInputChange(event) {
// stops the current event
event.stopPropagation();
/** @ignore */
this[this._componentTargetProperty] = event.target[this._eventTargetProperty];
// Explicitly re-emit the change event after the property has been set
if (this._triggerChangeEvent) {
this.trigger('change');
}
}
/**
Resets the formField when a reset is triggered on the parent form.
@protected
*/
_onFormReset(event) {
if (event.target.contains(this)) {
this.reset();
}
}
/**
We capture every input change and validate that it belongs to our target input. If this is the case,
<code>_onInputChange</code> will be called with the same event.
@protected
*/
_onTargetInputChange(event) {
const targetInput = this._getTargetChangeInput();
// if the targetInput is null we still call _onInputChange to be backwards compatible
if (targetInput === event.target || targetInput === null) {
// we call _onInputChange since the target matches
this._onInputChange(event);
}
}
/**
A utility method for adding the appropriate <code>for</code> attribute to any <code>label</code> elements
referenced by the <code>labelledBy</code> property value.
@param {String} labelledBy
The value of the <code>labelledBy<code> property providing a space-delimited list of the <code>id</code>
attributes for elements that label the formField.
@param {String} elementId
The <code>id</code> of the formField or one of its descendants that should be labelled by
<code>label</code> elements referenced by the <code>labelledBy</code> property value.
@param {Boolean} remove
Whether the existing <code>for</code> attributes should be removed.
@protected
*/
_updateForAttributes(labelledBy, elementId, remove) {
// labelledby contains whitespace sparated items, so we need to separate each individual id
const labelIds = labelledBy.split(/\s+/);
// we update the 'for' attribute for every id.
labelIds.forEach((currentValue) => {
const labelElement = document.getElementById(currentValue);
if (labelElement && labelElement.tagName === 'LABEL') {
const forAttribute = labelElement.getAttribute('for');
if (remove) {
// we just remove it when it is our target
if (forAttribute === elementId) {
labelElement.removeAttribute('for');
}
} else {
// if we do not have to remove, it does not matter the current value of the label, we can set it in every
// case
labelElement.setAttribute('for', elementId);
}
}
});
}
/**
Clears the <code>value</code> of formField to the default value.
*/
clear() {
/** @ignore */
this.value = '';
}
/**
Resets the <code>value</code> to the initial value.
*/
reset() {
// since the 'value' property is not reflected, form components use it to restore the initial value. When a
// component has support for values, this method needs to be overwritten
/** @ignore */
this.value = transform.string(this.getAttribute('value'));
}
static get _attributePropertyMap() {
return commons.extend(super._attributePropertyMap, {
describedby: 'describedBy',
labelledby: 'labelledBy',
readonly: 'readOnly',
});
}
// We don't want to watch existing attributes for components that extend native HTML elements
static get _nativeObservedAttributes() {
return super.observedAttributes.concat([
'describedby',
'labelled',
'labelledby',
'invalid'
]);
}
/** @ignore */
static get observedAttributes() {
return super.observedAttributes.concat([
'describedby',
'labelled',
'labelledby',
'invalid',
'readonly',
'name',
'value',
'disabled',
'required'
]);
}
};
export default BaseFormField;