import { TZDate } from "@date-fns/tz"; import { consume, type ContextType } from "@lit/context"; import type { ActionDetail } from "@material/mwc-list"; import { mdiCalendarToday } from "@mdi/js"; import "cally"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, queryAll, state } from "lit/decorators"; import { firstWeekdayIndex } from "../../common/datetime/first_weekday"; import { formatCallyDateRange, formatDateMonth, formatDateYear, formatISODateOnly, } from "../../common/datetime/format_date"; import { fireEvent } from "../../common/dom/fire_event"; import { configContext, localeContext, localizeContext, } from "../../data/context"; import { TimeZone } from "../../data/translation"; import { MobileAwareMixin } from "../../mixins/mobile-aware-mixin"; import { haStyleScrollbar } from "../../resources/styles"; import type { ValueChangedEvent } from "../../types"; import "../chips/ha-chip-set"; import "../chips/ha-filter-chip"; import type { HaFilterChip } from "../chips/ha-filter-chip"; import type { HaBaseTimeInput } from "../ha-base-time-input"; import "../ha-icon-button"; import "../ha-icon-button-next"; import "../ha-icon-button-prev"; import "../ha-list"; import "../ha-list-item"; import "../ha-time-input"; import type { HaTimeInput } from "../ha-time-input"; import type { DateRangePickerRanges } from "./ha-date-range-picker"; import { datePickerStyles, dateRangePickerStyles } from "./styles"; @customElement("date-range-picker") export class DateRangePicker extends MobileAwareMixin(LitElement) { @property({ attribute: false }) public ranges?: DateRangePickerRanges | false; @property({ attribute: false }) public startDate?: Date; @property({ attribute: false }) public endDate?: Date; @property({ attribute: "time-picker", type: Boolean }) public timePicker = false; @state() @consume({ context: localizeContext, subscribe: true }) private localize!: ContextType; @state() @consume({ context: localeContext, subscribe: true }) private locale!: ContextType; @state() @consume({ context: configContext, subscribe: true }) private hassConfig!: ContextType; /** used to show month in calendar-range header */ @state() private _pickerMonth?: string; /** used to show year in calendar-date header */ @state() private _pickerYear?: string; /** used for today to navigate focus in calendar-range */ @state() private _focusDate?: string; @state() private _dateValue?: string; @state() private _timeValue = { from: { hours: 0, minutes: 0 }, to: { hours: 23, minutes: 59 }, }; @queryAll("ha-time-input") private _timeInputs?: NodeListOf; public connectedCallback() { super.connectedCallback(); const date = this.startDate || new Date(); this._dateValue = this.startDate && this.endDate ? formatCallyDateRange( this.startDate, this.endDate, this.locale, this.hassConfig ) : undefined; this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig); this._pickerYear = formatDateYear(date, this.locale, this.hassConfig); if (this.timePicker && this.startDate && this.endDate) { this._timeValue = { from: { hours: this.startDate.getHours(), minutes: this.startDate.getMinutes(), }, to: { hours: this.endDate.getHours(), minutes: this.endDate.getMinutes(), }, }; } } private _renderRanges() { if (this._isMobileSize) { return html` ${Object.entries(this.ranges!).map( ([name, range], index) => html` ${name} ` )} `; } return html` ${Object.keys(this.ranges!).map( (name) => html`${name}` )} `; } render() { return html`
${this.ranges !== false && this.ranges ? html`
${this._renderRanges()}
` : nothing}
${this._pickerMonth} ${this._pickerYear}
${this.timePicker ? html`
` : nothing}
`; } private _focusToday() { const date = new Date(); this._focusDate = formatISODateOnly(date, this.locale, this.hassConfig); this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig); this._pickerYear = formatDateYear(date, this.locale, this.hassConfig); } private _cancel() { fireEvent(this, "cancel-date-picker"); } private _save() { if (!this._dateValue) { return; } const dates = this._dateValue.split("/"); let startDate = new Date(`${dates[0]}T00:00:00`); let endDate = new Date(`${dates[1]}T23:59:00`); if (this.timePicker) { const timeInputs = this._timeInputs; if ( timeInputs && ![...timeInputs].every((input) => input.reportValidity()) ) { // If we have time inputs, and they don't all report valid, don't save return; } startDate.setHours(this._timeValue.from.hours); startDate.setMinutes(this._timeValue.from.minutes); endDate.setHours(this._timeValue.to.hours); endDate.setMinutes(this._timeValue.to.minutes); startDate.setSeconds(0); startDate.setMilliseconds(0); endDate.setSeconds(0); endDate.setMilliseconds(0); if (endDate <= startDate) { endDate.setDate(startDate.getDate() + 1); } } if (this.locale.time_zone === TimeZone.server) { startDate = new Date( new TZDate(startDate, this.hassConfig.time_zone).getTime() ); endDate = new Date( new TZDate(endDate, this.hassConfig.time_zone).getTime() ); } if ( startDate.getHours() !== this._timeValue.from.hours || startDate.getMinutes() !== this._timeValue.from.minutes || endDate.getHours() !== this._timeValue.to.hours || endDate.getMinutes() !== this._timeValue.to.minutes ) { this._timeValue.from.hours = startDate.getHours(); this._timeValue.from.minutes = startDate.getMinutes(); this._timeValue.to.hours = endDate.getHours(); this._timeValue.to.minutes = endDate.getMinutes(); } fireEvent(this, "value-changed", { value: { startDate, endDate, }, }); } private _focusChanged(ev: CustomEvent) { const date = ev.detail; this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig); this._pickerYear = formatDateYear(date, this.locale, this.hassConfig); this._focusDate = undefined; } private _handleChange(ev: CustomEvent) { const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"]; this._dateValue = dateElement.value; this._focusDate = undefined; } private _clickDateRangeChip(ev: Event) { const chip = ev.target as HaFilterChip & { index: number; range: [Date, Date]; }; this._saveDateRangePreset(chip.range, chip.index); } private _setDateRange(ev: CustomEvent) { const dateRange: [Date, Date] = Object.values(this.ranges!)[ ev.detail.index ]; this._saveDateRangePreset(dateRange, ev.detail.index); } private _saveDateRangePreset(range: [Date, Date], index: number) { fireEvent(this, "value-changed", { value: { startDate: range[0], endDate: range[1], }, }); fireEvent(this, "preset-selected", { index, }); } private _handleChangeTime(ev: ValueChangedEvent) { ev.stopPropagation(); const time = ev.detail.value; const target = ev.target as HaBaseTimeInput; const type = target.id; if (time) { if (!this._timeValue) { this._timeValue = { from: { hours: 0, minutes: 0 }, to: { hours: 23, minutes: 59 }, }; } const [hours, minutes] = time.split(":").map(Number); this._timeValue[type].hours = hours; this._timeValue[type].minutes = minutes; } } static styles = [ datePickerStyles, dateRangePickerStyles, haStyleScrollbar, css` .picker { display: flex; flex-direction: row; } .date-range-ranges { border-right: var(--ha-border-width-sm) solid var(--divider-color); min-width: 140px; flex: 0 1 30%; } .range { display: flex; flex-direction: column; align-items: center; flex: 1; padding: var(--ha-space-3); overflow-x: hidden; } @media all and (max-width: 450px), all and (max-height: 500px) { .picker { flex-direction: column; } .date-range-ranges { border-bottom: 1px solid var(--divider-color); margin-top: var(--ha-space-5); overflow: visible; } ha-chip-set { padding: var(--ha-space-3); flex-wrap: nowrap; overflow-x: auto; } .range { flex-basis: fit-content; } } .times { display: flex; flex-direction: column; gap: var(--ha-space-2); } .footer { display: flex; justify-content: flex-end; padding: var(--ha-space-2); border-top: 1px solid var(--divider-color); } `, ]; } declare global { interface HTMLElementTagNameMap { "date-range-picker": DateRangePicker; } interface HASSDomEvents { "cancel-date-picker": undefined; "preset-selected": { index: number }; } }