@instawork/design-system
Version:
The design system for Instawork's web apps
450 lines • 16.9 kB
JavaScript
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