UNPKG

@adobe/coral-spectrum

Version:

Coral Spectrum is a JavaScript library of Web Components following Spectrum design patterns.

426 lines (369 loc) 13 kB
/** * 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. */ const BaseFormField = (superClass) => class 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;