413 lines
10 KiB
JavaScript
413 lines
10 KiB
JavaScript
/**
|
|
@license
|
|
Copyright (c) 2022 trading_peter
|
|
This program is available under Apache License Version 2.0
|
|
*/
|
|
|
|
import '@tp/tp-icon/tp-icon.js';
|
|
import { FormElement } from '@tp/helpers/form-element';
|
|
import { LitElement, html, css, svg } from 'lit';
|
|
import { DateTime } from 'luxon';
|
|
|
|
class TpDatePicker extends FormElement(LitElement) {
|
|
static get styles() {
|
|
return [
|
|
css`
|
|
:host {
|
|
display: block;
|
|
}
|
|
|
|
.header {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
align-items: center;
|
|
}
|
|
|
|
.header > div {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-around;
|
|
padding: 5px 10px;
|
|
}
|
|
|
|
.header > div > div {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.right {
|
|
display: flex;
|
|
justify-content: end;
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, 1fr);
|
|
grid-template-rows: repeat(7, 1fr);
|
|
}
|
|
|
|
.grid > div {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 42px;
|
|
height: 42px;
|
|
padding: 2px;
|
|
}
|
|
|
|
.day {
|
|
font-weight: bold;
|
|
}
|
|
|
|
div[part~="filler"] {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.year-overlay {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
grid-row-gap: 5px;
|
|
position: absolute;
|
|
inset: 0;
|
|
overflow: auto;
|
|
background: white;
|
|
opacity: 0;
|
|
transition: 300ms opacity ease-in-out;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.year-overlay[visible] {
|
|
opacity: 1;
|
|
pointer-events: all;
|
|
}
|
|
|
|
.year-overlay > div {
|
|
text-align: center;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.year-overlay > div:hover {
|
|
background: var(--date-picker-hover-color);
|
|
}
|
|
|
|
.number {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 40px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.number:hover,
|
|
.number[part~="selected"] {
|
|
background: var(--tp-datepicker-day-hover-bg, #fff);
|
|
color: var(--tp-datepicker-day-hover-color, #000);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.number[part~="today"] {
|
|
background: var(--tp-datepicker-today-bg, #fff);
|
|
color: var(--tp-datepicker-today-color, #000);
|
|
}
|
|
|
|
.pointer {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.event-dots {
|
|
display: flex;
|
|
gap: 4px;
|
|
justify-content: center;
|
|
position: absolute;
|
|
bottom: 8px;
|
|
left: 0;
|
|
right: 0;
|
|
}
|
|
|
|
.event-dot {
|
|
width: 4px;
|
|
height: 4px;
|
|
border-radius: 50%;
|
|
background-color: var(--tp-datepicker-event-dot-color, #666);
|
|
}
|
|
|
|
div[part~="date"] {
|
|
position: relative;
|
|
}
|
|
`
|
|
];
|
|
}
|
|
|
|
render() {
|
|
let { month, year, dayNames, value, yearsForward, yearsBackwards } = this;
|
|
|
|
const today = DateTime.utc();
|
|
|
|
if ((!month && month !== 0) || (!year && year !== 0)) {
|
|
month = today.month - 1; // Luxon uses 1-based months, convert to 0-based for compatibility
|
|
year = today.year;
|
|
}
|
|
|
|
const years = Array(yearsForward + yearsBackwards)
|
|
.fill()
|
|
.map((_, i) => today.year - yearsBackwards + i)
|
|
.reverse();
|
|
const curMonth = this.getMonthDates(month, year);
|
|
|
|
return html`
|
|
<div class="header">
|
|
<div>
|
|
<div>
|
|
<tp-icon part="nav-icon" .icon=${TpDatePicker.iconPrev} @click=${() => this.previousMonth()}></tp-icon>
|
|
</div>
|
|
<div>${this.monthNames[month]}</div>
|
|
<div class="right">
|
|
<tp-icon part="nav-icon" .icon=${TpDatePicker.iconNext} @click=${() => this.nextMonth()}></tp-icon>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div>
|
|
<tp-icon part="nav-icon" .icon=${TpDatePicker.iconPrev} @click=${() => this.previousYear()}></tp-icon>
|
|
</div>
|
|
<div class="pointer" @click=${this.showYearOverlay}>${year}</div>
|
|
<div class="right">
|
|
<tp-icon part="nav-icon" .icon=${TpDatePicker.iconNext} @click=${() => this.nextYear()}></tp-icon>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="grid" @click=${e => this.selectDate(e)}>
|
|
${dayNames.map(label => html`
|
|
<div class="day" part="day">${label}</div>
|
|
`)}
|
|
${curMonth.map(d => {
|
|
const matchesValue = this.equalDate(d, value);
|
|
const isToday = this.equalDate(d, today);
|
|
const eventCount = this.countEventsForDate(d);
|
|
|
|
return html`
|
|
<div .date=${d} part="date ${d.month - 1 !== month ? 'filler' : 'of-month'}">
|
|
<div class="number" part="number ${matchesValue ? 'selected' : ''} ${isToday ? 'today' : ''} ${d.month - 1 === month ? 'of-month' : ''}">
|
|
${d.day}
|
|
${eventCount > 0 ? html`
|
|
<div class="event-dots">
|
|
<div class="event-dot" part="event-dot"></div>
|
|
${eventCount > 1 ? html`<div class="event-dot" part="event-dot"></div>` : ''}
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
`
|
|
})}
|
|
</div>
|
|
<div class="year-overlay" part="year-overlay" ?visible=${this.showYearSelector} @click=${this.selectYear}>
|
|
${years.map(y => html`
|
|
<div .value=${y}>${y}</div>
|
|
`)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
static get properties() {
|
|
return {
|
|
value: { type: Object }, // Changed from Date to Luxon DateTime
|
|
today: { type: Object }, // Changed from Date to Luxon DateTime
|
|
month: { type: Number },
|
|
year: { type: Number },
|
|
monthNames: { type: Array },
|
|
dayNames: { type: Array },
|
|
yearsForward: { type: Number },
|
|
yearsBackwards: { type: Number },
|
|
showYearSelector: { type: Boolean },
|
|
events: { type: Array },
|
|
};
|
|
}
|
|
|
|
static get iconNext() {
|
|
return svg`
|
|
<path fill="var(--tp-icon-color)" d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
|
`
|
|
}
|
|
|
|
static get iconPrev() {
|
|
return svg`
|
|
<path fill="var(--tp-icon-color)" d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
|
|
`
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
|
|
this.today = DateTime.utc();
|
|
this.month = this.today.month - 1; // Convert 1-based to 0-based month
|
|
this.year = this.today.year;
|
|
this.yearsForward = 10;
|
|
this.yearsBackwards = 100;
|
|
this.events = [];
|
|
|
|
this.dayNames = [
|
|
'Mo',
|
|
'Tue',
|
|
'Wed',
|
|
'Th',
|
|
'Fr',
|
|
'Sat',
|
|
'Sun',
|
|
];
|
|
|
|
this.monthNames = [
|
|
'January',
|
|
'February',
|
|
'March',
|
|
'April',
|
|
'May',
|
|
'June',
|
|
'July',
|
|
'August',
|
|
'September',
|
|
'October',
|
|
'November',
|
|
'December'
|
|
]
|
|
}
|
|
|
|
shouldUpdate(changes) {
|
|
if (changes.has('value') && this.value) {
|
|
this.month = this.value.month - 1; // Convert 1-based to 0-based month
|
|
this.year = this.value.year;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
equalDate(d1, d2) {
|
|
if (!d1 || !d2) return false;
|
|
return d1.day === d2.day && d1.year === d2.year && d1.month === d2.month;
|
|
}
|
|
|
|
selectDate(e) {
|
|
for (const el of e.composedPath()) {
|
|
if (el.date !== undefined) {
|
|
this.value = el.date;
|
|
this.dispatchEvent(new CustomEvent('value-changed', { detail: this.value, bubbles: true, composed: true }));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
previousMonth() {
|
|
let prevYear = this.year;
|
|
let prevMonth = this.month - 1;
|
|
|
|
if (prevMonth < 0) {
|
|
prevYear -= 1;
|
|
prevMonth = 11;
|
|
}
|
|
|
|
this.month = prevMonth;
|
|
this.year = prevYear;
|
|
this.dispatchRangeChangeEvent();
|
|
}
|
|
|
|
nextMonth() {
|
|
let nextYear = this.year;
|
|
let nextMonth = this.month + 1;
|
|
|
|
if (nextMonth > 11) {
|
|
nextYear += 1;
|
|
nextMonth = 0;
|
|
}
|
|
|
|
this.month = nextMonth;
|
|
this.year = nextYear;
|
|
this.dispatchRangeChangeEvent();
|
|
}
|
|
|
|
previousYear() {
|
|
this.year -= 1;
|
|
this.dispatchRangeChangeEvent();
|
|
}
|
|
|
|
nextYear() {
|
|
this.year += 1;
|
|
this.dispatchRangeChangeEvent();
|
|
}
|
|
|
|
getMonthDates(month = null, year = null) {
|
|
month = month !== null ? month : DateTime.utc().month - 1; // Convert 1-based to 0-based
|
|
year = year || DateTime.utc().year;
|
|
|
|
// Create DateTime for the first of the month (convert 0-based month back to 1-based)
|
|
const firstOfMonth = DateTime.utc(year, month + 1, 1, 0, 0, 0);
|
|
|
|
// Create DateTime for the last of the month
|
|
const lastOfMonth = firstOfMonth.endOf('month').startOf('day');
|
|
|
|
const dates = [];
|
|
|
|
// Calculate dates before the first of month to fill the calendar
|
|
let firstDayOfCalendar = firstOfMonth;
|
|
while (firstDayOfCalendar.weekday > 1) { // 1 is Monday in Luxon
|
|
firstDayOfCalendar = firstDayOfCalendar.minus({ days: 1 });
|
|
dates.push(firstDayOfCalendar);
|
|
}
|
|
|
|
dates.reverse();
|
|
|
|
// Add all days of the current month
|
|
for (let i = 1; i <= lastOfMonth.day; i++) {
|
|
dates.push(DateTime.utc(year, month + 1, i, 0, 0, 0));
|
|
}
|
|
|
|
// Make sure we always fill 42 dates to guarantee equal size of the date picker
|
|
let nextDate = lastOfMonth;
|
|
while (dates.length < 42) {
|
|
nextDate = nextDate.plus({ days: 1 });
|
|
dates.push(nextDate);
|
|
}
|
|
|
|
return dates;
|
|
}
|
|
|
|
showYearOverlay() {
|
|
this.showYearSelector = true;
|
|
}
|
|
|
|
selectYear(e) {
|
|
const elList = e.composedPath();
|
|
for (const el of elList) {
|
|
if (el.value) {
|
|
this.year = el.value;
|
|
this.showYearSelector = false;
|
|
this.dispatchRangeChangeEvent();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
countEventsForDate(date) {
|
|
if (!Array.isArray(this.events)) return 0;
|
|
return this.events.filter(event => this.equalDate(event, date)).length;
|
|
}
|
|
|
|
dispatchRangeChangeEvent() {
|
|
const currentMonthDates = this.getMonthDates(this.month, this.year);
|
|
const rangeStart = currentMonthDates[0];
|
|
const rangeEnd = currentMonthDates[currentMonthDates.length - 1];
|
|
|
|
this.dispatchEvent(new CustomEvent('range-changed', {
|
|
detail: {
|
|
month: this.month,
|
|
year: this.year,
|
|
rangeStart,
|
|
rangeEnd,
|
|
visibleDates: currentMonthDates
|
|
},
|
|
bubbles: true,
|
|
composed: true
|
|
}));
|
|
}
|
|
}
|
|
|
|
window.customElements.define('tp-date-picker', TpDatePicker);
|