ExamplesPlaygroundReference Source

coral-spectrum/coral-component-clock/src/scripts/Clock.js

  1. /**
  2. * Copyright 2019 Adobe. All rights reserved.
  3. * This file is licensed to you under the Apache License, Version 2.0 (the "License");
  4. * you may not use this file except in compliance with the License. You may obtain a copy
  5. * of the License at http://www.apache.org/licenses/LICENSE-2.0
  6. *
  7. * Unless required by applicable law or agreed to in writing, software distributed under
  8. * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
  9. * OF ANY KIND, either express or implied. See the License for the specific language
  10. * governing permissions and limitations under the License.
  11. */
  12.  
  13. import {BaseComponent} from '../../../coral-base-component';
  14. import {BaseFormField} from '../../../coral-base-formfield';
  15. import {DateTime} from '../../../coral-datetime';
  16. import '../../../coral-component-textfield';
  17. import '../../../coral-component-select';
  18. import base from '../templates/base';
  19. import {transform, commons, validate, i18n} from '../../../coral-utils';
  20. import {Decorator} from '../../../coral-decorator';
  21.  
  22. // Default for display and value format
  23. const DEFAULT_HOUR_FORMAT = 'HH';
  24. const DEFAULT_MINUTE_FORMAT = 'mm';
  25. const DEFAULT_TIME_FORMAT = `${DEFAULT_HOUR_FORMAT}:${DEFAULT_MINUTE_FORMAT}`;
  26.  
  27. // Used to extract the time format from a date format
  28. const AUTHORIZED_TOKENS = '(A|a|H{1,2}|h{1,2}|k{1,2}|m{1,2})';
  29. const TIME_REG_EXP = new RegExp(`${AUTHORIZED_TOKENS}.*${AUTHORIZED_TOKENS}|${AUTHORIZED_TOKENS}`);
  30. const HOUR_REG_EXP = new RegExp('h{1,2}|H{1,2}|k{1,2}');
  31. const MIN_REG_EXP = new RegExp('m{1,2}');
  32.  
  33. /**
  34. Enumeration for {@link Clock} variants.
  35.  
  36. @typedef {Object} ClockVariantEnum
  37.  
  38. @property {String} DEFAULT
  39. A default, gray Clock.
  40. @property {String} QUIET
  41. A Clock with no border or background.
  42. */
  43. const variant = {
  44. DEFAULT: 'default',
  45. QUIET: 'quiet'
  46. };
  47.  
  48. const CLASSNAME = '_coral-Clock';
  49.  
  50. // builds an array containing all possible variant classnames. this will be used to remove classnames when the variant
  51. // changes
  52. const ALL_VARIANT_CLASSES = [];
  53. for (const variantValue in variant) {
  54. ALL_VARIANT_CLASSES.push(`${CLASSNAME}--${variant[variantValue]}`);
  55. }
  56.  
  57. /**
  58. @class Coral.Clock
  59. @classdesc A Clock component that can be used as a time selection form field. Leverages {@link momentJS} if loaded
  60. on the page.
  61. @htmltag coral-clock
  62. @extends {HTMLElement}
  63. @extends {BaseComponent}
  64. @extends {BaseFormField}
  65. */
  66. const Clock = Decorator(class extends BaseFormField(BaseComponent(HTMLElement)) {
  67. /** @ignore */
  68. constructor() {
  69. super();
  70.  
  71. // Default value
  72. this._value = '';
  73.  
  74. // Events
  75. this._delegateEvents(commons.extend(this._events, {
  76. 'change [handle="period"]': '_onPeriodChange'
  77. }));
  78.  
  79. // Prepare templates
  80. this._elements = {};
  81. this._template = base.call(this._elements, {commons, i18n});
  82.  
  83. // Pre-define labellable element
  84. this._labellableElement = this;
  85.  
  86. // Add aria-errormessage attribute to coral-clock element
  87. this.errorID = (this.id || commons.getUID()) + "-coral-clock-error-label";
  88.  
  89. // Prevent typing in specific characters which can be added to number inputs
  90. const forbiddenChars = ["-", "+", "e", ",", "."];
  91. this.addEventListener("keydown", (e) => {
  92. if (forbiddenChars.includes(e.key)) {
  93. e.preventDefault();
  94. }
  95. });
  96. }
  97.  
  98. /**
  99. The format used to display the selected time to the user. If the user manually types a time, this format
  100. will be used to parse the value. 'HH:mm' is supported by default. Include momentjs to support additional format
  101. string options see http://momentjs.com/docs/#/displaying/.
  102.  
  103. @type {String}
  104. @default "HH:mm"
  105. @htmlattribute displayformat
  106. @htmlattributereflected
  107. */
  108. get displayFormat() {
  109. return this._displayFormat || DEFAULT_TIME_FORMAT;
  110. }
  111.  
  112. set displayFormat(value) {
  113. this._displayFormat = this._extractTimeFormat(transform.string(value).trim(), TIME_REG_EXP, DEFAULT_TIME_FORMAT);
  114. this._reflectAttribute('displayformat', this._displayFormat);
  115.  
  116. this._syncDisplay();
  117. }
  118.  
  119. /**
  120. The format to use on expressing the time as a string on the <code>value</code> attribute. The value
  121. will be sent to the server using this format. If an empty string is provided, then the default value per type
  122. will be used. 'HH:mm' is supported by default. Include momentjs to support additional format string options
  123. see http://momentjs.com/docs/#/displaying/.
  124.  
  125. @type {String}
  126. @default "HH:mm"
  127. @htmlattribute valueformat
  128. @htmlattributereflected
  129. */
  130. get valueFormat() {
  131. return this._valueFormat || DEFAULT_TIME_FORMAT;
  132. }
  133.  
  134. set valueFormat(value) {
  135. const setValueFormat = (newValue) => {
  136. this._valueFormat = this._extractTimeFormat(transform.string(newValue).trim(), TIME_REG_EXP, DEFAULT_TIME_FORMAT);
  137. this._reflectAttribute('valueformat', this._valueFormat);
  138. };
  139.  
  140. // Once the valueFormat is set, we make sure the value is also correct
  141. if (!this._valueFormat && this._originalValue) {
  142. setValueFormat(value);
  143. this.value = this._originalValue;
  144. } else {
  145. setValueFormat(value);
  146. this._elements.input.value = this.value;
  147. }
  148. }
  149.  
  150. /**
  151. The current value as a Date. If the value is "" or an invalid date, <code>null</code> will be returned.
  152.  
  153. @type {Date}
  154. @default null
  155. */
  156. get valueAsDate() {
  157. return this._value ? new Date(this._value.toDate().getTime()) : null;
  158. }
  159.  
  160. set valueAsDate(value) {
  161. this.value = value instanceof Date ? new DateTime.Moment(value, null, true).format(this.valueFormat) : '';
  162. }
  163.  
  164. /**
  165. The clock's variant. See {@link ClockVariantEnum}.
  166.  
  167. @type {String}
  168. @default ClockVariantEnum.DEFAULT
  169. @htmlattribute variant
  170. @htmlattributereflected
  171. */
  172. get variant() {
  173. return this._variant || variant.DEFAULT;
  174. }
  175.  
  176. set variant(value) {
  177. value = transform.string(value).toLowerCase();
  178. this._variant = validate.enumeration(variant)(value) && value || variant.DEFAULT;
  179. this._reflectAttribute('variant', this._variant);
  180.  
  181. // passes down the variant to the underlying components
  182. this._elements.hours.variant = this._variant;
  183. this._elements.minutes.variant = this._variant;
  184. this._elements.period.variant = this._variant;
  185.  
  186. // removes every existing variant
  187. this.classList.remove(...ALL_VARIANT_CLASSES);
  188.  
  189. if (this._variant !== variant.DEFAULT) {
  190. this.classList.add(`${CLASSNAME}--${this._variant}`);
  191. }
  192. }
  193.  
  194. /**
  195. Name used to submit the data in a form.
  196. @type {String}
  197. @default ""
  198. @htmlattribute name
  199. @htmlattributereflected
  200. */
  201. get name() {
  202. return this._elements.input.name;
  203. }
  204.  
  205. set name(value) {
  206. this._reflectAttribute('name', value);
  207.  
  208. this._elements.input.name = value;
  209. }
  210.  
  211. /**
  212. Whether this field is disabled or not.
  213. @type {Boolean}
  214. @default false
  215. @htmlattribute disabled
  216. @htmlattributereflected
  217. */
  218. get disabled() {
  219. return this._disabled || false;
  220. }
  221.  
  222. set disabled(value) {
  223. this._disabled = transform.booleanAttr(value);
  224. this._reflectAttribute('disabled', this._disabled);
  225.  
  226. this[this._disabled ? 'setAttribute' : 'removeAttribute']('aria-disabled', this._disabled);
  227. this.classList.toggle('is-disabled', this._disabled);
  228.  
  229. this._elements.hours.disabled = this._disabled;
  230. this._elements.minutes.disabled = this._disabled;
  231. // stops the form submission
  232. this._elements.input.disabled = this._disabled;
  233. }
  234.  
  235. /**
  236. Inherited from {@link BaseFormField#invalid}.
  237. */
  238. get invalid() {
  239. return super.invalid;
  240. }
  241.  
  242. set invalid(value) {
  243. super.invalid = value;
  244.  
  245. this._elements.hours.invalid = this._invalid;
  246. this._elements.minutes.invalid = this._invalid;
  247. this._elements.hours.setAttribute("aria-errormessage", this.errorID);
  248. this._elements.minutes.setAttribute("aria-errormessage", this.errorID);
  249.  
  250. const ERROR_LABEL_ELEMENT_CLASS = "._coral-Clock .coral-Form-errorlabel";
  251. const errorLabel = this.querySelector(ERROR_LABEL_ELEMENT_CLASS);
  252.  
  253. if (this._elements.hours.invalid || this._elements.minutes.invalid) {
  254. errorLabel.setAttribute("id", this.errorID);
  255. errorLabel.setAttribute("aria-live", "assertive");
  256. errorLabel.hidden = false;
  257. errorLabel.style.display = "table-caption";
  258. errorLabel.style["caption-side"] = "bottom";
  259. } else {
  260. errorLabel.setAttribute("aria-live", "off");
  261. errorLabel.hidden = true;
  262. }
  263. }
  264.  
  265. /**
  266. Whether this field is required or not.
  267. @type {Boolean}
  268. @default false
  269. @htmlattribute required
  270. @htmlattributereflected
  271. */
  272. get required() {
  273. return this._required || false;
  274. }
  275.  
  276. set required(value) {
  277. this._required = transform.booleanAttr(value);
  278. this._reflectAttribute('required', this._required);
  279.  
  280. this._elements.hours.required = this._required;
  281. this._elements.minutes.required = this._required;
  282. this._elements.input.required = this._required;
  283. }
  284.  
  285. /**
  286. Whether this field is readOnly or not. Indicating that the user cannot modify the value of the control.
  287. @type {Boolean}
  288. @default false
  289. @htmlattribute readonly
  290. @htmlattributereflected
  291. */
  292. get readOnly() {
  293. return this._readOnly || false;
  294. }
  295.  
  296. set readOnly(value) {
  297. this._readOnly = transform.booleanAttr(value);
  298. this._reflectAttribute('readonly', this._readOnly);
  299.  
  300. this._elements.hours.readOnly = this._readOnly;
  301. this._elements.minutes.readOnly = this._readOnly;
  302. this._elements.input.readOnly = this._readOnly;
  303. }
  304.  
  305. /**
  306. This field's current value.
  307. @type {String}
  308. @default ""
  309. @htmlattribute value
  310. */
  311. get value() {
  312. return this._getValueAsString(this._value, this.valueFormat);
  313. }
  314.  
  315. set value(value) {
  316. value = typeof value === 'string' ? value : '';
  317. // This is used to change the value if valueformat is also set but afterwards
  318. this._originalValue = value;
  319.  
  320. // we do strict conversion of the values
  321. const time = new DateTime.Moment(value, this.valueFormat, true);
  322. this._value = time.isValid() ? time : '';
  323. this._elements.input.value = this.value;
  324.  
  325. this._syncValueAsText();
  326. this._syncDisplay();
  327. }
  328.  
  329. /**
  330. Inherited from {@link BaseFormField#labelledBy}.
  331. */
  332. get labelledBy() {
  333. // Get current aria-labelledby attribute on the labellable element.
  334. let labelledBy = this.getAttribute('aria-labelledby');
  335.  
  336. // If a labelledBy attribute has been defined,
  337. if (labelledBy) {
  338. // and strip the valueAsText element id from the end of the aria-labelledby string.
  339. labelledBy = labelledBy.replace(this._elements.valueAsText.id, '').trim();
  340.  
  341. // If the resulting labelledBy string is empty, return null.
  342. if (!labelledBy.length) {
  343. labelledBy = null;
  344. }
  345. }
  346. return labelledBy;
  347. }
  348.  
  349. set labelledBy(value) {
  350. super.labelledBy = value;
  351.  
  352. // The specified labelledBy property.
  353. const labelledBy = this.labelledBy;
  354.  
  355. // An array of element ids to label control, the last being the valueAsText element id.
  356. const ids = [this._elements.valueAsText.id];
  357.  
  358. // If a labelledBy property exists,
  359. if (labelledBy) {
  360. // prepend the labelledBy value to the ids array
  361. ids.unshift(labelledBy);
  362.  
  363. // Set aria-labelledby attribute on the labellable element joining ids array into space-delimited list of ids.
  364. this.setAttribute('aria-labelledby', ids.join(' '));
  365. } else {
  366. // labelledBy property is null, remove the aria-labelledby attribute.
  367. this.removeAttribute('aria-labelledby');
  368. }
  369. }
  370.  
  371. /**
  372. Ignore the date part and use the time part only
  373.  
  374. @private
  375. */
  376. _extractTimeFormat(format, regExp, defaultFormat) {
  377. const match = regExp.exec(format);
  378. return match && match.length && match[0] !== '' ? match[0] : defaultFormat;
  379. }
  380.  
  381. /**
  382. Sync time display based on the format
  383.  
  384. @private
  385. */
  386. _syncDisplay() {
  387. const hourFormat = this._extractTimeFormat(this.displayFormat, HOUR_REG_EXP, DEFAULT_HOUR_FORMAT);
  388. const minuteFormat = this._extractTimeFormat(this.displayFormat, MIN_REG_EXP, DEFAULT_MINUTE_FORMAT);
  389.  
  390. this._elements.hours.placeholder = hourFormat;
  391. this._elements.minutes.placeholder = minuteFormat;
  392.  
  393. this._elements.hours.value = this._getValueAsString(this._value, hourFormat);
  394. this._elements.minutes.value = this._getValueAsString(this._value, minuteFormat);
  395.  
  396. this._syncPeriod();
  397. this._syncValueAsText();
  398. }
  399.  
  400. /**
  401. Sync period selector based on the format
  402.  
  403. @private
  404. */
  405. _syncPeriod() {
  406. const period = this._elements.period;
  407. const time = this._value;
  408. const am = i18n.get('am');
  409. const pm = i18n.get('pm');
  410. const items = period.items.getAll();
  411.  
  412. if (time && time.isValid()) {
  413. if (time.hours() < 12) {
  414. period.value = 'am';
  415. } else {
  416. period.value = 'pm';
  417. }
  418. }
  419.  
  420. // Check for am/pm
  421. if (this.displayFormat.indexOf('a') !== -1) {
  422. items[0].textContent = am;
  423. items[1].textContent = pm;
  424. this._togglePeriod(true);
  425. } else if (this.displayFormat.indexOf('A') !== -1) {
  426. items[0].textContent = am.toUpperCase();
  427. items[1].textContent = pm.toUpperCase();
  428. this._togglePeriod(true);
  429. } else {
  430. this._togglePeriod(false);
  431. }
  432. }
  433.  
  434. /** @private */
  435. _togglePeriod(show) {
  436. this.classList.toggle(`${CLASSNAME}--extended`, show);
  437. this._elements.period.hidden = !show;
  438. }
  439.  
  440. /** @private */
  441. _onPeriodChange(event) {
  442. // stops the event from leaving the component
  443. event.stopImmediatePropagation();
  444.  
  445. const time = this._value;
  446. const period = this._elements.period;
  447.  
  448. // we check if a change event needs to be triggered since it was produced via user interaction
  449. if (time && time.isValid()) {
  450. if (this.displayFormat.indexOf('h') !== -1) {
  451. if (period.value === 'am') {
  452. time.subtract(12, 'h');
  453. } else {
  454. time.add(12, 'h');
  455. }
  456. }
  457.  
  458. this.value = time.format(this.valueFormat);
  459. this.trigger('change');
  460. }
  461. }
  462.  
  463. _syncValueAsText() {
  464. this._elements.valueAsText.textContent = this._getValueAsString(this._value, this.displayFormat);
  465.  
  466. if (!this.getAttribute('aria-labelledby')) {
  467. this.labelledBy = this.labelledBy;
  468. }
  469. }
  470.  
  471. /**
  472. Kills the internal _onInputChange from BaseFormField because it does not check the target.
  473.  
  474. @private
  475. */
  476. _onInputChange(event) {
  477. // stops the event from leaving the component
  478. event.stopImmediatePropagation();
  479.  
  480. let newTime = new DateTime.Moment();
  481. const oldTime = this._value;
  482.  
  483. let hours = parseInt(this._elements.hours.value, 10);
  484. const minutes = parseInt(this._elements.minutes.value, 10);
  485.  
  486. if (window.isNaN(hours) || window.isNaN(minutes)) {
  487. newTime = '';
  488. } else {
  489. if (!this._elements.period.hidden &&
  490. this.displayFormat.indexOf('h') !== -1 &&
  491. this._elements.period.value === 'pm') {
  492. hours += 12;
  493. }
  494.  
  495. newTime.hours(hours);
  496. newTime.minutes(minutes);
  497. }
  498.  
  499. // we check if a change event needs to be triggered since it was produced via user interaction
  500. if (newTime && newTime.isValid()) {
  501. // @polyfill ie
  502. this.invalid = false;
  503.  
  504. if (!newTime.isSame(oldTime, 'hour') || !newTime.isSame(oldTime, 'minute')) {
  505. this.value = newTime.format(this.valueFormat);
  506. this.trigger('change');
  507. }
  508. } else {
  509. // @polyfill ie
  510. this.invalid = true;
  511. // does not sync the inputs so allow the user to continue typing the date
  512. this._value = '';
  513.  
  514. if (newTime !== oldTime) {
  515. this.trigger('change');
  516. }
  517. }
  518. }
  519.  
  520. /**
  521. Helper class that converts the internal moment value into a String using the provided date format. If the value is
  522. invalid, empty string will be returned.
  523.  
  524. @param {?Moment} value
  525. The value representing the date. It has to be a moment object or <code>null</code>
  526. @param {String} format
  527. The Date format to be used.
  528.  
  529. @returns {String} a String representing the value in the given format.
  530.  
  531. @ignore
  532. */
  533. _getValueAsString(value, format) {
  534. return value && value.isValid() ? value.format(format) : '';
  535. }
  536.  
  537. focus() {
  538. // Sets focus to appropriate descendant
  539. if (!this.contains(document.activeElement)) {
  540. this._elements.hours.focus();
  541. }
  542. }
  543.  
  544. /**
  545. Returns {@link Clock} variants.
  546.  
  547. @return {ClockVariantEnum}
  548. */
  549. static get variant() {
  550. return variant;
  551. }
  552.  
  553. static get _attributePropertyMap() {
  554. return commons.extend(super._attributePropertyMap, {
  555. displayformat: 'displayFormat',
  556. valueformat: 'valueFormat'
  557. });
  558. }
  559.  
  560. /** @ignore */
  561. static get observedAttributes() {
  562. return super.observedAttributes.concat([
  563. 'displayformat',
  564. 'valueformat',
  565. 'variant'
  566. ]);
  567. }
  568.  
  569. /** @ignore */
  570. render() {
  571. super.render();
  572.  
  573. this.classList.add(CLASSNAME);
  574.  
  575. // a11y
  576. this.setAttribute('role', 'group');
  577.  
  578. // Default reflected attributes
  579. if (!this._variant) {
  580. this.variant = variant.DEFAULT;
  581. }
  582. if (!this._valueFormat) {
  583. this.valueFormat = DEFAULT_TIME_FORMAT;
  584. }
  585. if (!this._displayFormat) {
  586. this.displayFormat = DEFAULT_TIME_FORMAT;
  587. }
  588.  
  589. // clean up to be able to clone it
  590. while (this.firstChild) {
  591. this.removeChild(this.firstChild);
  592. }
  593.  
  594. // Render template
  595. this.appendChild(this._template);
  596.  
  597. this._syncDisplay();
  598. }
  599. });
  600.  
  601. export default Clock;