diff --git a/README.md b/README.md index 1ab27b7..ada099a 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# tp-element +# tp-date-input diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2cc17fa --- /dev/null +++ b/package-lock.json @@ -0,0 +1,132 @@ +{ + "name": "@tp/tp-date-input", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@tp/tp-date-input", + "version": "0.0.1", + "license": "Apache-2.0", + "dependencies": { + "date-fns": "^2.28.0", + "date-fns-tz": "^1.3.3", + "lit": "^2.2.0" + } + }, + "node_modules/@lit/reactive-element": { + "version": "1.3.1", + "resolved": "https://verdaccio.codeblob.work/@lit%2freactive-element/-/reactive-element-1.3.1.tgz", + "integrity": "sha512-nOJARIr3pReqK3hfFCSW2Zg/kFcFsSAlIE7z4a0C9D2dPrgD/YSn3ZP2ET/rxKB65SXyG7jJbkynBRm+tGlacw==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.2", + "resolved": "https://verdaccio.codeblob.work/@types%2ftrusted-types/-/trusted-types-2.0.2.tgz", + "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "2.28.0", + "resolved": "https://verdaccio.codeblob.work/date-fns/-/date-fns-2.28.0.tgz", + "integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==", + "license": "MIT", + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/date-fns-tz": { + "version": "1.3.3", + "resolved": "https://verdaccio.codeblob.work/date-fns-tz/-/date-fns-tz-1.3.3.tgz", + "integrity": "sha512-Gks46gwbSauBQnV3Oofluj1wTm8J0tM7sbSJ9P+cJq/ZnTCpMohTKmmO5Tn+jQ7dyn0+b8G7cY4O2DZ5P/LXcA==", + "license": "MIT", + "peerDependencies": { + "date-fns": ">=2.0.0" + } + }, + "node_modules/lit": { + "version": "2.2.1", + "resolved": "https://verdaccio.codeblob.work/lit/-/lit-2.2.1.tgz", + "integrity": "sha512-dSe++R50JqrvNGXmI9OE13de1z5U/Y3J2dTm/9GC86vedI8ILoR8ZGnxfThFpvQ9m0lR0qRnIR4IiKj/jDCfYw==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^1.3.0", + "lit-element": "^3.2.0", + "lit-html": "^2.2.0" + } + }, + "node_modules/lit-element": { + "version": "3.2.0", + "resolved": "https://verdaccio.codeblob.work/lit-element/-/lit-element-3.2.0.tgz", + "integrity": "sha512-HbE7yt2SnUtg5DCrWt028oaU4D5F4k/1cntAFHTkzY8ZIa8N0Wmu92PxSxucsQSOXlODFrICkQ5x/tEshKi13g==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^1.3.0", + "lit-html": "^2.2.0" + } + }, + "node_modules/lit-html": { + "version": "2.2.1", + "resolved": "https://verdaccio.codeblob.work/lit-html/-/lit-html-2.2.1.tgz", + "integrity": "sha512-AiJ/Rs0awjICs2FioTnHSh+Np5dhYSkyRczKy3wKjp8qjLhr1Ov+GiHrUQNdX8ou1LMuznpIME990AZsa/tR8g==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + } + }, + "dependencies": { + "@lit/reactive-element": { + "version": "1.3.1", + "resolved": "https://verdaccio.codeblob.work/@lit%2freactive-element/-/reactive-element-1.3.1.tgz", + "integrity": "sha512-nOJARIr3pReqK3hfFCSW2Zg/kFcFsSAlIE7z4a0C9D2dPrgD/YSn3ZP2ET/rxKB65SXyG7jJbkynBRm+tGlacw==" + }, + "@types/trusted-types": { + "version": "2.0.2", + "resolved": "https://verdaccio.codeblob.work/@types%2ftrusted-types/-/trusted-types-2.0.2.tgz", + "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==" + }, + "date-fns": { + "version": "2.28.0", + "resolved": "https://verdaccio.codeblob.work/date-fns/-/date-fns-2.28.0.tgz", + "integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==" + }, + "date-fns-tz": { + "version": "1.3.3", + "resolved": "https://verdaccio.codeblob.work/date-fns-tz/-/date-fns-tz-1.3.3.tgz", + "integrity": "sha512-Gks46gwbSauBQnV3Oofluj1wTm8J0tM7sbSJ9P+cJq/ZnTCpMohTKmmO5Tn+jQ7dyn0+b8G7cY4O2DZ5P/LXcA==", + "requires": {} + }, + "lit": { + "version": "2.2.1", + "resolved": "https://verdaccio.codeblob.work/lit/-/lit-2.2.1.tgz", + "integrity": "sha512-dSe++R50JqrvNGXmI9OE13de1z5U/Y3J2dTm/9GC86vedI8ILoR8ZGnxfThFpvQ9m0lR0qRnIR4IiKj/jDCfYw==", + "requires": { + "@lit/reactive-element": "^1.3.0", + "lit-element": "^3.2.0", + "lit-html": "^2.2.0" + } + }, + "lit-element": { + "version": "3.2.0", + "resolved": "https://verdaccio.codeblob.work/lit-element/-/lit-element-3.2.0.tgz", + "integrity": "sha512-HbE7yt2SnUtg5DCrWt028oaU4D5F4k/1cntAFHTkzY8ZIa8N0Wmu92PxSxucsQSOXlODFrICkQ5x/tEshKi13g==", + "requires": { + "@lit/reactive-element": "^1.3.0", + "lit-html": "^2.2.0" + } + }, + "lit-html": { + "version": "2.2.1", + "resolved": "https://verdaccio.codeblob.work/lit-html/-/lit-html-2.2.1.tgz", + "integrity": "sha512-AiJ/Rs0awjICs2FioTnHSh+Np5dhYSkyRczKy3wKjp8qjLhr1Ov+GiHrUQNdX8ou1LMuznpIME990AZsa/tR8g==", + "requires": { + "@types/trusted-types": "^2.0.2" + } + } + } +} diff --git a/package.json b/package.json index c39fdff..252c2d6 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,20 @@ { - "name": "@tp/tp-element", - "version": "0.0.1", + "name": "@tp/tp-date-input", + "version": "0.1.0", "description": "", - "main": "tp-element.js", + "main": "tp-date-input.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { "type": "git", - "url": "https://gitea.codeblob.work/tp-elements/tp-element.git" + "url": "https://gitea.codeblob.work/tp-elements/tp-date-input.git" }, "author": "trading_peter", "license": "Apache-2.0", "dependencies": { + "date-fns": "^2.28.0", + "date-fns-tz": "^1.3.3", "lit": "^2.2.0" } } diff --git a/tp-date-input.js b/tp-date-input.js new file mode 100644 index 0000000..fffc4eb --- /dev/null +++ b/tp-date-input.js @@ -0,0 +1,428 @@ +/** +@license +Copyright (c) 2022 trading_peter +This program is available under Apache License Version 2.0 +*/ + +import '@tp/tp-input/tp-input.js'; +import { ControlState } from '@tp/helpers/control-state.js'; +import { EventHelpers } from '@tp/helpers/event-helpers.js'; +import { FormElement } from '@tp/helpers/form-element.js'; +import { LitElement, html, css } from 'lit'; +import { format, parse, parseISO, isAfter, isValid, endOfDay } from 'date-fns/esm'; + +class TpDateInput extends EventHelpers(ControlState(FormElement(LitElement))) { + static get styles() { + return [ + css` + :host { + display: block; + } + + .wrap { + display: flex; + flex-direction: column; + } + + .wrap > div { + align-self: flex-end; + } + + tp-input { + width: 30px; + text-align: center; + border: none; + } + + tp-input.bigger { + width: 50px; + } + + .under { + position: relative; + } + + .error-message { + position: absolute; + top: -5px; + left: 0; + font-size: 10px; + color: var(--tp-input-text-color-invalid, #B71C1C); + transition: opacity 0.3s; + opacity: 0; + will-change: opacity; + } + + :host([invalid]) .error-message { + opacity: 1; + } + ` + ]; + } + + render() { + const { _delemiter } = this; + + return html` +
+ + + +
${_delemiter}
+ + + +
${_delemiter}
+ + + +
+ `; + } + + static get properties() { + return { + // Format of the date. + // Supports MM, dd and y. The order specifies how the input fields are labeled. + // The delimiter is also taken from the supplied format. + format: { type: String }, + + // Date object with the currently selected date. + date: { type: Object }, + + // If true, let value default to the date of today. + today: { type: Boolean }, + + required: { type: Boolean }, + + autoValidate: { type: Boolean }, + + // If true, the entered date is invalid. + invalid: { type: Boolean, reflect: true }, + + // Error message to show if the date is invalid. + errorMessage: { type: String }, + + optional: { type: Boolean }, + + // Range of years in the future that are selectable in the date-picker. + maxYear: { type: Number }, + + // Range of years in the past that are selectable in the date-picker. + minYear: { type: Number }, + + // Maximum date that can be selected. Everything after will be disabled. + // Set it to `today` to automatically allow dates till today (inclusive). + // Expects 'today' or a ISO Date string. + maxDate: { type: String }, + }; + } + + constructor() { + super(); + this.format = 'MM-dd-y'; + this.required = false; + this.autoValidate = false; + this.invalid = false; + this.optional = false; + this.maxYear = 10; + this.minYear = 10; + } + + shouldUpdate(changes) { + if (changes.has('format')) { + this._formatChanged(); + } + + if (changes.has('value')) { + this._onValueChanged(); + } + + return true; + } + + firstUpdated() { + this.listen(this, 'input', '_autoMoveCursor'); + } + + /** + * Returns current validation state of the control. + */ + validate() { + this.inputs[0].validate(); + this.inputs[1].validate(); + this.inputs[2].validate(); + + if (this.optional && this.inputs[0].value === '' && this.inputs[1].value === '' && this.inputs[2].value === '') { + this.invalid = false; + return true; + } + + const maxDate = this._getMaxDate(this.maxDate); + + if ((this.inputs[0].invalid || this.inputs[1].invalid || this.inputs[2].invalid) || + !this.dateValid(this.value, maxDate)) { + this.invalid = true; + return false; + } + + this.invalid = false; + return true; + } + + focus() { + this.inputs[0].select(); + } + + /** + * Test is a date is selectable when considering all restricting options + * of the control, like enabledDates, maxDate, ... + */ + dateValid(date, maxDate, enabledDates) { + date = this._toDate(date); + + if (isValid(date) === false) { + return false; + } + + maxDate = maxDate || this.maxDate; + enabledDates = enabledDates || []; + + if ((enabledDates.length > 0 && enabledDates.indexOf(date) === -1) || + (maxDate && isAfter(date, maxDate))) { + return false; + } + return true; + } + + /** + * Reset the control if a parent era-form is reset. + */ + reset() { + this._input0 = ''; + this._input1 = ''; + this._input2 = ''; + this.shadowRoot.querySelectorAll('era-input').forEach(el => el.invalid = false); + this.invalid = false; + this.value = null; + + if (this.today) { + this._setToday(); + } + } + + _inputChanged() { + const i0 = this._input0; + const i1 = this._input1; + const i2 = this._input2; + + if (i0 === '' && i1 === '' && i2 === '' && this.optional) { + this.date = null; + this.value = null; + this.invalid = false; + return; + } + + if ( + i0 === '' || i1 === '' || i2 === '' || + !this.inputs[0].validate() || !this.inputs[1].validate() || !this.inputs[2].validate() + ) { + if (this.focused) { + this.date = null; + this.value = null; + if (this.autoValidate) { + this.invalid = true; + } + } + return; + } + + const date = parse(i0 + '-' + i1 + '-' + i2, this._inputAssign.join('-'), new Date()); + + if (isValid(date)) { + this.inputs[0].invalid = false; + this.inputs[1].invalid = false; + this.inputs[2].invalid = false; + this.date = date; + this.value = date.toISOString(); + this.invalid = false; + } else { + this.inputs[0].invalid = true; + this.inputs[1].invalid = true; + this.inputs[2].invalid = true; + this.date = null; + this.value = null; + if (this.autoValidate) { + this.invalid = true; + } + } + } + + _setValidator(idx) { + switch (this._inputAssign[idx]) { + case 'dd': + return this._validateDay; + case 'MM': + return this._validateMonth; + case 'y': + return this._validateYear; + } + } + + _validateDay(value) { + if (typeof value !== 'string') { + return false; + } + + if (/^[0-9]+$/.test(value) !== true) { + return false; + } + + var v = parseInt(value, 10); + return v >= 1 && v <= 31; + } + + _validateMonth(value) { + if (typeof value !== 'string') { + return false; + } + + if (/^[0-9]+$/.test(value) !== true) { + return false; + } + + var v = parseInt(value, 10); + return v >= 1 && v <= 12; + } + + _validateYear(value) { + if (typeof value !== 'string') { + return false; + } + + if (/^[0-9]+$/.test(value) !== true) { + return false; + } + + var v = parseInt(value, 10); + return v >= 1900; + } + + _setPlaceholder(idx) { + switch (this._inputAssign[idx]) { + case 'dd': + return 'DD'; + case 'MM': + return 'MM'; + case 'y': + return 'YYYYY'; + } + } + + _formatChanged(format) { + if (!format) return; + + let _realFormat = format; + + const types = ['MM', 'dd', 'y']; + this._inputAssign = []; + + this._delimiter = this._determineDelimiter(_realFormat); + + const parts = _realFormat.split(this._delimiter); + if (parts.length < 2) { + console.warn(this.tagName + ': Unknown format. Fallback to format MM-dd-y'); + this.format = 'MM-dd-y'; + return; + } + + for (let i = 0; i <= 2; ++i) { + for (let a = 0, la = types.length; a < la; ++a) { + if (types[a] === parts[i] || types[a] === parts[i].toLowerCase()) { + parts[i] = parts[i] !== 'MM' ? parts[i].toLowerCase() : parts[i]; + this._inputAssign.push(parts[i]); + } + } + } + + if (this.value) { + this._onValueChanged(); + } + } + + _determineDelimiter(format) { + if (format.indexOf('-') > -1) { + return '-'; + } + if (format.indexOf('/') > -1) { + return '/'; + } + if (format.indexOf('.') > -1) { + return '.'; + } + return '/'; + } + + // Reset invalid state if value was changed. + // This clears up old invalid states if the value was changed programmatically. + _onValueChanged() { + // If the control is focused we ignore a programmatically set value because + // the user may works with the element right now. + if (this.focused) { + return; + } + + this.invalid = false; + + if (this.value === null || this.value === undefined || this.value === 'Invalid date') { + this.date = null; + this.value = null; + this._input0 = ''; + this._input1 = ''; + this._input2 = ''; + return; + } + + const date = this._toDate(this.value); + this._input0 = format(date, this._inputAssign[0]); + this._input1 = format(date, this._inputAssign[1]); + this._input2 = format(date, this._inputAssign[2]); + this.date = date; + } + + _setToday() { + if (this.today) { + setTimeout(() => { + if (!this.value) { + this.value = new Date().toISOString(); + } + }); + } + } + + _getMaxDate(maxDate) { + if (typeof maxDate !== 'string') return null; + if (maxDate.toLowerCase() === 'today') { + return endOfDay(new Date()); + } + return this._toDate(maxDate); + } + + _autoMoveCursor(e) { + const target = e.composedPath().find(node => node.tagName === 'ERA-INPUT'); + const idx = Array.from(this.inputs).findIndex(node => node === target); + if (target.value.length === 2 && idx < 2) { + this.inputs[idx + 1].select(); + } + } + + _toDate(value) { + if (typeof value === 'string') { + return parseISO(value); + } else { + return value; + } + } +} + +window.customElements.define('tp-date-input', TpDateInput); diff --git a/tp-element.js b/tp-element.js deleted file mode 100644 index 6a92a2f..0000000 --- a/tp-element.js +++ /dev/null @@ -1,35 +0,0 @@ -/** -@license -Copyright (c) 2022 trading_peter -This program is available under Apache License Version 2.0 -*/ - -import { LitElement, html, css } from 'lit'; - -class TpElement extends LitElement { - static get styles() { - return [ - css` - :host { - display: block; - } - ` - ]; - } - - render() { - const { } = this; - - return html` - - `; - } - - static get properties() { - return { }; - } - - -} - -window.customElements.define('tp-element', TpElement);