UNPKG

@instawork/design-system

Version:

The design system for Instawork's web apps

450 lines 16.9 kB
import * as $ from 'jquery'; import { DateTime } from 'luxon'; import { DateUtil, isNoValueString, KeyboardInput } from '../common'; import { TemporalComponent } from '../temporal-component'; import './time-input.component.scss'; import TEMPLATE from './time-input.component.pug'; const DATA_HOURS = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map(hour => hour.toString()); const DATA_MINUTES = [...Array(60).keys()].map(min => min.toString().padStart(2, '0')); const DATA_MERIDIAN = ['AM', 'PM']; const VALID_TIME_INPUT = /\d/; const VALID_HOUR = /^(?:1[0-2])$|^(?:0?[1-9])$/; const VALID_MINUTE = /^[0-5]?[0-9]$/; const VALID_MERIDIAN_INPUT = /[ap]/i; const VALID_MERIDIAN = /^[ap]m?$/i; const DEFAULT_DISABLED_PLACEHOLDER = '-'; DATA_HOURS.forEach(hour => { if (!VALID_HOUR.test(hour)) { throw new Error(`Invalid hour ${hour}`); } }); DATA_MINUTES.forEach(minute => { if (!VALID_MINUTE.test(minute)) { throw new Error(`Invalid minute ${minute}`); } }); function isMultiPartDisplayPlaceholder(obj) { return (obj === null || obj === void 0 ? void 0 : obj.length) === 3; } function checkTimeZoneSupport() { if (!DateUtil.TIME_ZONE_SUPPORT) { console.warn('This browser does not support using explicit time zones when working with date/time values. ' + 'Some features of the <iw-time-input> component may not work as expected.'); } } export class TimeInputComponent extends TemporalComponent { // note: this is required to ensure that inheritance works correctly constructor($el) { super($el); } static loadPlugin() { super.loadPlugin(); if (!$.fn.iwTimeInput) { $.fn.iwTimeInput = this.jQueryPlugin('TimeInputComponent', undefined, checkTimeZoneSupport); } } get placeholder() { return this._placeholder; } set placeholder(placeholder) { this._placeholder = placeholder; this.syncPlaceholder(this.disabled); } get disabledPlaceholder() { const attrValue = super.disabledPlaceholder; if (attrValue === false) { return false; } return attrValue || DEFAULT_DISABLED_PLACEHOLDER; } get hasInput() { return this.parts.some(part => !!$(part).val()); } get displayPlaceholder() { return this._displayPlaceholder; } get hasDisplayPlaceholder() { return !!this.displayPlaceholder; } /** * Returns a jQuery selector of all tabbable elements in the document. * * Used to determine next/previous tab navigation targets. */ getTabbable() { return $('input,button,select,textarea,object,a[href],[tabindex]').filter(':visible:not([tabindex^=-])'); } getDisplayInput() { return this.$el.find('input.time-input__part'); } getDisplayPlaceholder() { return this.$display.filter('[data-part=hour]'); } getValueInput() { return this.$el.find('input[type=hidden]'); } initDomRefs() { super.initDomRefs(); this.$el.attr('tabindex', this.$el.attr('tabindex') || '0'); this.$value.attr('name', this.$el.attr('name')); this.$spacer = this.$el.find('.time-input__spacer'); this.$parts = this.$display; this.parts = this.$parts.toArray(); this.$hour = this.$parts .filter('[data-part=hour]') .data('data', DATA_HOURS) .data('input-validator', VALID_TIME_INPUT) .data('validator', VALID_HOUR); this.$minute = this.$parts .filter('[data-part=minute]') .data('data', DATA_MINUTES) .data('input-validator', VALID_TIME_INPUT) .data('validator', VALID_MINUTE); this.$meridian = this.$parts .filter('[data-part=meridian]') .data('data', DATA_MERIDIAN) .data('input-validator', VALID_MERIDIAN_INPUT) .data('validator', VALID_MERIDIAN); this.$parts.each((index, part) => { const $part = $(part); $part.prop('id', `${this.id}__${$part.data('part')}`); }); } initEvents() { super.initEvents(); this.$parts .on('input', this.onInput.bind(this)) .on('keydown', KeyboardInput.wrapEvent(this.onInputKeyDown.bind(this))) .on('keyup', KeyboardInput.wrapEvent(this.onInputKeyUp.bind(this))) .on('focus', this.onInputFocus.bind(this)) .on('blur', this.onInputBlur.bind(this)) .on('copy', this.onCopy.bind(this)) .on('paste', this.onPaste.bind(this)); this.$el.on('focus', this.onComponentFocus.bind(this)); } initDom() { this.onDisabledChange(this.disabled); } onInput() { // simulate the input event on the component when any part input has an input event this.$el.trigger('input'); } onValue(value, isChanged, isValid) { super.onValue(value, isChanged, isValid); this.setInputValues(value); this.syncSpacer(); } onInputKeyDown(e) { const $el = $(e.target); switch (e.key) { // allow modifier keys (required for shortcuts / reverse tabbing), deleting characters case 'Alt': case 'Backspace': case 'Delete': case 'Meta': case 'Shift': return true; case 'Tab': return e.shiftKey ? this.eventReturn(e, this.tabPrev()) : true; // allow common shortcuts case 'a': case 'c': case 'v': case 'x': case 'z': return this.eventReturn(e, e.metaKey || e.ctrlKey || this.validateKeyDown($el, e.key) && this.handleKeyDown($el, e.key)); case ':': if (this.isHour($el)) { this.$minute.focus(); } return this.eventReturn(e, false); case 'ArrowLeft': case 'ArrowRight': return this.eventReturn(e, this.arrowNav($el, e.key === 'ArrowRight' ? 1 : -1)); case 'ArrowUp': case 'ArrowDown': this.incrementValue($el, e.key === 'ArrowUp' ? 1 : -1); return this.eventReturn(e, false); default: return this.eventReturn(e, this.validateKeyDown($el, e.key) && this.handleKeyDown($el, e.key)); } } eventReturn(e, allowBubble) { if (!allowBubble) { e.stopImmediatePropagation(); e.preventDefault(); } return allowBubble; } onInputKeyUp(e) { if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') { // for some reason, returning false from onInputKeyDown doesn't stop the keyboard event from bubbling to here // in Safari return; } const part = e.target; const $part = $(part); const maxLength = this.getMaxLength($part); if (maxLength && part.selectionStart === maxLength) { const arrowNavHandled = !this.arrowNav($part, 1); if (!arrowNavHandled) { this.tabNext(); } } } onInputFocus(e) { const part = e.target; this.syncSpacer(); // set the selection in a timeout so the mouseup doesn't undo the selection setTimeout(() => part.setSelectionRange(0, part.value.length)); } onInputBlur() { if (!this.hasFocus) { this.syncValue(); } else { this.syncSpacer(); } } onComponentFocus() { this.focusPart(this.$hour[0]); } validatePart($part, value) { value = typeof value === undefined ? this.getPartValue($part) : value; const validator = $part.data('validator'); return validator.test(value); } getPartValue($part) { return $part.val(); } getMaxLength($part) { const maxLength = $part.prop('maxlength'); if (typeof maxLength === 'number') { return maxLength; } return $part.prop('size'); } validatePartInput($part, newValue, inputValue) { const maxLength = this.getMaxLength($part); if (maxLength) { if (newValue.length === maxLength) { return this.validatePart($part, newValue); } } const inputValidator = $part.data('input-validator'); if (inputValidator) { return inputValidator.test(inputValue); } return true; } onCopy(e) { e.originalEvent.clipboardData.setData('text/plain', this.value); return false; } onPaste(e) { const pastedInput = e.originalEvent.clipboardData.getData('text/plain'); this.importValue(pastedInput); return false; } syncValue(value) { if ((value === null || value === void 0 ? void 0 : value.isValid) === false && this.hasFocus) { // hold off updating if the component still has focus (e.g. the user is still editing) // this prevents things like deleting the content of a part from clearing the values from the rest of the parts return value; } if (arguments.length === 0) { value = this.getValueFromInputParts(); } return super.syncValue(value); } getValueFromInputParts() { const inputParts = this.getInputParts(); if (!inputParts) { return undefined; } const date = this.getBaseDate(); const [hour, minute, meridian] = inputParts; // note: using m instead of mm to avoid having to 0-prefix the minute const time = DateTime.fromFormat(`${hour}:${minute} ${meridian}`, 'h:m a'); if ((date === null || date === void 0 ? void 0 : date.isValid) && (time === null || time === void 0 ? void 0 : time.isValid)) { const { year, month, day } = date; const { hour, minute } = time; return DateTime.fromObject({ year, month, day, hour, minute }, { zone: date.zoneName }); } return this.getInvalidValue(date, time); } getInputParts() { var _a; const hourInput = this.getPartValue(this.$hour); const minuteInput = this.getPartValue(this.$minute); const meridianInput = (_a = this.getPartValue(this.$meridian)) === null || _a === void 0 ? void 0 : _a.toLocaleUpperCase(); if (isNoValueString(hourInput) && isNoValueString(minuteInput) && isNoValueString(meridianInput)) { return undefined; } return [hourInput, minuteInput, meridianInput]; } getInvalidValue(date, time) { if (!date.isValid) { return date; } return time; } syncSpacer() { // show the spacer if any part has a value, focus, or if there is a multi-part display placeholder this.$spacer.toggle(this.hasFocus || this.hasInput || this.hasDisplayPlaceholder); } setDisplay(value) { if (!this.$parts) { // initDomRefs has not been called yet, initDom will call onDisabledChange again to make sure this happens return this.$display; } this.setInputValues(value ? this.parseValue(value) : undefined); return this.$display; } setInputValues(time) { // update if there's a valid value, or no value - leave inputs for invalid values as they are if ((time === null || time === void 0 ? void 0 : time.isValid) === true || typeof time === 'undefined') { this.$hour.val(this.formatInputPart(time, 'h')); this.$minute.val(this.formatInputPart(time, 'mm')); this.$meridian.val(this.formatInputPart(time, 'a')); } } formatInputPart(value, format) { if (typeof value === 'undefined') { return ''; } return value.toFormat(format); } isHour($el) { return $el[0] === this.$hour[0]; } isMinute($el) { return $el[0] === this.$minute[0]; } isMeridian($el) { return $el[0] === this.$meridian[0]; } arrowNav($part, dir) { const part = $part[0]; const partIndex = this.parts.indexOf(part); const partValue = this.getPartValue($part); if (partIndex < 0) { throw new Error('oops!'); } const prevPart = this.parts[partIndex - 1]; const nextPart = this.parts[partIndex + 1]; if (dir < 0 && part.selectionStart === 0) { this.focusPart(prevPart); return false; } if (dir > 0 && part.selectionEnd === partValue.length) { this.focusPart(nextPart); return false; } return true; } focusPart(part) { if (!part) { return; } part.focus(); } incrementValue($part, amount, select = false) { const data = $part.data('data'); const curIndex = data.indexOf($part.val()); let newIndex = curIndex < 0 ? 0 : (curIndex + amount); if (newIndex < 0) { newIndex = data.length + newIndex; if (this.isHour($part)) { this.incrementValue(this.$meridian, -1); } else if (this.isMinute($part)) { this.incrementValue(this.$hour, -1); } } else if (newIndex >= data.length) { if (this.isHour($part)) { this.incrementValue(this.$meridian, 1); } else if (this.isMinute($part)) { this.incrementValue(this.$hour, 1); } newIndex = newIndex - data.length; } const newValue = data[newIndex]; $part.val(newValue); this.syncValue(); // IMPORTANT: setting selection needs to happen AFTER syncValue, as syncValue can mess with the selection // in Safari if (select) { $part[0].setSelectionRange(0, newValue.length); } } validateKeyDown($part, key) { const part = $part[0]; const value = (this.getPartValue($part) || '').split(''); value.splice(part.selectionStart, part.selectionEnd - part.selectionStart, key); const newValue = value.join(''); return this.validatePartInput($part, newValue, key); } handleKeyDown($part, key) { if (this.isMeridian($part)) { if (key.toUpperCase() === 'M') { $part[0].setSelectionRange(2, 2); return false; } const assumedValue = DATA_MERIDIAN.find(meridian => meridian.startsWith(key.toUpperCase())); if (assumedValue) { $part.val(assumedValue); $part[0].setSelectionRange(1, assumedValue.length); return false; } } return true; } tabPrev() { const $tabbable = this.getTabbable(); const index = $tabbable.index(this.el); const prevIndex = index - 1 < 0 ? $tabbable.length - 1 : index - 1; $tabbable[prevIndex].focus(); return false; } tabNext() { const $tabbable = this.getTabbable(); const index = $tabbable.index(this.el); const nextIndex = index + 1 === $tabbable.length ? 0 : index + 1; $tabbable[nextIndex].focus(); return false; } getMultipartDisplayPlaceholder(input) { const placeholderParts = input.split(/[:\s]/g); // e.g. "h:m AM" -> ["h", "m", "AM"] if (placeholderParts.length === 2) { placeholderParts.push(''); } if (isMultiPartDisplayPlaceholder(placeholderParts)) { return placeholderParts; } return undefined; } syncPlaceholder(disabled) { if (disabled || !this.placeholder) { return super.syncPlaceholder(disabled); } const displayPlaceholder = this.getMultipartDisplayPlaceholder(this.placeholder); if (!displayPlaceholder) { return super.syncPlaceholder(disabled); } this._displayPlaceholder = displayPlaceholder; if (!this.$parts) { // initDomRefs has not been called yet, initDom will call onDisabledChange again to make sure this happens return; } const [hourPlaceholder, minutePlaceholder, meridianPlaceholder] = displayPlaceholder; this.$hour.prop('placeholder', hourPlaceholder); this.$minute.prop('placeholder', minutePlaceholder); this.$meridian.prop('placeholder', meridianPlaceholder); } } TimeInputComponent.COMPONENT_SELECTOR = 'iw-time-input'; TimeInputComponent.TEMPLATE = TEMPLATE; //# sourceMappingURL=time-input.component.js.map