diff --git a/README.md b/README.md index 1ab27b7..fd13f97 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# tp-element +# tp-time-input diff --git a/package.json b/package.json index 24f0225..1d1c945 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { - "name": "@tp/tp-element", - "version": "0.0.1", + "name": "@tp/tp-time-input", + "version": "1.0.0", "description": "", - "main": "tp-element.js", + "main": "tp-time-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-time-input.git" }, "author": "trading_peter", "license": "Apache-2.0", diff --git a/tp-element.js b/tp-element.js deleted file mode 100644 index 6195006..0000000 --- a/tp-element.js +++ /dev/null @@ -1,35 +0,0 @@ -/** -@license -Copyright (c) 2024 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); diff --git a/tp-time-input.js b/tp-time-input.js new file mode 100644 index 0000000..461c040 --- /dev/null +++ b/tp-time-input.js @@ -0,0 +1,624 @@ +/** +@license +Copyright (c) 2025 trading_peter +This program is available under Apache License Version 2.0 +*/ + +import { LitElement, html, css } from 'lit'; +import { FormElement } from '@tp/helpers/form-element.js'; + +class TpTimeInput extends FormElement(LitElement) { + static get styles() { + return [ + css` + :host { + display: inline-flex; + position: relative; + align-items: center; + font-family: inherit; + font-size: 14px; + border: solid 1px #000; + overflow: hidden; + background: transparent; + box-sizing: border-box; + } + + :host(:focus-within) { + border-color: var(--tp-time-input-focus-color, #007bff); + } + + .wrapper { + display: flex; + align-items: center; + width: 100%; + } + + .time-container { + display: flex; + align-items: center; + flex: 1; + padding: 4px 5px; + } + + input { + width: 2.5em; + text-align: center; + padding: 4px 0; + -moz-appearance: textfield; /* Firefox */ + outline: none; + box-shadow: none; + padding: 0; + min-width: 0; /** Because of FF **/ + background: transparent; + border: none; + font-family: inherit; + font-size: inherit; + color: inherit; /** FF seems to need this **/ + } + + /* Hide number input arrows for Chrome, Safari, Edge, Opera */ + input::-webkit-outer-spin-button, + input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + span.separator { + font-size: inherit; + padding: 0; + user-select: none; + } + + .period-toggle { + border-left: 1px solid #ccc; + font-size: inherit; + cursor: pointer; + user-select: none; + text-align: center; + min-width: 2.5em; + padding: 5px; + outline: none; + } + + :host([disabled]) .period-toggle { + cursor: not-allowed; + background: transparent; + } + + .suffix { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + } + + .suffix ::slotted([slot="suffix"]) { + margin-left: 5px; + white-space: nowrap; + } + + input:focus { + outline: none; + } + + :host([disabled]) { + background: #f5f5f5; + cursor: not-allowed; + } + + input:disabled { + background: transparent; + cursor: not-allowed; + } + + input:invalid { + color: #ff0000; + } + + :host([invalid]) { + border-color: var(--tp-time-input-border-color-invalid, #ff0000); + } + + .error-message { + position: absolute; + z-index: 1; + left: 0; + right: 0; + bottom: -18px; + font-size: 10px; + color: var(--tp-time-input-text-color-invalid, #B71C1C); + transition: opacity 0.3s; + opacity: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; + } + + :host([invalid]) .error-message { + opacity: 1; + } + ` + ]; + } + + render() { + const { selPeriod } = this; + + return html` +
+
+ + : + +
+ ${this.useAmPm ? html` +
+ ${selPeriod || 'AM'} +
+ ` : ''} +
+ +
+
+ ${this.errorMessage ? html` +
${this.errorMessage}
+ ` : ''} + `; + } + + static get properties() { + return { + value: { type: String, reflect: true }, + required: { type: Boolean, reflect: true }, + disabled: { type: Boolean, reflect: true }, + name: { type: String, reflect: true }, + useAmPm: { type: Boolean, reflect: true }, + invalid: { type: Boolean, reflect: true }, + errorMessage: { type: String }, + autoValidate: { type: Boolean }, + validator: { type: Object }, + forceInvalid: { type: Boolean }, + optional: { type: Boolean }, + selPeriod: { type: String }, + }; + } + + constructor() { + super(); + this.value = ''; + this.required = false; + this.disabled = false; + this.name = ''; + this.useAmPm = false; + this.invalid = false; + this.errorMessage = ''; + this.autoValidate = false; + this.forceInvalid = false; + this.optional = false; + this.selPeriod = 'AM'; + } + + get hours() { + if (!this.value) return ''; + const hours = parseInt(this.value.split(':')[0]); + if (this.useAmPm) { + return hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; + } + return hours; + } + + get minutes() { + return this.value ? parseInt(this.value.split(':')[1]) : ''; + } + + get period() { + if (!this.value || !this.useAmPm) return ''; + const hours = parseInt(this.value.split(':')[0]); + return hours >= 12 ? 'PM' : 'AM'; + } + + firstUpdated() { + super.firstUpdated(); + // Initialize validation + if (this.autoValidate) { + this.validate(); + } + } + + updated(changedProperties) { + if (changedProperties.has('forceInvalid')) { + this._forceInvalidChanged(); + } + + if (changedProperties.has('value')) { + // When value changes, update selPeriod to match + if (this.useAmPm && this.value) { + this.selPeriod = this.period; + } + + if (this.autoValidate) { + this.validate(); + } + } + } + + _formatHours() { + if (this.hours === '') { + return ''; + } + return this.hours.toString().padStart(2, '0'); + } + + _formatMinutes() { + if (this.minutes === '') { + return ''; + } + return this.minutes.toString().padStart(2, '0'); + } + + _handleFocus(e) { + const input = e.target; + + // Select all text when focused, even if empty + setTimeout(() => { + if (input && typeof input.select === 'function') { + input.select(); + } + }, 0); + } + + _handleBlur(e) { + const input = e.target; + + // Only add padding when the input loses focus and has a value + if (input.value !== '') { + const value = parseInt(input.value); + if (!isNaN(value)) { + input.value = value.toString().padStart(2, '0'); + this._updateValue(); + } + } + } + + _handleKeyDown(e) { + const input = e.target; + + // Submit form if Enter key is pressed + if (e.key === 'Enter' && this.parentForm) { + this.parentForm.submit(); + e.preventDefault(); + return; + } + + // Arrow key navigation between hours and minutes + if (e.key === 'ArrowRight' && input.id === 'hours' && input.selectionStart === input.value.length) { + this.shadowRoot.querySelector('#minutes').focus(); + e.preventDefault(); + } else if (e.key === 'ArrowLeft' && input.id === 'minutes' && input.selectionStart === 0) { + this.shadowRoot.querySelector('#hours').focus(); + e.preventDefault(); + } else if (e.key === ':') { + // When typing colon, move to minutes + if (input.id === 'hours') { + this.shadowRoot.querySelector('#minutes').focus(); + e.preventDefault(); + } + } else if (e.key === 'ArrowRight' && input.id === 'minutes' && input.selectionStart === input.value.length && this.useAmPm) { + // Move to period toggle when at end of minutes + this.shadowRoot.querySelector('#period').focus(); + e.preventDefault(); + } + } + + _handlePeriodKeyDown(e) { + // Toggle period with space or enter + if (e.key === ' ' || e.key === 'Enter') { + this._togglePeriod(); + e.preventDefault(); + } else if (e.key === 'ArrowLeft') { + // Move back to minutes field + this.shadowRoot.querySelector('#minutes').focus(); + e.preventDefault(); + } + } + + _handleHoursInput(e) { + const input = e.target; + let value = input.value.replace(/\D/g, ''); + + // Don't limit length or auto-advance here, allow full input + if (value.length > 0) { + // For 24-hour format + if (!this.useAmPm) { + // Only auto-advance after 2 digits or if first digit > 2 + if ((value.length === 2) || (value.length === 1 && parseInt(value) > 2)) { + // Before auto-advancing, check if the value is valid + const hours = parseInt(value); + if (hours > 23) { + input.value = '23'; + } else { + input.value = value; + } + this._updateValue(); + this.shadowRoot.querySelector('#minutes').focus(); + return; + } + } else { + // For 12-hour format + // Only auto-advance after 2 digits or if first digit > 1 + if ((value.length === 2) || (value.length === 1 && parseInt(value) > 1)) { + // Before auto-advancing, check if the value is valid + const hours = parseInt(value); + if (hours > 12) { + input.value = '12'; + } else if (hours < 1) { + input.value = '01'; + } else { + input.value = value.padStart(2, '0'); + } + this._updateValue(); + this.shadowRoot.querySelector('#minutes').focus(); + return; + } + } + } + + // Don't pad while user is typing - just set the raw value + input.value = value; + + // Only update the internal value if we have a complete value + if (value.length === 2) { + this._updateValue(); + } + } + + _handleMinutesInput(e) { + const input = e.target; + let value = input.value.replace(/\D/g, ''); + + // Don't limit length or auto-advance here, allow full input + if (value.length > 0) { + // Only auto-advance after 2 digits or if first digit > 5 + if ((value.length === 2) || (value.length === 1 && parseInt(value) > 5)) { + // Before auto-advancing, check if the value is valid + const minutes = parseInt(value); + if (minutes > 59) { + input.value = '59'; + } else { + input.value = value.padStart(2, '0'); + } + this._updateValue(); + if (this.useAmPm) { + this.shadowRoot.querySelector('#period').focus(); + } + return; + } + } + + // Don't pad while user is typing - just set the raw value + input.value = value; + + // Only update the internal value if we have a complete value + if (value.length === 2) { + this._updateValue(); + } + } + + _togglePeriod() { + if (this.disabled) return; + + // Toggle between AM and PM + const currentPeriod = this.selPeriod || 'AM'; + this.selPeriod = currentPeriod === 'AM' ? 'PM' : 'AM'; + + // Update the value with the new period + this._updateValue(this.selPeriod); + } + + _updateValue(forcedPeriod = null) { + const hoursInput = this.shadowRoot.querySelector('#hours'); + const minutesInput = this.shadowRoot.querySelector('#minutes'); + + if (!hoursInput || !minutesInput) return; + + const hoursValue = hoursInput.value; + const minutesValue = minutesInput.value; + + // Only create a value if we have both hours and minutes + if (!hoursValue || !minutesValue) { + this.value = ''; + + if (this.autoValidate) { + this.validate(); + } + + return; + } + + let hours = parseInt(hoursValue) || 0; + let minutes = parseInt(minutesValue) || 0; + + // Validate values + if (this.useAmPm) { + if (hours < 1) hours = 1; + if (hours > 12) hours = 12; + } else { + if (hours < 0) hours = 0; + if (hours > 23) hours = 23; + } + + if (minutes < 0) minutes = 0; + if (minutes > 59) minutes = 59; + + // Always format with padding + const formattedHours = hours.toString().padStart(2, '0'); + const formattedMinutes = minutes.toString().padStart(2, '0'); + + // Update the input values to show the padded format + hoursInput.value = formattedHours; + minutesInput.value = formattedMinutes; + + let newValue = ''; + let hours24 = hours; + + if (this.useAmPm) { + // Use the forced period if provided (for toggle), otherwise use current + const period = forcedPeriod || this.period || 'AM'; + hours24 = this._convertTo24Hour(hours, period); + newValue = `${hours24.toString().padStart(2, '0')}:${formattedMinutes}`; + } else { + newValue = `${formattedHours}:${formattedMinutes}`; + } + + if (newValue !== this.value) { + this.value = newValue; + + // Create a more detailed event with numeric hour and minute values + this.dispatchEvent(new CustomEvent('change', { + detail: { + value: newValue, + hours: hours24, // 24-hour format hour (numeric) + minutes: minutes, // minutes (numeric) + displayHours: hours, // Display format hour (12 or 24 hour depending on useAmPm) + period: this.useAmPm ? (forcedPeriod || this.period || 'AM') : null + }, + bubbles: true, + composed: true + })); + + if (this.autoValidate) { + this.validate(); + } + } + } + + _convertTo24Hour(hours, period) { + if (hours === 12) { + return period === 'AM' ? 0 : 12; + } + return period === 'PM' ? hours + 12 : hours; + } + + _forceInvalidChanged() { + if (this.forceInvalid) { + this.invalid = true; + } else if (this.autoValidate) { + this.validate(); + } + } + + /** + * Validate the time input + * @returns {boolean} True if valid, false if invalid + */ + validate() { + if (this.forceInvalid) { + this.invalid = true; + return false; + } + + // Check if the value is empty + const isEmpty = !this.value; + + // If optional and empty, it's valid + if (this.optional && isEmpty) { + this.invalid = false; + return true; + } + + // If required and empty, it's invalid + if (this.required && isEmpty) { + this.invalid = true; + return false; + } + + // If we have a value, validate it (must have hours and minutes) + let valid = !isEmpty && this.hours !== '' && this.minutes !== ''; + + // Call the custom validator if provided + if (valid && typeof this.validator === 'function') { + valid = this.validator(this, this.value); + } + + this.invalid = !valid; + return valid; + } + + /** + * Reset the component to its initial state + */ + reset() { + const hoursInput = this.shadowRoot.querySelector('#hours'); + const minutesInput = this.shadowRoot.querySelector('#minutes'); + const periodEl = this.shadowRoot.querySelector('#period'); + + if (hoursInput) hoursInput.value = ''; + if (minutesInput) minutesInput.value = ''; + if (periodEl) periodEl.textContent = 'AM'; + + this.value = ''; + this.invalid = false; + } + + /** + * Focus the hours input + */ + focus() { + const hoursInput = this.shadowRoot.querySelector('#hours'); + if (hoursInput) { + hoursInput.focus(); + } + } + + /** + * Blur the currently focused input + */ + blur() { + const activeElement = this.shadowRoot.activeElement; + if (activeElement) { + activeElement.blur(); + } + } +} + +customElements.define('tp-time-input', TpTimeInput); \ No newline at end of file diff --git a/tp-time-selector.js b/tp-time-selector.js new file mode 100644 index 0000000..180cde5 --- /dev/null +++ b/tp-time-selector.js @@ -0,0 +1,341 @@ +/** +@license +Copyright (c) 2025 trading_peter +This program is available under Apache License Version 2.0 +*/ + +import { LitElement, html, css } from 'lit'; +import { FormElement } from '@tp/helpers/form-element.js'; + +class TpTimeSelector extends FormElement(LitElement) { + static get styles() { + return [ + css` + :host { + display: block; + position: relative; + width: 100%; + font-family: inherit; + font-size: inherit; + box-sizing: border-box; + overflow: hidden; + user-select: none; + } + + .columns-container { + display: flex; + width: 100%; + height: 100%; + align-items: stretch; + } + + .column { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + position: relative; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ + border-right: 1px solid var(--tp-time-selector-column-border-color, #e0e0e0); + } + + .column:last-child { + border-right: none; + } + + /* Hide scrollbar for Chrome, Safari and Opera */ + .column::-webkit-scrollbar { + display: none; + } + + .item { + padding: 8px; + text-align: center; + cursor: pointer; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + height: var(--tp-time-selector-item-height, 36px); + } + + .item:hover { + background-color: var(--tp-time-selector-item-hover-bg, #f5f5f5); + } + + .item.selected { + background-color: var(--tp-time-selector-item-selected-bg, #e6f7ff); + color: var(--tp-time-selector-item-selected-color, #007bff); + } + + :host([disabled]) { + opacity: 0.6; + pointer-events: none; + } + ` + ]; + } + + render() { + const hourFormat = this.useAmPm ? 12 : 24; + + return html` +
+ +
+ ${this._generateHours(hourFormat).map(hour => html` +
this._selectHour(hour)}> + ${hour.toString().padStart(2, '0')} +
+ `)} +
+ + +
+ ${this._generateMinutes().map(minute => html` +
this._selectMinute(minute)}> + ${minute.toString().padStart(2, '0')} +
+ `)} +
+ + + ${this.useAmPm ? html` +
+
this._selectPeriod('AM')}> + AM +
+
this._selectPeriod('PM')}> + PM +
+
+ ` : ''} +
+ `; + } + + static get properties() { + return { + value: { type: String, reflect: true }, + minuteStep: { type: Number }, + useAmPm: { type: Boolean }, + selectedHour: { type: Number, state: true }, + selectedMinute: { type: Number, state: true }, + selectedPeriod: { type: String, state: true }, + disabled: { type: Boolean, reflect: true } + }; + } + + constructor() { + super(); + this.value = ''; + this.minuteStep = 1; + this.useAmPm = false; + this.selectedHour = 0; + this.selectedMinute = 0; + this.selectedPeriod = 'AM'; + this.disabled = false; + } + + firstUpdated() { + // Ensure initial scroll positions after the component is in the DOM + this.updateComplete.then(() => { + this._scrollToSelectedItems(); + }); + } + + updated(changedProperties) { + if (changedProperties.has('value')) { + this._parseValue(); + if (this.isConnected) { + this.updateComplete.then(() => { + this._scrollToSelectedItems(); + }); + } + } + } + + _parseValue() { + if (!this.value) { + this.selectedHour = 0; + this.selectedMinute = 0; + this.selectedPeriod = 'AM'; + return; + } + + const [hours, minutes] = this.value.split(':'); + const hoursInt = parseInt(hours, 10); + + if (this.useAmPm) { + // Convert 24h to 12h format + if (hoursInt === 0) { + this.selectedHour = 12; + this.selectedPeriod = 'AM'; + } else if (hoursInt === 12) { + this.selectedHour = 12; + this.selectedPeriod = 'PM'; + } else if (hoursInt > 12) { + this.selectedHour = hoursInt - 12; + this.selectedPeriod = 'PM'; + } else { + this.selectedHour = hoursInt; + this.selectedPeriod = 'AM'; + } + } else { + this.selectedHour = hoursInt; + } + + this.selectedMinute = parseInt(minutes, 10); + // Round to nearest step + this.selectedMinute = Math.round(this.selectedMinute / this.minuteStep) * this.minuteStep; + } + + _generateHours(format) { + const hours = []; + const start = format === 12 ? 1 : 0; + const end = format === 12 ? 12 : 23; + + for (let i = start; i <= end; i++) { + hours.push(i); + } + + return hours; + } + + _generateMinutes() { + const minutes = []; + for (let i = 0; i < 60; i += this.minuteStep) { + minutes.push(i); + } + return minutes; + } + + _selectHour(hour) { + this.selectedHour = hour; + this._updateValue(); + } + + _selectMinute(minute) { + this.selectedMinute = minute; + this._updateValue(); + } + + _selectPeriod(period) { + this.selectedPeriod = period; + this._updateValue(); + } + + _updateValue() { + let hours = this.selectedHour; + + // Convert to 24-hour format if using AM/PM + if (this.useAmPm) { + if (hours === 12) { + hours = this.selectedPeriod === 'AM' ? 0 : 12; + } else if (this.selectedPeriod === 'PM') { + hours += 12; + } + } + + const formattedHours = hours.toString().padStart(2, '0'); + const formattedMinutes = this.selectedMinute.toString().padStart(2, '0'); + + const newValue = `${formattedHours}:${formattedMinutes}`; + + if (this.value !== newValue) { + this.value = newValue; + + this.dispatchEvent(new CustomEvent('change', { + detail: { + value: newValue, + hours: hours, + minutes: this.selectedMinute, + displayHours: this.selectedHour, + period: this.useAmPm ? this.selectedPeriod : null + }, + bubbles: true, + composed: true + })); + } + } + + _scrollToSelectedItems() { + // Get references to each column + const hoursColumn = this.shadowRoot.querySelector('#hoursColumn'); + const minutesColumn = this.shadowRoot.querySelector('#minutesColumn'); + const periodColumn = this.useAmPm ? this.shadowRoot.querySelector('#periodColumn') : null; + + if (!hoursColumn || !minutesColumn) return; + + // Find the selected items + const hourItem = hoursColumn.querySelector('.item.selected'); + const minuteItem = minutesColumn.querySelector('.item.selected'); + + // Scroll to selected items if they exist + if (hourItem) { + hourItem.scrollIntoView({ block: 'center' }); + } + + if (minuteItem) { + minuteItem.scrollIntoView({ block: 'center' }); + } + + if (this.useAmPm && periodColumn) { + const periodItem = periodColumn.querySelector('.item.selected'); + if (periodItem) { + periodItem.scrollIntoView({ block: 'center' }); + } + } + } + + /** + * Implement the validate method required by FormElement + */ + validate() { + const isEmpty = !this.value; + + // If it has a value, it's valid (since we control the format) + // If it's required, it must have a value + const valid = !this.required || !isEmpty; + + this.invalid = !valid; + return valid; + } + + /** + * Reset to initial state + */ + reset() { + this.value = ''; + this.selectedHour = 0; + this.selectedMinute = 0; + this.selectedPeriod = 'AM'; + } +} + +customElements.define('tp-time-selector', TpTimeSelector); \ No newline at end of file