First usable version
This commit is contained in:
341
tp-time-selector.js
Normal file
341
tp-time-selector.js
Normal file
@@ -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`
|
||||
<div class="columns-container" part="columns-container">
|
||||
<!-- Hours column -->
|
||||
<div
|
||||
class="column hours"
|
||||
part="hours-column number-column"
|
||||
id="hoursColumn">
|
||||
${this._generateHours(hourFormat).map(hour => html`
|
||||
<div
|
||||
class="item ${this.selectedHour === hour ? 'selected' : ''}"
|
||||
part="hour-item ${this.selectedHour === hour ? 'hour-item-selected' : ''}"
|
||||
data-value="${hour}"
|
||||
@click=${() => this._selectHour(hour)}>
|
||||
${hour.toString().padStart(2, '0')}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<!-- Minutes column -->
|
||||
<div
|
||||
class="column minutes"
|
||||
part="minutes-column number-column"
|
||||
id="minutesColumn">
|
||||
${this._generateMinutes().map(minute => html`
|
||||
<div
|
||||
class="item ${this.selectedMinute === minute ? 'selected' : ''}"
|
||||
part="minute-item ${this.selectedMinute === minute ? 'minute-item-selected' : ''}"
|
||||
data-value="${minute}"
|
||||
@click=${() => this._selectMinute(minute)}>
|
||||
${minute.toString().padStart(2, '0')}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<!-- Period column (AM/PM) -->
|
||||
${this.useAmPm ? html`
|
||||
<div
|
||||
class="column period"
|
||||
part="period-column"
|
||||
id="periodColumn">
|
||||
<div
|
||||
class="item ${this.selectedPeriod === 'AM' ? 'selected' : ''}"
|
||||
part="period-item ${this.selectedPeriod === 'AM' ? 'period-item-selected' : ''}"
|
||||
data-value="AM"
|
||||
@click=${() => this._selectPeriod('AM')}>
|
||||
AM
|
||||
</div>
|
||||
<div
|
||||
class="item ${this.selectedPeriod === 'PM' ? 'selected' : ''}"
|
||||
part="period-item ${this.selectedPeriod === 'PM' ? 'period-item-selected' : ''}"
|
||||
data-value="PM"
|
||||
@click=${() => this._selectPeriod('PM')}>
|
||||
PM
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user