UNPKG

@eclipse-scout/core

Version:
1,461 lines (1,301 loc) 62.5 kB
/* * Copyright (c) 2010, 2025 BSI Business Systems Integration AG * * This program and the accompanying materials are made * available under the terms of the Eclipse Public License 2.0 * which is available at https://www.eclipse.org/legal/epl-2.0/ * * SPDX-License-Identifier: EPL-2.0 */ import { AddCellEditorFieldCssClassesOptions, aria, arrays, CellEditorPopup, CellEditorRenderedOptions, DateFieldEventMap, DateFieldModel, DateFormat, DateFormatAnalyzeInfo, DatePicker, DatePickerDateSelectEvent, DatePickerPopup, DatePickerTouchPopup, DatePredictionFailedStatus, dates, DateTimeCompositeLayout, Device, Event, fields, focusUtils, FormField, HtmlComponent, InitModelOf, InputFieldKeyStrokeContext, keys, KeyStrokeContext, objects, ParsingFailedStatus, Popup, Predicate, scout, Status, StatusType, strings, styles, TimePicker, TimePickerPopup, TimePickerTimeSelectEvent, TimePickerTouchPopup, ValueField, ValueFieldWithCellEditorRenderedCallback } from '../../../index'; import $ from 'jquery'; export class DateField extends ValueField<Date, Date | string> implements DateFieldModel, ValueFieldWithCellEditorRenderedCallback<Date, Date | string> { declare model: DateFieldModel; declare eventMap: DateFieldEventMap; declare self: DateField; popup: Popup & { getDatePicker?(): DatePicker; getTimePicker?(): TimePicker }; autoDate: Date; dateDisplayText: string; dateHasText: boolean; dateFocused: boolean; dateFormatPattern: string; hasDate: boolean; touchMode: boolean; embedded: boolean; hasTime: boolean; hasTimePopup: boolean; timeDisplayText: string; timeHasText: boolean; timePickerResolution: number; timeFormatPattern: string; timeFocused: boolean; isolatedDateFormat: DateFormat; isolatedTimeFormat: DateFormat; allowedDates: Date[]; htmlDateTimeComposite: HtmlComponent; $dateField: JQuery; $timeField: JQuery; $dateFieldIcon: JQuery; $timeFieldIcon: JQuery; $dateClearIcon: JQuery; $timeClearIcon: JQuery; /** @internal */ _$predictDateField: JQuery; /** @internal */ _$predictTimeField: JQuery; /** * This is the storage for the time (as date) while the focus in the field (e.g. when pressing up/down). In date fields, the date picker is used for that purposes. */ protected _tempTimeDate: Date; protected _cellEditorPopup: CellEditorPopup<Date>; constructor() { super(); this.allowedDates = []; this.popup = null; this.autoDate = null; this.dateDisplayText = null; this.dateHasText = false; this.dateFocused = false; this.dateFormatPattern = null; this.hasDate = true; this.touchMode = false; this.embedded = false; this.hasTime = false; this.hasTimePopup = true; this.timeDisplayText = null; this.timeHasText = false; this.timeDisplayText = null; this.timePickerResolution = 30; this.timeFormatPattern = null; this.timeFocused = false; this.$dateField = null; this.$timeField = null; this.$dateFieldIcon = null; this.$timeFieldIcon = null; this.$dateClearIcon = null; this.$timeClearIcon = null; this._$predictDateField = null; this._$predictTimeField = null; this._tempTimeDate = null; this.invalidValueMessageKey = 'ui.InvalidDate'; this._addCloneProperties(['hasDate', 'hasTime', 'dateFormatPattern', 'timeFormatPattern', 'allowedDates', 'autoDate']); } static ErrorCode = { PARSE_ERROR: -1 }; /** * Predicate function to find a PARSE_ERROR. */ static PARSE_ERROR_PREDICATE: Predicate<Status> = status => status.code === DateField.ErrorCode.PARSE_ERROR; protected override _createKeyStrokeContext(): KeyStrokeContext { return new InputFieldKeyStrokeContext(); } protected override _init(model: InitModelOf<this>) { super._init(model); fields.initTouch(this, model); this.popup = model.popup; this._setAutoDate(this.autoDate); this._setDisplayText(this.displayText); this._setAllowedDates(this.allowedDates); this._setTimePickerResolution(this.timePickerResolution); } /** * Initializes the date format before calling set value. * This cannot be done in _init because the value field would call _setValue first */ protected override _initValue(value: Date) { this._setDateFormatPattern(this.dateFormatPattern); this._setTimeFormatPattern(this.timeFormatPattern); super._initValue(value); } createDatePopup(): Popup { let popupType: new() => DatePickerTouchPopup | DatePickerPopup = this.touchMode ? DatePickerTouchPopup : DatePickerPopup; return scout.create(popupType, { parent: this, $anchor: this.$field, boundToAnchor: !this.touchMode, cssClass: this._errorStatusClass(), closeOnAnchorMouseDown: false, field: this, allowedDates: this.allowedDates, dateFormat: this.isolatedDateFormat }); } createTimePopup(): Popup { let popupType: new() => TimePickerTouchPopup | TimePickerPopup = this.touchMode ? TimePickerTouchPopup : TimePickerPopup; return scout.create(popupType, { parent: this, $anchor: this.$timeField, boundToAnchor: !this.touchMode, cssClass: this._errorStatusClass(), closeOnAnchorMouseDown: false, field: this, timeResolution: this.timePickerResolution }); } protected override _render() { this.addContainer(this.$parent, 'date-field'); this.addLabel(); this.addField(this.$parent.makeDiv('date-time-composite')); this.addStatus(); if (!this.embedded) { this.addMandatoryIndicator(); } this.htmlDateTimeComposite = HtmlComponent.install(this.$field, this.session); this.htmlDateTimeComposite.setLayout(new DateTimeCompositeLayout(this)); } protected override _renderProperties() { this._renderHasDate(); this._renderHasTime(); // Has to be the last call, otherwise _renderErrorStatus() would operate on the wrong state. super._renderProperties(); this._renderDateHasText(); this._renderTimeHasText(); } protected override _remove() { super._remove(); this.$dateField = null; this.$timeField = null; this.$dateFieldIcon = null; this.$timeFieldIcon = null; this.$dateClearIcon = null; this.$timeClearIcon = null; this._$predictDateField = null; this._$predictTimeField = null; this.popup = null; } protected override _renderMandatory() { super._renderMandatory(); // date field uses individual fields, move mandatory attribute aria.required(this.$field, null); aria.required(this.$dateField, this.mandatory || null); aria.required(this.$timeField, this.mandatory || null); } setHasDate(hasDate: boolean) { this.setProperty('hasDate', hasDate); } protected _setHasDate(hasDate: boolean) { this._setProperty('hasDate', hasDate); if (this.initialized) { // if property changes on the fly, update the display text this._updateDisplayTextProperty(); } } protected _renderHasDate() { if (this.hasDate && !this.$dateField) { // Add $dateField this.$dateField = fields.makeInputOrDiv(this, 'date') .on('mousedown', this._onDateFieldMouseDown.bind(this)) .appendTo(this.$field); this._linkWithLabel(this.$dateField); aria.addHiddenDescriptionAndLinkToElement(this.$dateField, this.id + '-date-func-desc', this.session.text('ui.AriaDateFieldDescription', this.dateFormatPattern)); if (this.$timeField) { // make sure date field comes before time field, otherwise tab won't work as expected this.$dateField.insertBefore(this.$timeField); } if (!this.touchMode) { this.$dateField .on('keydown', this._onDateFieldKeyDown.bind(this)) .on('input', this._onDateFieldInput.bind(this)) .on('blur', this._onDateFieldBlur.bind(this)) .on('focus', this._onDateFieldFocus.bind(this)); } HtmlComponent.install(this.$dateField, this.session); this.$dateFieldIcon = fields.appendIcon(this.$field, 'date') .on('mousedown', this._onDateIconMouseDown.bind(this)); aria.hidden(this.$dateFieldIcon, true); } else if (!this.hasDate && this.$dateField) { // Remove $dateField this.$dateField.remove(); this.$dateField = null; this.$dateFieldIcon.remove(); this.$dateFieldIcon = null; } if (!this.rendering) { this._renderDisplayText(); this._renderFieldStyle(); this._renderEnabled(); this.htmlDateTimeComposite.invalidateLayoutTree(); } this._renderDateClearable(); this.$container.toggleClass('has-date', this.hasDate); } setHasTime(hasTime: boolean) { this.setProperty('hasTime', hasTime); } protected _setHasTime(hasTime: boolean) { this._setProperty('hasTime', hasTime); if (this.initialized) { // if property changes on the fly, update the display text this._updateDisplayTextProperty(); } } protected _renderHasTime() { if (this.hasTime && !this.$timeField) { // Add $timeField this.$timeField = fields.makeInputOrDiv(this, 'time') .on('mousedown', this._onTimeFieldMouseDown.bind(this)) .appendTo(this.$field); this._linkWithLabel(this.$timeField); aria.addHiddenDescriptionAndLinkToElement(this.$timeField, this.id + '-time-func-desc', this.session.text('ui.AriaTimeFieldDescription', this.timeFormatPattern)); if (this.$dateField) { // make sure time field comes after date field, otherwise tab won't work as expected this.$timeField.insertAfter(this.$dateField); } if (!this.touchMode || !this.hasTimePopup) { this.$timeField .on('keydown', this._onTimeFieldKeyDown.bind(this)) .on('input', this._onTimeFieldInput.bind(this)) .on('blur', this._onTimeFieldBlur.bind(this)) .on('focus', this._onTimeFieldFocus.bind(this)); } HtmlComponent.install(this.$timeField, this.session); this.$timeFieldIcon = fields.appendIcon(this.$field, 'time') .on('mousedown', this._onTimeIconMouseDown.bind(this)); aria.hidden(this.$timeFieldIcon, true); } else if (!this.hasTime && this.$timeField) { // Remove $timeField this.$timeField.remove(); this.$timeField = null; this.$timeFieldIcon.remove(); this.$timeFieldIcon = null; } if (!this.rendering) { this._renderDisplayText(); this._renderFieldStyle(); this._renderEnabled(); this.htmlDateTimeComposite.invalidateLayoutTree(); } this._renderTimeClearable(); this.$container.toggleClass('has-time', this.hasTime); } setTimePickerResolution(timePickerResolution: number) { this.setProperty('timePickerResolution', timePickerResolution); } protected _setTimePickerResolution(timePickerResolution: number) { if (timePickerResolution < 1) { // default timePickerResolution = 10; this.hasTimePopup = false; } else { this.hasTimePopup = true; } this._setProperty('timePickerResolution', timePickerResolution); } protected override _renderPlaceholder($field?: JQuery) { super._renderPlaceholder(this._fieldForPlaceholder()); } protected override _removePlaceholder($field?: JQuery) { super._removePlaceholder(this._fieldForPlaceholder()); } protected _fieldForPlaceholder(): JQuery { if (this.hasDate) { return this.$dateField; } if (this.hasTime) { return this.$timeField; } return null; } setDateFormatPattern(dateFormatPattern: string) { this.setProperty('dateFormatPattern', dateFormatPattern); } protected _setDateFormatPattern(dateFormatPattern: string) { if (!dateFormatPattern) { dateFormatPattern = this.session.locale.dateFormatPatternDefault; } this._setProperty('dateFormatPattern', dateFormatPattern); this.isolatedDateFormat = new DateFormat(this.session.locale, this.dateFormatPattern); if (this.initialized) { // if format changes on the fly, just update the display text this._updateDisplayText(); } } setTimeFormatPattern(timeFormatPattern: string) { this.setProperty('timeFormatPattern', timeFormatPattern); } protected _setTimeFormatPattern(timeFormatPattern: string) { if (!timeFormatPattern) { timeFormatPattern = this.session.locale.timeFormatPatternDefault; } this._setProperty('timeFormatPattern', timeFormatPattern); this.isolatedTimeFormat = new DateFormat(this.session.locale, this.timeFormatPattern); if (this.initialized) { // if format changes on the fly, just update the display text this._updateDisplayText(); } } protected override _renderEnabled() { super._renderEnabled(); this.$container.setEnabled(this.enabledComputed); if (this.$dateField) { this.$dateField.setEnabled(this.enabledComputed); } if (this.$timeField) { this.$timeField.setEnabled(this.enabledComputed); } } protected override _renderDisplayText() { if (this.hasDate) { this._renderDateDisplayText(); } if (this.hasTime) { this._renderTimeDisplayText(); } this._removePredictionFields(); } protected override _readDisplayText(): string { let dateDisplayText: string, timeDisplayText: string; if (this.hasDate) { dateDisplayText = this._readDateDisplayText(); } if (this.hasTime) { timeDisplayText = this._readTimeDisplayText(); } return this._computeDisplayText(dateDisplayText, timeDisplayText); } protected _renderDateDisplayText() { fields.valOrText(this.$dateField, this.dateDisplayText); this._updateDateHasText(); } protected _readDateDisplayText(): string { return this._$predictDateField ? fields.valOrText(this._$predictDateField) : fields.valOrText(this.$dateField); } protected _renderTimeDisplayText() { fields.valOrText(this.$timeField, this.timeDisplayText); this._updateTimeHasText(); } protected _readTimeDisplayText(): string { return this._$predictTimeField ? fields.valOrText(this._$predictTimeField) : fields.valOrText(this.$timeField); } override setDisplayText(displayText: string) { // Overridden to avoid the equals check -> make sure renderDisplayText is executed whenever setDisplayText is called // Reason: key up/down and picker day click modify the display text, but input doesn't // -> reverting to a date using day click or up down after the input changed would not work anymore // changing 'onXyInput' to always update the display text would fix that, but would break acceptInput this._setDisplayText(displayText); if (this.rendered) { this._renderDisplayText(); } } protected _setDisplayText(displayText: string) { this._setProperty('displayText', displayText); let parts = this._splitDisplayText(displayText); if (this.hasDate) { // preserve dateDisplayText if hasDate is set to false (only override if it is true) this.dateDisplayText = parts.dateText; } if (this.hasTime) { // preserve timeDisplayText if hasTime is set to false (only override if it is true) this.timeDisplayText = parts.timeText; } } protected override _ensureValue(value: Date | string): Date { return dates.ensure(value); } protected override _validateValue(value: Date): Date { if (objects.isNullOrUndefined(value)) { return value; } if (!(value instanceof Date)) { throw this.session.text(this.invalidValueMessageKey); } if (!this.isDateAllowed(value)) { throw this.session.text('DateIsNotAllowed'); } if (!this.hasDate && !this.value) { // truncate to 01.01.1970 if no date was entered before. Otherwise preserve date part (important for toggling hasDate on the fly) value = dates.combineDateTime(null, value); } return value; } isDateAllowed(date: Date): boolean { if (!date || this.allowedDates.length === 0 || this.embedded) { // in embedded mode, main date field must take care of validation, otherwise error status won't be shown return true; } let dateAsTimestamp = dates.trunc(date).getTime(); return this.allowedDates.some(allowedDate => allowedDate.getTime() === dateAsTimestamp); } protected override _valueEquals(valueA: Date, valueB: Date): boolean { return dates.equals(valueA, valueB); } setAutoDate(autoDate: Date | string) { this.setProperty('autoDate', autoDate); } protected _setAutoDate(autoDate: Date | string) { autoDate = dates.ensure(autoDate); this._setProperty('autoDate', autoDate); } setAllowedDates(allowedDates: (string | Date)[]) { this.setProperty('allowedDates', allowedDates); } protected _setAllowedDates(allowedDates: (string | Date)[]) { let truncDates = []; arrays.ensure(allowedDates).forEach(date => { if (date) { truncDates.push(dates.trunc(dates.ensure(date))); } }); truncDates = truncDates.sort(dates.compare); this._setProperty('allowedDates', truncDates); } /** @internal */ override _renderErrorStatus() { super._renderErrorStatus(); let hasStatus = !!this.errorStatus, statusClass = this._errorStatusClass(); if (this.$dateField) { this._updateErrorStatusClassesOnElement(this.$dateField, statusClass, hasStatus); // Because the error color of field icons depends on the error status of sibling <input> elements. // The prediction fields are clones of the input fields, so the 'has-error' class has to be // removed from them as well to make the icon "valid". this._updateErrorStatusClassesOnElement(this._$predictDateField, statusClass, hasStatus); } // Do the same for the time field if (this.$timeField) { this._updateErrorStatusClassesOnElement(this.$timeField, statusClass, hasStatus); this._updateErrorStatusClassesOnElement(this._$predictTimeField, statusClass, hasStatus); } if (this.popup) { this._updateErrorStatusClassesOnElement(this.popup.$container, statusClass, hasStatus); } } protected _errorStatusClass(): string { return (this.errorStatus && !this._isSuppressStatusField()) ? 'has-' + this.errorStatus.cssClass() : ''; } protected override _renderFont() { this.$dateField && styles.legacyFont(this, this.$dateField); this.$timeField && styles.legacyFont(this, this.$timeField); } protected override _renderForegroundColor() { this.$dateField && styles.legacyForegroundColor(this, this.$dateField); this.$timeField && styles.legacyForegroundColor(this, this.$timeField); } protected override _renderBackgroundColor() { this.$dateField && styles.legacyBackgroundColor(this, this.$dateField); this.$timeField && styles.legacyBackgroundColor(this, this.$timeField); } override activate() { if (!this.enabledComputed || !this.rendered) { return; } if (this.$dateField) { this.$dateField.focus(); this._onDateFieldMouseDown(); } else if (this.$timeField) { this.$timeField.focus(); this._onTimeFieldMouseDown(); } } override getFocusableElement(): JQuery { if (this.$dateField) { return this.$dateField; } if (this.$timeField) { return this.$timeField; } return null; } protected _onDateFieldMouseDown() { if (fields.handleOnClick(this)) { this.openDatePopupAndSelect(this.value); } } protected _onTimeFieldMouseDown() { if (fields.handleOnClick(this)) { this.openTimePopupAndSelect(this.value); } } setDateFocused(dateFocused: boolean) { this.setProperty('dateFocused', dateFocused); } protected _renderDateFocused() { this.$container.toggleClass('date-focused', this.dateFocused); } protected _updateTimeHasText() { this.setTimeHasText(strings.hasText(this._readTimeDisplayText())); } setTimeHasText(timeHasText: boolean) { this.setProperty('timeHasText', timeHasText); } protected _renderTimeHasText() { if (this.$timeField) { this.$timeField.toggleClass('has-text', this.timeHasText); } this.$container.toggleClass('time-has-text', this.timeHasText); } protected _updateDateHasText() { this.setDateHasText(strings.hasText(this._readDateDisplayText())); } setDateHasText(dateHasText: boolean) { this.setProperty('dateHasText', dateHasText); } protected _renderDateHasText() { if (this.$dateField) { this.$dateField.toggleClass('has-text', this.dateHasText); } this.$container.toggleClass('date-has-text', this.dateHasText); } override clear() { if (!(this.hasDate && this.hasTime)) { super.clear(); return; } this._clear(); // If field shows date and time, don't accept input while one field has the focus // Reason: x icon is shown in one field, pressing that icon should clear the content of that field. // Accept input would set the value to '', thus clearing both fields which may be unexpected. if (!this.dateFocused && !this.timeFocused) { this.acceptInput(); } this._triggerClear(); } protected override _clear() { this._removePredictionFields(); if (this.hasDate && !this.timeFocused) { fields.valOrText(this.$dateField, ''); this._setDateValid(true); this._updateDateHasText(); } if (this.hasTime && !this.dateFocused) { fields.valOrText(this.$timeField, ''); this._setTimeValid(true); this._updateTimeHasText(); } } protected _onDateClearIconMouseDown(event: JQuery.MouseDownEvent) { if (!this.enabledComputed) { return; } this.$dateField.focus(); this.clear(); if (this.value) { this.selectDate(this.value, false); } else { this.preselectDate(this._referenceDate(), false); } event.preventDefault(); } protected _onDateIconMouseDown(event: JQuery.MouseDownEvent) { if (!this.enabledComputed) { return; } this.$dateField.focus(); if (!this.embedded) { this.openDatePopupAndSelect(this.value); } } setTimeFocused(timeFocused: boolean) { this.setProperty('timeFocused', timeFocused); } protected _renderTimeFocused() { this.$container.toggleClass('time-focused', this.timeFocused); } protected override _renderClearable() { this._renderDateClearable(); this._renderTimeClearable(); this._updateClearableStyles(); } protected _renderDateClearable() { if (this.hasDate && this.isClearable()) { if (!this.$dateClearIcon) { // date clear icon this.$dateClearIcon = this.$field.appendSpan('icon date-clear unfocusable text-field-icon action') .on('mousedown', this._onDateClearIconMouseDown.bind(this)); aria.role(this.$dateClearIcon, 'button'); aria.label(this.$dateClearIcon, this.session.text('ui.ClearField'), true); } } else { if (this.$dateClearIcon) { // Remove clear icon this.$dateClearIcon.remove(); this.$dateClearIcon = null; } } } protected _renderTimeClearable() { if (this.hasTime && this.isClearable()) { if (!this.$timeClearIcon) { // time clear icon this.$timeClearIcon = this.$field.appendSpan('icon time-clear unfocusable text-field-icon action') .on('mousedown', this._onTimeClearIconMouseDown.bind(this)); aria.role(this.$timeClearIcon, 'button'); aria.label(this.$timeClearIcon, this.session.text('ui.ClearField'), true); } } else { if (this.$timeClearIcon) { // Remove clear icon this.$timeClearIcon.remove(); this.$timeClearIcon = null; } } } protected _onTimeClearIconMouseDown(event: JQuery.MouseDownEvent) { if (!this.enabledComputed) { return; } this.$timeField.focus(); this.clear(); if (this.value) { this.selectTime(this.value); } else { this.preselectTime(this._referenceDate()); } event.preventDefault(); } protected _onTimeIconMouseDown(event: JQuery.MouseDownEvent) { if (!this.enabledComputed) { return; } this.$timeField.focus(); if (!this.embedded) { this.openTimePopupAndSelect(this.value); } } protected _onDateFieldBlur(event: JQuery.BlurEvent) { this.setFocused(false); this.setDateFocused(false); if (this.embedded) { // Don't execute, otherwise date would be accepted even though touch popup is still open. // This prevents following behavior: user clears date by pressing x and then selects another date. Now a blur event is triggered which would call acceptDate and eventually remove the time // -> Don't accept as long as touch dialog is open return; } // Close picker and update model if (this.popup instanceof DatePickerPopup) { // in embedded mode we must update the date prediction but not close the popup (don't accidentally close time picker popup) this.closePopup(); } this.setDateFocused(false); this.acceptDate(); this._removePredictionFields(); } protected _onDateFieldFocus(event: JQuery.FocusEvent) { this.setFocused(true); this.setDateFocused(true); } protected _onTimeFieldBlur(event: JQuery.BlurEvent) { this._tempTimeDate = null; this.setFocused(false); this.setTimeFocused(false); if (this.embedded) { // Don't execute, otherwise time would be accepted even though touch popup is still open. // This prevents following behavior: user clears time by pressing x and then selects another time. Now a blur event is triggered which would call acceptTime and eventually remove the date // -> Don't accept as long as touch dialog is open return; } // Close picker and update model if (this.popup instanceof TimePickerPopup) { // in embedded mode we must update the date prediction but not close the popup this.closePopup(); } this._tempTimeDate = null; this.setTimeFocused(false); this.acceptTime(); this._removePredictionFields(); } protected _onTimeFieldFocus() { this.setFocused(true); this.setTimeFocused(true); } /** * Handle "navigation" keys, i.e. keys that don't emit visible characters. Character input is handled * in _onDateFieldInput(), which is fired after 'keydown'. */ protected _onDateFieldKeyDown(event: JQuery.KeyDownEvent) { let delta = 0, diffYears = 0, diffMonths = 0, diffDays = 0, cursorPos = (this.$dateField[0] as HTMLInputElement).selectionStart, displayText = fields.valOrText(this.$dateField), prediction = this._$predictDateField && fields.valOrText(this._$predictDateField), modifierCount = (event.ctrlKey ? 1 : 0) + (event.shiftKey ? 1 : 0) + (event.altKey ? 1 : 0) + (event.metaKey ? 1 : 0), pickerStartDate = this.value || this._referenceDate(), shiftDate = true, which = event.which; // Don't propagate tab to cell editor -> tab should focus time field if (this.hasTime && this.mode === FormField.Mode.CELLEDITOR && which === keys.TAB && modifierCount === 0) { event.stopPropagation(); return; } if (which === keys.TAB || which === keys.SHIFT || which === keys.HOME || which === keys.END || which === keys.CTRL || which === keys.ALT) { // Default handling return; } if (which === keys.ENTER) { if (this.popup || this._$predictDateField) { // Close the picker and accept the current prediction (if available) this.acceptDate(); this.closePopup(); $.suppressEvent(event); } return; } if (which === keys.ESC) { if (this.popup) { // Close the picker, but don't do anything else this.closePopup(); $.suppressEvent(event); } return; } if (which === keys.RIGHT && cursorPos === displayText.length) { // Move cursor one right and apply next char of the prediction if (prediction) { this._setDateDisplayText(prediction.substring(0, displayText.length + 1)); } return; } if (which === keys.UP || which === keys.DOWN || which === keys.PAGE_UP || which === keys.PAGE_DOWN) { if (displayText && !this._isDateValid()) { // If there is an error, try to parse the date. If it may be parsed, the error was likely a validation error. // In that case use the parsed date as starting point and not the for the user invisible value let parsedValue = this.isolatedDateFormat.parse(displayText, pickerStartDate); if (parsedValue) { pickerStartDate = parsedValue; this._setDateValid(true); } } } if (which === keys.PAGE_UP || which === keys.PAGE_DOWN) { if (!displayText || !this._isDateValid()) { // If input is empty or invalid, set picker to reference date pickerStartDate = this._referenceDate(); if (this.hasTime) { // keep time part pickerStartDate = dates.combineDateTime(pickerStartDate, this.value || this._referenceDate()); } this.openDatePopupAndSelect(pickerStartDate); this._updateDisplayText(pickerStartDate); this._setDateValid(true); shiftDate = false; // don't shift if field has no value yet and popup was not open } else if (!this.popup) { // Otherwise, ensure picker is open this.openDatePopupAndSelect(pickerStartDate); } if (shiftDate) { diffMonths = (which === keys.PAGE_UP ? -1 : 1); this.shiftSelectedDate(0, diffMonths, 0); this._updateDisplayText(this.getDatePicker().selectedDate); } $.suppressEvent(event); return; } if (which === keys.UP || which === keys.DOWN) { delta = (which === keys.UP ? -1 : 1); // event.ctrlKey || event.metaKey --> some keystrokes with ctrl modifier are captured and suppressed by osx, use command key instead if ((event.ctrlKey || event.metaKey) && modifierCount === 1) { // only ctrl diffYears = delta; } else if (event.shiftKey && modifierCount === 1) { // only shift diffMonths = delta; } else if (modifierCount === 0) { // no modifier diffDays = delta; } else { // Unsupported modifier or too many modifiers $.suppressEvent(event); return; } if (!displayText || !this._isDateValid()) { // If input is empty or invalid, set picker to reference date pickerStartDate = this._referenceDate(); if (this.hasTime) { // keep time part pickerStartDate = dates.combineDateTime(pickerStartDate, this.value || this._referenceDate()); } this.openDatePopupAndSelect(pickerStartDate); this._updateDisplayText(pickerStartDate); this._setDateValid(true); shiftDate = false; // don't shift if field has no value yet and popup was not open } else if (!this.popup) { // Otherwise, ensure picker is open this.openDatePopupAndSelect(pickerStartDate); } if (shiftDate) { this.shiftSelectedDate(diffYears, diffMonths, diffDays); this._updateDisplayText(this.getDatePicker().selectedDate); } $.suppressEvent(event); } } /** * Handle changed input. This method is fired when the field's content has been altered by a user * action (not by JS) such as pressing a character key, deleting a character using DELETE or * BACKSPACE, cutting or pasting text with ctrl-x / ctrl-v or mouse drag-and-drop. * Keys that don't alter the content (e.g. modifier keys, arrow keys, home, end etc.) are handled * in _onDateFieldKeyDown(). */ protected _onDateFieldInput(event: JQuery.TriggeredEvent) { let displayText = fields.valOrText(this.$dateField); // If the focus has changed to another field in the meantime, don't predict anything and // don't show the picker. Just validate the input. if (this.$dateField[0] !== this.$dateField.activeElement(true)) { return; } // Create $predictDateField if necessary if (!this._$predictDateField) { this._$predictDateField = this._createPredictionField(this.$dateField); } // Predict date this._removePredictErrorStatus(); let datePrediction = this._predictDate(displayText); // this also updates the errorStatus if (datePrediction) { fields.valOrText(this._$predictDateField, datePrediction.text); this.openDatePopupAndSelect(datePrediction.date); } else { // No valid prediction! this._removePredictionFields(); } this._updateDateHasText(); // Hide the prediction field if input field is scrolled to the left. Otherwise, the // two fields would not be aligned correctly, which looks bad. This can only happen // when the fields are rather small, so the prediction would be of limited use anyway. // Unfortunately, most browsers don't fire 'scroll' events for input fields. Also, // when the 'input' even is fired, the scrollLeft() position sometimes has not been // updated yet, that's why we must use setTimeout() with a short delay. setTimeout(() => { if (this._$predictDateField) { this._$predictDateField.setVisible(this.$dateField.scrollLeft() === 0); } }, 50); } override acceptInput(whileTyping?: boolean) { let displayText = scout.nvl(this._readDisplayText(), ''); let inputChanged = this._checkDisplayTextChanged(displayText); if (inputChanged) { this.parseAndSetValue(displayText); } else { let oldValue = this.value; this.parseAndSetValue(displayText); if (!dates.equals(this.value, oldValue)) { inputChanged = true; } } if (inputChanged) { this._triggerAcceptInput(whileTyping); } } /** * Clears the time field if date field is empty before accepting the input.<br/> * Don't delete invalid input from the time field. */ acceptDate() { let invalid = this.containsStatus(ParsingFailedStatus); if (this.hasTime && !invalid && strings.empty(this.$dateField.val() as string)) { this.$timeField.val(''); } this.acceptInput(); } /** * Clears the date field if time field is empty before accepting the input.<br/> * Don't delete invalid input from the time field. */ acceptTime() { let invalid = this.containsStatus(ParsingFailedStatus); if (this.hasDate && !invalid && strings.empty(this.$timeField.val() as string)) { this.$dateField.val(''); } this.acceptInput(); } acceptDateTime(acceptDate: boolean, acceptTime: boolean) { if (acceptDate) { this.acceptDate(); } else if (acceptTime) { this.acceptTime(); } } /** * Handle "navigation" keys, i.e. keys that don't emit visible characters. Character input is handled * in _onTimeFieldInput(), which is fired after 'keydown'. */ protected _onTimeFieldKeyDown(event: JQuery.KeyDownEvent) { let delta = 0, diffHours = 0, diffMinutes = 0, diffSeconds = 0, cursorPos = (this.$timeField[0] as HTMLInputElement).selectionStart, displayText = this.$timeField.val() as string, prediction = this._$predictTimeField && this._$predictTimeField.val() as string, modifierCount = (event.ctrlKey ? 1 : 0) + (event.shiftKey ? 1 : 0) + (event.altKey ? 1 : 0) + (event.metaKey ? 1 : 0), pickerStartTime = this.value || this._referenceDate(), shiftTime = true, which = event.which; // Don't propagate shift-tab to cell editor -> shift tab should focus date field if (this.hasDate && this.mode === FormField.Mode.CELLEDITOR && which === keys.TAB && event.shiftKey && modifierCount === 1) { event.stopPropagation(); return; } if (which === keys.TAB || which === keys.SHIFT || which === keys.HOME || which === keys.END || which === keys.CTRL || which === keys.ALT) { // Default handling return; } if (which === keys.ENTER) { // TimeField is shown in touch popup, so we need to make sure time gets accepted and popup closed, even if the regular time field itself has no popup if (this.popup || this._$predictDateField) { // Accept the current prediction (if available) this._tempTimeDate = null; this.acceptTime(); this.closePopup(); $.suppressEvent(event); } return; } if (which === keys.ESC) { if (this.popup) { // Close the picker, but don't do anything else this.closePopup(); $.suppressEvent(event); } return; } if (which === keys.RIGHT && cursorPos === displayText.length) { // Move cursor one right and apply next char of the prediction if (prediction) { this._setTimeDisplayText(prediction.substring(0, displayText.length + 1)); } return; } if (which === keys.UP || which === keys.DOWN) { delta = (which === keys.UP ? -1 : 1); if (event.ctrlKey && modifierCount === 1) { // only ctrl diffSeconds = delta; } else if (event.shiftKey && modifierCount === 1) { // only shift diffHours = delta; } else if (modifierCount === 0) { // no modifier diffMinutes = delta; } else { // Unsupported modifier or too many modifiers $.suppressEvent(event); return; } if (this.hasTimePopup) { if (!displayText || !this._isTimeValid()) { // If input is empty or invalid, set picker to reference date pickerStartTime = this._referenceDate(); this.openTimePopupAndSelect(pickerStartTime); this._updateDisplayText(pickerStartTime); this._setTimeValid(true); shiftTime = false; // don't shift if field has no value yet and popup was not open } else if (!this.popup) { // Otherwise, ensure picker is open this.openTimePopupAndSelect(pickerStartTime); } if (shiftTime) { this.shiftSelectedTime(diffHours, diffMinutes, diffSeconds); this._updateDisplayText(this.getTimePicker().selectedTime); } $.suppressEvent(event); } else { // without picker if (!this._tempTimeDate) { let timePrediction = this._predictTime(displayText); // this also updates the errorStatus if (timePrediction && timePrediction.date) { this._tempTimeDate = timePrediction.date; } else { this._tempTimeDate = this._referenceDate(); shiftTime = false; } } if (shiftTime) { this._tempTimeDate = dates.shiftTime(this._tempTimeDate, diffHours, diffMinutes, diffSeconds); } if (this.hasDate) { // Combine _tempTimeDate with existing date part this._tempTimeDate = dates.combineDateTime(this.value || this._referenceDate(), this._tempTimeDate); } this._updateDisplayText(this._tempTimeDate); this._setTimeValid(true); $.suppressEvent(event); } } } /** * Handle changed input. This method is fired when the field's content has been altered by a user * action (not by JS) such as pressing a character key, deleting a character using DELETE or * BACKSPACE, cutting or pasting text with ctrl-x / ctrl-v or mouse drag-and-drop. * Keys that don't alter the content (e.g. modifier keys, arrow keys, home, end etc.) are handled * in _onTimeFieldKeyDown(). */ protected _onTimeFieldInput(event: JQuery.TriggeredEvent) { let displayText = this.$timeField.val() as string; // If the focus has changed to another field in the meantime, don't predict anything and // don't show the picker. Just validate the input. if (this.$timeField[0] !== this.$timeField.activeElement(true)) { return; } // Create $predictTimeField if necessary if (!this._$predictTimeField) { this._$predictTimeField = this._createPredictionField(this.$timeField); } // Predict time let timePrediction = this._predictTime(displayText); // this also updates the errorStatus if (timePrediction) { this._$predictTimeField.val(timePrediction.text); this.openTimePopupAndSelect(timePrediction.date); } else { // No valid prediction! this._tempTimeDate = null; this._removePredictionFields(); } this._updateTimeHasText(); // See comment for similar code in _onDateFieldInput() setTimeout(() => { if (this._$predictTimeField) { this._$predictTimeField.setVisible(this.$timeField.scrollLeft() === 0); } }, 50); } protected _onDatePickerDateSelect(event: DatePickerDateSelectEvent) { this._setNewDateTimeValue(this._newTimestampAsDate(event.date, this.value)); } protected _onTimePickerTimeSelect(event: TimePickerTimeSelectEvent) { this._setNewDateTimeValue(this._newTimestampAsDate(this.value, event.time)); } protected _setNewDateTimeValue(newValue: Date) { this._setDateValid(true); this._setTimeValid(true); this.setValue(newValue); this._triggerAcceptInput(false); this.closePopup(); } protected _createPredictionField($inputField: JQuery): JQuery { this.setSuppressStatus(FormField.SuppressStatus.ALL); let $predictionField = $inputField.clone() .addClass('predict') .attr('tabindex', '-2') .insertBefore($inputField); if ($inputField.hasClass('has-error')) { $predictionField.addClass('has-error'); } return $predictionField; } protected _removePredictionFields() { this.setSuppressStatus(null); if (this._$predictDateField) { this._$predictDateField.remove(); this._$predictDateField = null; } if (this._$predictTimeField) { this._$predictTimeField.remove(); this._$predictTimeField = null; } } protected _setDateDisplayText(displayText: string) { this.dateDisplayText = displayText; this._updateDisplayTextProperty(); if (this.rendered) { this._renderDateDisplayText(); } } protected _setTimeDisplayText(displayText: string) { this.timeDisplayText = displayText; this._updateDisplayTextProperty(); if (this.rendered) { this._renderTimeDisplayText(); } } protected _computeDisplayText(dateDisplayText: string, timeDisplayText: string): string { let dateText = dateDisplayText || '', timeText = timeDisplayText || ''; // do not use strings.join which ignores empty components let displayText = (this.hasDate ? dateText : '') + (this.hasDate && this.hasTime ? '\n' : '') + (this.hasTime ? timeText : ''); // empty display text should always be just an empty string if (displayText === '\n') { displayText = ''; } return displayText; } protected _splitDisplayText(displayText: string): { dateText: string; timeText: string } { let dateText = '', timeText = ''; if (strings.hasText(displayText)) { let parts = displayText.split('\n'); dateText = this.hasDate ? parts[0] : ''; timeText = this.hasTime ? (this.hasDate ? parts[1] : parts[0]) : ''; } return { dateText: dateText, timeText: timeText }; } protected _updateDisplayTextProperty() { this._setProperty('displayText', this._computeDisplayText(this.dateDisplayText, this.timeDisplayText)); } override aboutToBlurByMouseDown(target: Element) { let eventOnDateField = this.$dateField ? (this.$dateField.isOrHas(target) || this.$dateFieldIcon.isOrHas(target) || (this.$dateClearIcon && this.$dateClearIcon.isOrHas(target))) : false, eventOnTimeField = this.$timeField ? (this.$timeField.isOrHas(target) || this.$timeFieldIcon.isOrHas(target) || (this.$timeClearIcon && this.$timeClearIcon.isOrHas(target))) : false, eventOnPopup = this.popup && this.popup.$container.isOrHas(target), eventOnStatus = this.fieldStatus && this.fieldStatus.$container.isOrHas(target), datePicker = this.getDatePicker(), timePicker = this.getTimePicker(); if (!eventOnDateField && !eventOnTimeField && !eventOnPopup && !eventOnStatus) { // event outside this field. let dateFieldActive = focusUtils.isActiveElement(this.$dateField); let timeFieldActive = focusUtils.isActiveElement(this.$timeField); // Accept only the currently focused part (the other one cannot have a pending change) this.acceptDateTime(dateFieldActive, timeFieldActive); return; } // when date-field is embedded, time-prediction must be accepted before // the date-picker triggers the 'dateSelect' event. if (this.embedded) { let eventOnDatePicker = datePicker && datePicker.$container.isOrHas(target); let eventOnTimePicker = timePicker && timePicker.$container.isOrHas(target); if (eventOnDatePicker && eventOnTimePicker) { this.acceptTime(); } } } /** * Returns null if both arguments are not set. Otherwise, this.value or the current date * is used as basis and the given arguments are applied to that date. The result is returned. */ protected _newTimestampAsDate(date: Date, time: Date): Date { let result: Date = null; if (date || time) { result = this.value || this._referenceDate(); if (date) { result = dates.combineDateTime(date, result); } if (time) { result = dates.combineDateTime(result, time); } } return result; } /** * Returns the reference date for this date field, which is used in various places (i.e. opening the date picker, analyzing user inputs). * * The reference date is either (in that order): * - the model's "auto timestamp" (as date), or * - the current date/time */ protected _referenceDate(): Date { let referenceDate: Date = this.autoDate || dates.ceil(dates.newDate(), this.timePickerResolution); if (this.autoDate) { referenceDate = this.autoDate; } else if (this.hasTime) { referenceDate = dates.ceil(dates.newDate(), this.timePickerResolution); } else { referenceDate = dates.trunc(dates.newDate()); } if (this.allowedDates) { referenceDate = this._findAllowedReferenceDate(referenceDate); } return referenceDate; } /** * Find nearest allowed date which is equals or greater than the current referenceDate. */ protected _findAllowedReferenceDate(referenceDate: Date): Date { // 1st: try to find a date which is equals or greater than the referenceDate (today) for (let i = 0; i < this.allowedDates.length; i++) { let allowedDate = this.allowedDates[i]; if (dates.compare(allowedDate, referenceDate) >= 0) { return allowedDate; } } // 2nd: try to find an allowed date in the past for (let i = this.allowedDates.length - 1; i >= 0; i--) { let allowedDate = this.allowedDates[i]; if (dates.compare(allowedDate, referenceDate) <= 0) { return allowedDate; } } return referenceDate; } openDatePopup() { if (this.popup) { // already open return; } this.popup = this.createDatePopup(); this.popup.open(); this.$dateField.addClass('focused'); this.popup.one('destroy', event => { // Removing the class must happen before _onPopupDestroy() is called, otherwise the date field no longer exists, // because in touch mode _onPopupDestroy() destroys the date field. this.$dateField.removeClass('focused'); this._onPopupDestroy(event); this.popup = null; }); this.getDatePicker().on('dateSelect', this._onDatePickerDateSelect.bind(this)); } closePopup() { if (this.popup) { this.popup.close(); } } toggleDatePopup() { $.log.isInfoEnabled() && $.log.info('(DateField#toggleDatePopup) popupOpen=', !!this.popup); if (this.popup) { this.closePopup(); } else { this.openDatePopupAndSelect(this.value); } } openTimePopup() { if (!this.hasTimePopup || this.popup) { // already open return; } this.popup = this.createTimePopup(); this.popup.open(); this.$timeField.addClass('focused'); this.popup.one('destroy', event => { // Removing the class must happen before _onPopupDestroy() is called, otherwise the date field no longer exists, // because in touch mode _onPopupDestroy() destroys the date field. this.$timeField.removeClass('focused'); this._onPopupDestroy(event); this.popup = null; }); this.getTimePicker().on('timeSelect', this._onTimePickerTimeSelect.bind(this)); } toggleTimePopup() { $.log.isInfoEnabled() && $.log.info('(DateField#toggleTimePopup) popupOpen=', !!this.popup); if (this.popup) { this.closePopup(); } else { this.openTimePopupAndSelect(this.value); } } protected override _parseValue(displayText: string): Date { let parts = this._splitDisplayText(displayText); let dateText = parts.dateText; let datePrediction: Partial<DateFieldPredictionResult> = {}; let timeText = parts.timeText; let timePrediction: Partial<DateFieldPredictionResult> = {}; let success = true; this._removePredictErrorStatus(); if (this.hasDate) { datePrediction = this._predictDate(dateText); // this also updates the errorStatus if (!datePrediction) { success = false; } this._setDateDisplayText(dateText); } if (this.hasTime) { timePrediction = this._predictTime(timeText); // this also updates the errorStatus if (!timePrediction) { success = false; } this._setTimeDisplayTex