coral-spectrum/coral-component-clock/src/scripts/Clock.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 {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;