Files
rio/frontend/code/components/calendar.ts
2024-06-19 10:50:34 +02:00

360 lines
11 KiB
TypeScript

import { ComponentBase, ComponentState } from './componentBase';
import { applyIcon } from '../designApplication';
import { markEventAsHandled } from '../eventHandling';
const CALENDAR_WIDTH = 15.7;
const CALENDAR_HEIGHT = 17.8;
export type CalendarState = ComponentState & {
_type_: 'Calendar-builtin';
selectedYear?: number;
selectedMonth?: number; // [1, 12]
selectedDay?: number; // [1, ...]
monthNamesLong?: Array<string>;
dayNamesLong?: Array<string>;
firstDayOfWeek?: number;
};
export class CalendarComponent extends ComponentBase {
state: Required<CalendarState>;
// Internal HTML Elements
private prevYearButton: HTMLElement;
private prevMonthButton: HTMLElement;
private yearMonthDisplay: HTMLElement;
private nextMonthButton: HTMLElement;
private nextYearButton: HTMLElement;
private grid: HTMLElement;
// These store the displayed year and month. This is in contrast to the
// *selected* year and month, which are stored in the state.
private displayedYear: number;
private displayedMonth: number; // [1, 12]
createElement(): HTMLElement {
// Create the HTML structure
let element = document.createElement('div');
element.classList.add('rio-calendar');
element.innerHTML = `
<div class="rio-calendar-inner" style="width: ${CALENDAR_WIDTH}rem; height: ${CALENDAR_HEIGHT}rem;">
<div class="rio-calendar-header">
<div class="rio-calendar-button rio-calendar-prev-year-button"></div>
<div class="rio-calendar-button rio-calendar-prev-month-button"></div>
<div class="rio-calendar-year-month-display"></div>
<div class="rio-calendar-button rio-calendar-next-month-button"></div>
<div class="rio-calendar-button rio-calendar-next-year-button"></div>
</div>
<div class="rio-calendar-grid"></div>
</div>
`;
// Expose the elements
let innerElement = element.firstElementChild as HTMLElement;
let headerElement;
[headerElement, this.grid] = Array.from(
innerElement.children
) as HTMLElement[];
[
this.prevYearButton,
this.prevMonthButton,
this.yearMonthDisplay,
this.nextMonthButton,
this.nextYearButton,
] = Array.from(headerElement.children) as HTMLElement[];
// Initialize icons
applyIcon(
this.prevYearButton,
'material/keyboard-double-arrow-left',
'currentColor'
);
applyIcon(
this.prevMonthButton,
'material/keyboard-arrow-left',
'currentColor'
);
applyIcon(
this.nextMonthButton,
'material/keyboard-arrow-right',
'currentColor'
);
applyIcon(
this.nextYearButton,
'material/keyboard-double-arrow-right',
'currentColor'
);
// Initialize the state
this.displayedYear = this.state.selectedYear;
this.displayedMonth = this.state.selectedMonth;
// Initialize the content
this.displayedValuesChanged();
// Connect to events
this.prevMonthButton.addEventListener(
'click',
this.onPressPrevMonth.bind(this)
);
this.nextMonthButton.addEventListener(
'click',
this.onPressNextMonth.bind(this)
);
this.prevYearButton.addEventListener(
'click',
this.onPressPrevYear.bind(this)
);
this.nextYearButton.addEventListener(
'click',
this.onPressNextYear.bind(this)
);
return element;
}
updateElement(
deltaState: CalendarState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// Apply latent changes to the state
let dateChanged: boolean = false;
if (deltaState.selectedYear !== undefined) {
this.state.selectedYear = deltaState.selectedYear;
this.displayedYear = this.state.selectedYear;
dateChanged = true;
}
if (deltaState.selectedMonth !== undefined) {
this.state.selectedMonth = deltaState.selectedMonth;
this.displayedMonth = this.state.selectedMonth;
dateChanged = true;
}
if (deltaState.selectedDay !== undefined) {
this.state.selectedDay = deltaState.selectedDay;
dateChanged = true;
}
// Then update the UI
if (dateChanged) {
this.displayedValuesChanged();
}
}
displayedValuesChanged(): void {
// Update the year and month display
console.debug(this.state.monthNamesLong, this.displayedMonth);
let monthName = this.state.monthNamesLong[this.displayedMonth - 1];
this.yearMonthDisplay.textContent = `${monthName} ${this.displayedYear}`;
// Update the grid
this.updateGrid();
}
updateGrid(): void {
// Clear the grid
this.grid.innerHTML = '';
// Add the day names
for (let i = 0; i < 7; ++i) {
let nameIndex = (i + this.state.firstDayOfWeek) % 7;
let longName = this.state.dayNamesLong[nameIndex];
let shortName = longName.slice(0, 1); // Don't crash if the name is too short
let cell = document.createElement('div');
cell.classList.add('rio-calendar-day-name');
cell.textContent = shortName;
this.grid.appendChild(cell);
}
// The first day of the month isn't placed in the first cell, because
// it must line up with the correct day of the week. Prepare a shift
// value to account for this.
//
// Since this is modular arithmetic and modulus is weird with negative
// numbers, subtraction is done by _adding_.
let firstThisMonth = new Date(
this.displayedYear,
this.displayedMonth - 1,
1
);
let dayShift =
(firstThisMonth.getDay() - this.state.firstDayOfWeek + 6) % 7;
// Prepare a list of all days to display
//
// Each day has the following values:
//
// - year
// - month
// - day
// - CSS classes to apply
let days: Array<[number, number, number, Array<string>]> = [];
// Subtract one, to account for the fact that the first day is 1 instead
// of zero. Note that this is a real subtraction. This is so that (day
// shift + 1) never ever becomes 7, as that would lead to an empty first
// row.
dayShift -= 1;
// Add the final days from the previous month
let numDaysPrevMonth = new Date(
this.displayedYear,
this.displayedMonth - 1,
0
).getDate();
let numEmptyCells = dayShift + 1;
let prevYear =
this.displayedMonth === 1
? this.displayedYear - 1
: this.displayedYear;
let prevMonth =
this.displayedMonth === 1 ? 12 : this.displayedMonth - 1;
for (
let i = numDaysPrevMonth - numEmptyCells + 1;
i <= numDaysPrevMonth;
++i
) {
days.push([
prevYear,
prevMonth,
i,
['rio-calendar-day', 'rio-calendar-day-other-month'],
]);
}
// Add the days of this month
let daysThisMonth = new Date(
this.displayedYear,
this.displayedMonth, // This will correctly overflow to the next year
0
).getDate();
let selectedDayIndex =
this.state.selectedYear === this.displayedYear &&
this.state.selectedMonth === this.displayedMonth
? this.state.selectedDay
: -1;
for (let i = 1; i <= daysThisMonth; ++i) {
let classes = ['rio-calendar-day'];
if (i === selectedDayIndex) {
classes.push('rio-calendar-selected-day');
}
days.push([this.displayedYear, this.displayedMonth, i, classes]);
}
// Add the first few days from the next month
let numEmptyCellsEnd = 7 - ((daysThisMonth + dayShift + 1) % 7);
numEmptyCellsEnd = numEmptyCellsEnd === 7 ? 0 : numEmptyCellsEnd;
let nextYear =
this.displayedMonth === 12
? this.displayedYear + 1
: this.displayedYear;
let nextMonth =
this.displayedMonth === 12 ? 1 : this.displayedMonth + 1;
for (let i = 1; i <= numEmptyCellsEnd; ++i) {
days.push([
nextYear,
nextMonth,
i,
['rio-calendar-day', 'rio-calendar-day-other-month'],
]);
}
// Populate the grid
for (let i = 0; i < days.length; ++i) {
let [year, month, day, classes] = days[i];
// Spawn the element
let cell = document.createElement('div');
this.grid.appendChild(cell);
cell.classList.add(...classes);
cell.textContent = day.toString();
// Detect clicks
cell.addEventListener('click', () =>
this.on_select_day(year, month, day)
);
}
}
onPressPrevMonth(event: MouseEvent): void {
if (this.displayedMonth === 1) {
this.displayedMonth = 12;
--this.displayedYear;
} else {
--this.displayedMonth;
}
this.displayedValuesChanged();
markEventAsHandled(event);
}
onPressNextMonth(event: MouseEvent): void {
if (this.displayedMonth === 12) {
this.displayedMonth = 1;
++this.displayedYear;
} else {
++this.displayedMonth;
}
this.displayedValuesChanged();
markEventAsHandled(event);
}
onPressPrevYear(event: MouseEvent): void {
--this.displayedYear;
this.displayedValuesChanged();
markEventAsHandled(event);
}
onPressNextYear(event: MouseEvent): void {
++this.displayedYear;
this.displayedValuesChanged();
markEventAsHandled(event);
}
on_select_day(year: number, month: number, day: number): void {
// Switch to the selected day
this.state.selectedYear = year;
this.state.selectedMonth = month;
this.state.selectedDay = day;
// Notify the backend
this.sendMessageToBackend({
year: this.state.selectedYear,
month: this.state.selectedMonth,
day: this.state.selectedDay,
});
// Update the grid
this.updateGrid();
}
}