UNPKG

@adobe/coral-spectrum

Version:

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

602 lines (495 loc) 17.4 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 {BaseComponent} from '../../../coral-base-component'; import {BaseFormField} from '../../../coral-base-formfield'; import {DateTime} from '../../../coral-datetime'; import '../../../coral-component-textfield'; import '../../../coral-component-select'; import base from '../templates/base'; import {transform, commons, validate, i18n} from '../../../coral-utils'; import {Decorator} from '../../../coral-decorator'; // Default for display and value format const DEFAULT_HOUR_FORMAT = 'HH'; const DEFAULT_MINUTE_FORMAT = 'mm'; const DEFAULT_TIME_FORMAT = `${DEFAULT_HOUR_FORMAT}:${DEFAULT_MINUTE_FORMAT}`; // Used to extract the time format from a date format const AUTHORIZED_TOKENS = '(A|a|H{1,2}|h{1,2}|k{1,2}|m{1,2})'; const TIME_REG_EXP = new RegExp(`${AUTHORIZED_TOKENS}.*${AUTHORIZED_TOKENS}|${AUTHORIZED_TOKENS}`); const HOUR_REG_EXP = new RegExp('h{1,2}|H{1,2}|k{1,2}'); const MIN_REG_EXP = new RegExp('m{1,2}'); /** Enumeration for {@link Clock} variants. @typedef {Object} ClockVariantEnum @property {String} DEFAULT A default, gray Clock. @property {String} QUIET A Clock with no border or background. */ const variant = { DEFAULT: 'default', QUIET: 'quiet' }; const CLASSNAME = '_coral-Clock'; // builds an array containing all possible variant classnames. this will be used to remove classnames when the variant // changes const ALL_VARIANT_CLASSES = []; for (const variantValue in variant) { ALL_VARIANT_CLASSES.push(`${CLASSNAME}--${variant[variantValue]}`); } /** @class Coral.Clock @classdesc A Clock component that can be used as a time selection form field. Leverages {@link momentJS} if loaded on the page. @htmltag coral-clock @extends {HTMLElement} @extends {BaseComponent} @extends {BaseFormField} */ const Clock = Decorator(class extends BaseFormField(BaseComponent(HTMLElement)) { /** @ignore */ constructor() { super(); // Default value this._value = ''; // Events this._delegateEvents(commons.extend(this._events, { 'change [handle="period"]': '_onPeriodChange' })); // Prepare templates this._elements = {}; this._template = base.call(this._elements, {commons, i18n}); // Pre-define labellable element this._labellableElement = this; // Add aria-errormessage attribute to coral-clock element this.errorID = (this.id || commons.getUID()) + "-coral-clock-error-label"; // Prevent typing in specific characters which can be added to number inputs const forbiddenChars = ["-", "+", "e", ",", "."]; this.addEventListener("keydown", (e) => { if (forbiddenChars.includes(e.key)) { e.preventDefault(); } }); } /** The format used to display the selected time to the user. If the user manually types a time, this format will be used to parse the value. 'HH:mm' is supported by default. Include momentjs to support additional format string options see http://momentjs.com/docs/#/displaying/. @type {String} @default "HH:mm" @htmlattribute displayformat @htmlattributereflected */ get displayFormat() { return this._displayFormat || DEFAULT_TIME_FORMAT; } set displayFormat(value) { this._displayFormat = this._extractTimeFormat(transform.string(value).trim(), TIME_REG_EXP, DEFAULT_TIME_FORMAT); this._reflectAttribute('displayformat', this._displayFormat); this._syncDisplay(); } /** The format to use on expressing the time as a string on the <code>value</code> attribute. The value will be sent to the server using this format. If an empty string is provided, then the default value per type will be used. 'HH:mm' is supported by default. Include momentjs to support additional format string options see http://momentjs.com/docs/#/displaying/. @type {String} @default "HH:mm" @htmlattribute valueformat @htmlattributereflected */ get valueFormat() { return this._valueFormat || DEFAULT_TIME_FORMAT; } set valueFormat(value) { const setValueFormat = (newValue) => { this._valueFormat = this._extractTimeFormat(transform.string(newValue).trim(), TIME_REG_EXP, DEFAULT_TIME_FORMAT); this._reflectAttribute('valueformat', this._valueFormat); }; // Once the valueFormat is set, we make sure the value is also correct if (!this._valueFormat && this._originalValue) { setValueFormat(value); this.value = this._originalValue; } else { setValueFormat(value); this._elements.input.value = this.value; } } /** The current value as a Date. If the value is "" or an invalid date, <code>null</code> will be returned. @type {Date} @default null */ get valueAsDate() { return this._value ? new Date(this._value.toDate().getTime()) : null; } set valueAsDate(value) { this.value = value instanceof Date ? new DateTime.Moment(value, null, true).format(this.valueFormat) : ''; } /** The clock's variant. See {@link ClockVariantEnum}. @type {String} @default ClockVariantEnum.DEFAULT @htmlattribute variant @htmlattributereflected */ get variant() { return this._variant || variant.DEFAULT; } set variant(value) { value = transform.string(value).toLowerCase(); this._variant = validate.enumeration(variant)(value) && value || variant.DEFAULT; this._reflectAttribute('variant', this._variant); // passes down the variant to the underlying components this._elements.hours.variant = this._variant; this._elements.minutes.variant = this._variant; this._elements.period.variant = this._variant; // removes every existing variant this.classList.remove(...ALL_VARIANT_CLASSES); if (this._variant !== variant.DEFAULT) { this.classList.add(`${CLASSNAME}--${this._variant}`); } } /** Name used to submit the data in a form. @type {String} @default "" @htmlattribute name @htmlattributereflected */ get name() { return this._elements.input.name; } set name(value) { this._reflectAttribute('name', value); this._elements.input.name = value; } /** Whether this field is disabled or not. @type {Boolean} @default false @htmlattribute disabled @htmlattributereflected */ get disabled() { return this._disabled || false; } set disabled(value) { this._disabled = transform.booleanAttr(value); this._reflectAttribute('disabled', this._disabled); this[this._disabled ? 'setAttribute' : 'removeAttribute']('aria-disabled', this._disabled); this.classList.toggle('is-disabled', this._disabled); this._elements.hours.disabled = this._disabled; this._elements.minutes.disabled = this._disabled; // stops the form submission this._elements.input.disabled = this._disabled; } /** Inherited from {@link BaseFormField#invalid}. */ get invalid() { return super.invalid; } set invalid(value) { super.invalid = value; this._elements.hours.invalid = this._invalid; this._elements.minutes.invalid = this._invalid; this._elements.hours.setAttribute("aria-errormessage", this.errorID); this._elements.minutes.setAttribute("aria-errormessage", this.errorID); const ERROR_LABEL_ELEMENT_CLASS = "._coral-Clock .coral-Form-errorlabel"; const errorLabel = this.querySelector(ERROR_LABEL_ELEMENT_CLASS); if (this._elements.hours.invalid || this._elements.minutes.invalid) { errorLabel.setAttribute("id", this.errorID); errorLabel.setAttribute("aria-live", "assertive"); errorLabel.hidden = false; errorLabel.style.display = "table-caption"; errorLabel.style["caption-side"] = "bottom"; } else { errorLabel.setAttribute("aria-live", "off"); errorLabel.hidden = true; } } /** Whether this field is required or not. @type {Boolean} @default false @htmlattribute required @htmlattributereflected */ get required() { return this._required || false; } set required(value) { this._required = transform.booleanAttr(value); this._reflectAttribute('required', this._required); this._elements.hours.required = this._required; this._elements.minutes.required = this._required; this._elements.input.required = this._required; } /** Whether this field is readOnly or not. Indicating that the user cannot modify the value of the control. @type {Boolean} @default false @htmlattribute readonly @htmlattributereflected */ get readOnly() { return this._readOnly || false; } set readOnly(value) { this._readOnly = transform.booleanAttr(value); this._reflectAttribute('readonly', this._readOnly); this._elements.hours.readOnly = this._readOnly; this._elements.minutes.readOnly = this._readOnly; this._elements.input.readOnly = this._readOnly; } /** This field's current value. @type {String} @default "" @htmlattribute value */ get value() { return this._getValueAsString(this._value, this.valueFormat); } set value(value) { value = typeof value === 'string' ? value : ''; // This is used to change the value if valueformat is also set but afterwards this._originalValue = value; // we do strict conversion of the values const time = new DateTime.Moment(value, this.valueFormat, true); this._value = time.isValid() ? time : ''; this._elements.input.value = this.value; this._syncValueAsText(); this._syncDisplay(); } /** Inherited from {@link BaseFormField#labelledBy}. */ get labelledBy() { // Get current aria-labelledby attribute on the labellable element. let labelledBy = this.getAttribute('aria-labelledby'); // If a labelledBy attribute has been defined, if (labelledBy) { // and strip the valueAsText element id from the end of the aria-labelledby string. labelledBy = labelledBy.replace(this._elements.valueAsText.id, '').trim(); // If the resulting labelledBy string is empty, return null. if (!labelledBy.length) { labelledBy = null; } } return labelledBy; } set labelledBy(value) { super.labelledBy = value; // The specified labelledBy property. const labelledBy = this.labelledBy; // An array of element ids to label control, the last being the valueAsText element id. const ids = [this._elements.valueAsText.id]; // If a labelledBy property exists, if (labelledBy) { // prepend the labelledBy value to the ids array ids.unshift(labelledBy); // Set aria-labelledby attribute on the labellable element joining ids array into space-delimited list of ids. this.setAttribute('aria-labelledby', ids.join(' ')); } else { // labelledBy property is null, remove the aria-labelledby attribute. this.removeAttribute('aria-labelledby'); } } /** Ignore the date part and use the time part only @private */ _extractTimeFormat(format, regExp, defaultFormat) { const match = regExp.exec(format); return match && match.length && match[0] !== '' ? match[0] : defaultFormat; } /** Sync time display based on the format @private */ _syncDisplay() { const hourFormat = this._extractTimeFormat(this.displayFormat, HOUR_REG_EXP, DEFAULT_HOUR_FORMAT); const minuteFormat = this._extractTimeFormat(this.displayFormat, MIN_REG_EXP, DEFAULT_MINUTE_FORMAT); this._elements.hours.placeholder = hourFormat; this._elements.minutes.placeholder = minuteFormat; this._elements.hours.value = this._getValueAsString(this._value, hourFormat); this._elements.minutes.value = this._getValueAsString(this._value, minuteFormat); this._syncPeriod(); this._syncValueAsText(); } /** Sync period selector based on the format @private */ _syncPeriod() { const period = this._elements.period; const time = this._value; const am = i18n.get('am'); const pm = i18n.get('pm'); const items = period.items.getAll(); if (time && time.isValid()) { if (time.hours() < 12) { period.value = 'am'; } else { period.value = 'pm'; } } // Check for am/pm if (this.displayFormat.indexOf('a') !== -1) { items[0].textContent = am; items[1].textContent = pm; this._togglePeriod(true); } else if (this.displayFormat.indexOf('A') !== -1) { items[0].textContent = am.toUpperCase(); items[1].textContent = pm.toUpperCase(); this._togglePeriod(true); } else { this._togglePeriod(false); } } /** @private */ _togglePeriod(show) { this.classList.toggle(`${CLASSNAME}--extended`, show); this._elements.period.hidden = !show; } /** @private */ _onPeriodChange(event) { // stops the event from leaving the component event.stopImmediatePropagation(); const time = this._value; const period = this._elements.period; // we check if a change event needs to be triggered since it was produced via user interaction if (time && time.isValid()) { if (this.displayFormat.indexOf('h') !== -1) { if (period.value === 'am') { time.subtract(12, 'h'); } else { time.add(12, 'h'); } } this.value = time.format(this.valueFormat); this.trigger('change'); } } _syncValueAsText() { this._elements.valueAsText.textContent = this._getValueAsString(this._value, this.displayFormat); if (!this.getAttribute('aria-labelledby')) { this.labelledBy = this.labelledBy; } } /** Kills the internal _onInputChange from BaseFormField because it does not check the target. @private */ _onInputChange(event) { // stops the event from leaving the component event.stopImmediatePropagation(); let newTime = new DateTime.Moment(); const oldTime = this._value; let hours = parseInt(this._elements.hours.value, 10); const minutes = parseInt(this._elements.minutes.value, 10); if (window.isNaN(hours) || window.isNaN(minutes)) { newTime = ''; } else { if (!this._elements.period.hidden && this.displayFormat.indexOf('h') !== -1 && this._elements.period.value === 'pm') { hours += 12; } newTime.hours(hours); newTime.minutes(minutes); } // we check if a change event needs to be triggered since it was produced via user interaction if (newTime && newTime.isValid()) { // @polyfill ie this.invalid = false; if (!newTime.isSame(oldTime, 'hour') || !newTime.isSame(oldTime, 'minute')) { this.value = newTime.format(this.valueFormat); this.trigger('change'); } } else { // @polyfill ie this.invalid = true; // does not sync the inputs so allow the user to continue typing the date this._value = ''; if (newTime !== oldTime) { this.trigger('change'); } } } /** Helper class that converts the internal moment value into a String using the provided date format. If the value is invalid, empty string will be returned. @param {?Moment} value The value representing the date. It has to be a moment object or <code>null</code> @param {String} format The Date format to be used. @returns {String} a String representing the value in the given format. @ignore */ _getValueAsString(value, format) { return value && value.isValid() ? value.format(format) : ''; } focus() { // Sets focus to appropriate descendant if (!this.contains(document.activeElement)) { this._elements.hours.focus(); } } /** Returns {@link Clock} variants. @return {ClockVariantEnum} */ static get variant() { return variant; } static get _attributePropertyMap() { return commons.extend(super._attributePropertyMap, { displayformat: 'displayFormat', valueformat: 'valueFormat' }); } /** @ignore */ static get observedAttributes() { return super.observedAttributes.concat([ 'displayformat', 'valueformat', 'variant' ]); } /** @ignore */ render() { super.render(); this.classList.add(CLASSNAME); // a11y this.setAttribute('role', 'group'); // Default reflected attributes if (!this._variant) { this.variant = variant.DEFAULT; } if (!this._valueFormat) { this.valueFormat = DEFAULT_TIME_FORMAT; } if (!this._displayFormat) { this.displayFormat = DEFAULT_TIME_FORMAT; } // clean up to be able to clone it while (this.firstChild) { this.removeChild(this.firstChild); } // Render template this.appendChild(this._template); this._syncDisplay(); } }); export default Clock;