/** @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);