diff --git a/package.json b/package.json index 8831b25c6c..2f1894fea2 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,6 @@ "@tsparticles/engine": "3.9.1", "@tsparticles/preset-links": "3.2.0", "@vibrant/color": "4.0.4", - "@vue/web-component-wrapper": "1.3.0", "@webcomponents/scoped-custom-element-registry": "0.0.10", "@webcomponents/webcomponentsjs": "2.8.0", "barcode-detector": "3.1.1", @@ -131,8 +130,6 @@ "superstruct": "2.0.2", "tinykeys": "3.0.0", "ua-parser-js": "2.0.9", - "vue": "2.7.16", - "vue2-daterange-picker": "0.6.8", "weekstart": "2.0.0", "workbox-cacheable-response": "7.4.0", "workbox-core": "7.4.0", diff --git a/renovate.json b/renovate.json index 82d32f8253..d9a8110670 100644 --- a/renovate.json +++ b/renovate.json @@ -24,11 +24,6 @@ "extends": ["monorepo:material-components-web"], "enabled": false }, - { - "description": "Vue is only used by date range which is only v2", - "matchPackageNames": ["vue"], - "allowedVersions": "< 3" - }, { "description": "Group MDI packages", "groupName": "Material Design Icons", diff --git a/src/common/datetime/calc_date_range.ts b/src/common/datetime/calc_date_range.ts index c87a1b8976..0bf7938d7e 100644 --- a/src/common/datetime/calc_date_range.ts +++ b/src/common/datetime/calc_date_range.ts @@ -1,17 +1,17 @@ import { addDays, - subHours, endOfDay, endOfMonth, + endOfQuarter, endOfWeek, endOfYear, startOfDay, startOfMonth, + startOfQuarter, startOfWeek, startOfYear, - startOfQuarter, - endOfQuarter, subDays, + subHours, subMonths, } from "date-fns"; import type { HomeAssistant } from "../../types"; @@ -33,88 +33,89 @@ export type DateRange = | "now-24h"; export const calcDateRange = ( - hass: HomeAssistant, + locale: HomeAssistant["locale"], + hassConfig: HomeAssistant["config"], range: DateRange ): [Date, Date] => { const today = new Date(); - const weekStartsOn = firstWeekdayIndex(hass.locale); + const weekStartsOn = firstWeekdayIndex(locale); switch (range) { case "today": return [ - calcDate(today, startOfDay, hass.locale, hass.config, { + calcDate(today, startOfDay, locale, hassConfig, { weekStartsOn, }), - calcDate(today, endOfDay, hass.locale, hass.config, { + calcDate(today, endOfDay, locale, hassConfig, { weekStartsOn, }), ]; case "yesterday": return [ - calcDate(addDays(today, -1), startOfDay, hass.locale, hass.config, { + calcDate(addDays(today, -1), startOfDay, locale, hassConfig, { weekStartsOn, }), - calcDate(addDays(today, -1), endOfDay, hass.locale, hass.config, { + calcDate(addDays(today, -1), endOfDay, locale, hassConfig, { weekStartsOn, }), ]; case "this_week": return [ - calcDate(today, startOfWeek, hass.locale, hass.config, { + calcDate(today, startOfWeek, locale, hassConfig, { weekStartsOn, }), - calcDate(today, endOfWeek, hass.locale, hass.config, { + calcDate(today, endOfWeek, locale, hassConfig, { weekStartsOn, }), ]; case "this_month": return [ - calcDate(today, startOfMonth, hass.locale, hass.config), - calcDate(today, endOfMonth, hass.locale, hass.config), + calcDate(today, startOfMonth, locale, hassConfig), + calcDate(today, endOfMonth, locale, hassConfig), ]; case "this_quarter": return [ - calcDate(today, startOfQuarter, hass.locale, hass.config), - calcDate(today, endOfQuarter, hass.locale, hass.config), + calcDate(today, startOfQuarter, locale, hassConfig), + calcDate(today, endOfQuarter, locale, hassConfig), ]; case "this_year": return [ - calcDate(today, startOfYear, hass.locale, hass.config), - calcDate(today, endOfYear, hass.locale, hass.config), + calcDate(today, startOfYear, locale, hassConfig), + calcDate(today, endOfYear, locale, hassConfig), ]; case "now-7d": return [ - calcDate(today, subDays, hass.locale, hass.config, 7), - calcDate(today, subDays, hass.locale, hass.config, 0), + calcDate(today, subDays, locale, hassConfig, 7), + calcDate(today, subDays, locale, hassConfig, 0), ]; case "now-30d": return [ - calcDate(today, subDays, hass.locale, hass.config, 30), - calcDate(today, subDays, hass.locale, hass.config, 0), + calcDate(today, subDays, locale, hassConfig, 30), + calcDate(today, subDays, locale, hassConfig, 0), ]; case "now-12m": return [ calcDate( today, (date) => subMonths(startOfMonth(date), 11), - hass.locale, - hass.config + locale, + hassConfig ), - calcDate(today, endOfMonth, hass.locale, hass.config), + calcDate(today, endOfMonth, locale, hassConfig), ]; case "now-1h": return [ - calcDate(today, subHours, hass.locale, hass.config, 1), - calcDate(today, subHours, hass.locale, hass.config, 0), + calcDate(today, subHours, locale, hassConfig, 1), + calcDate(today, subHours, locale, hassConfig, 0), ]; case "now-12h": return [ - calcDate(today, subHours, hass.locale, hass.config, 12), - calcDate(today, subHours, hass.locale, hass.config, 0), + calcDate(today, subHours, locale, hassConfig, 12), + calcDate(today, subHours, locale, hassConfig, 0), ]; case "now-24h": return [ - calcDate(today, subHours, hass.locale, hass.config, 24), - calcDate(today, subHours, hass.locale, hass.config, 0), + calcDate(today, subHours, locale, hassConfig, 24), + calcDate(today, subHours, locale, hassConfig, 0), ]; } return [today, today]; diff --git a/src/common/datetime/format_date.ts b/src/common/datetime/format_date.ts index 7f8e331ad8..d09c408924 100644 --- a/src/common/datetime/format_date.ts +++ b/src/common/datetime/format_date.ts @@ -261,3 +261,36 @@ const formatDateWeekdayShortDateMem = memoizeOne( timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), }) ); + +/** + * Format a date as YYYY-MM-DD. Uses "en-CA" because it's the only + * Intl locale that natively outputs ISO 8601 date format. + * Locale/config are only used to resolve the time zone. + */ +export const formatISODateOnly = ( + dateObj: Date, + locale: FrontendLocaleData, + config: HassConfig +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const formatter = new Intl.DateTimeFormat("en-CA", { + year: "numeric", + month: "2-digit", + day: "2-digit", + timeZone, + }); + return formatter.format(dateObj); +}; + +// 2026-08-10/2026-08-15 +export const formatCallyDateRange = ( + start: Date, + end: Date, + locale: FrontendLocaleData, + config: HassConfig +) => { + const startDate = formatISODateOnly(start, locale, config); + const endDate = formatISODateOnly(end, locale, config); + + return `${startDate}/${endDate}`; +}; diff --git a/src/components/date-picker/date-range-picker.ts b/src/components/date-picker/date-range-picker.ts new file mode 100644 index 0000000000..677660c3f3 --- /dev/null +++ b/src/components/date-picker/date-range-picker.ts @@ -0,0 +1,348 @@ +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, 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 type { ValueChangedEvent } from "../../types"; +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 { DateRangePickerRanges } from "./ha-date-range-picker"; +import { datePickerStyles, dateRangePickerStyles } from "./styles"; + +@customElement("date-range-picker") +export class DateRangePicker extends 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 }, + }; + + 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(), + }, + }; + } + } + + render() { + return html`
+ ${this.ranges !== false && this.ranges + ? html`
+ + ${Object.keys(this.ranges).map( + (name) => html`${name}` + )} + +
` + : 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) { + 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 _setDateRange(ev: CustomEvent) { + const dateRange: [Date, Date] = Object.values(this.ranges!)[ + ev.detail.index + ]; + this._dateValue = formatCallyDateRange( + dateRange[0], + dateRange[1], + this.locale, + this.hassConfig + ); + fireEvent(this, "value-changed", { + value: { + startDate: dateRange[0], + endDate: dateRange[1], + }, + }); + fireEvent(this, "preset-selected", { + index: ev.detail.index, + }); + } + + private _handleChangeTime(ev: ValueChangedEvent) { + ev.stopPropagation(); + const time = ev.detail.value; + const type = (ev.target as HaBaseTimeInput).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, + css` + .picker { + display: flex; + } + + .date-range-ranges { + border-right: 1px solid var(--divider-color); + } + .range { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + padding: var(--ha-space-3); + } + + .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); + } + + @media only screen and (max-width: 500px) { + .date-range-ranges { + max-width: 30%; + } + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "date-range-picker": DateRangePicker; + } + + interface HASSDomEvents { + "cancel-date-picker": undefined; + "preset-selected": { index: number }; + } +} diff --git a/src/components/date-picker/ha-date-range-picker.ts b/src/components/date-picker/ha-date-range-picker.ts new file mode 100644 index 0000000000..70b3955da0 --- /dev/null +++ b/src/components/date-picker/ha-date-range-picker.ts @@ -0,0 +1,406 @@ +import "@home-assistant/webawesome/dist/components/popover/popover"; +import { consume, type ContextType } from "@lit/context"; +import { mdiCalendar } from "@mdi/js"; +import "cally"; +import { isThisYear } from "date-fns"; +import type { TemplateResult } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { tinykeys } from "tinykeys"; +import { shiftDateRange } from "../../common/datetime/calc_date"; +import type { DateRange } from "../../common/datetime/calc_date_range"; +import { calcDateRange } from "../../common/datetime/calc_date_range"; +import { + formatShortDateTime, + formatShortDateTimeWithYear, +} from "../../common/datetime/format_date_time"; +import { fireEvent } from "../../common/dom/fire_event"; +import { + configContext, + localeContext, + localizeContext, +} from "../../data/context"; +import "../ha-bottom-sheet"; +import "../ha-icon-button"; +import "../ha-icon-button-next"; +import "../ha-icon-button-prev"; +import "../ha-textarea"; +import "./date-range-picker"; + +export type DateRangePickerRanges = Record; + +const RANGE_KEYS: DateRange[] = ["today", "yesterday", "this_week"]; +const EXTENDED_RANGE_KEYS: DateRange[] = [ + "this_month", + "this_year", + "now-1h", + "now-12h", + "now-24h", + "now-7d", + "now-30d", +]; + +@customElement("ha-date-range-picker") +export class HaDateRangePicker extends LitElement { + @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; + + @property({ attribute: false }) public startDate!: Date; + + @property({ attribute: false }) public endDate!: Date; + + @property({ attribute: false }) public ranges?: DateRangePickerRanges | false; + + @state() private _ranges?: DateRangePickerRanges; + + @property({ attribute: "time-picker", type: Boolean }) + public timePicker = false; + + @property({ type: Boolean, reflect: true }) + public backdrop = false; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public minimal = false; + + @property({ attribute: "extended-presets", type: Boolean }) + public extendedPresets = false; + + @property({ attribute: "popover-placement" }) + public popoverPlacement: + | "bottom" + | "top" + | "left" + | "right" + | "top-start" + | "top-end" + | "right-start" + | "right-end" + | "bottom-start" + | "bottom-end" + | "left-start" + | "left-end" = "bottom-start"; + + @state() private _opened = false; + + @state() private _pickerWrapperOpen = false; + + @state() private _openedNarrow = false; + + @state() private _popoverWidth = 0; + + @query(".container") private _containerElement?: HTMLDivElement; + + private _narrow = false; + + private _unsubscribeTinyKeys?: () => void; + + public connectedCallback() { + super.connectedCallback(); + + this._handleResize(); + window.addEventListener("resize", this._handleResize); + + const rangeKeys = this.extendedPresets + ? [...RANGE_KEYS, ...EXTENDED_RANGE_KEYS] + : RANGE_KEYS; + + this._ranges = {}; + rangeKeys.forEach((key) => { + this._ranges![ + this.localize(`ui.components.date-range-picker.ranges.${key}`) + ] = calcDateRange(this.locale, this.hassConfig, key); + }); + } + + public open(): void { + this._openPicker(); + } + + protected render(): TemplateResult { + return html` +
+
+ ${!this.minimal + ? html`= 459 ? " - " : " - \n") + + (isThisYear(this.endDate) + ? formatShortDateTime( + this.endDate, + this.locale, + this.hassConfig + ) + : formatShortDateTimeWithYear( + this.endDate, + this.locale, + this.hassConfig + ))} + .label=${this.localize( + "ui.components.date-range-picker.start_date" + ) + + " - " + + this.localize("ui.components.date-range-picker.end_date")} + .disabled=${this.disabled} + readonly + > + + + + ` + : html``} +
+ ${this._pickerWrapperOpen || this._opened + ? this._openedNarrow + ? html` + + ${this._renderPicker()} + + ` + : html` + + ${this._renderPicker()} + + ` + : nothing} +
+ `; + } + + private _renderPicker() { + if (!this._opened) { + return nothing; + } + return html` + + + `; + } + + private _hidePicker(ev: Event) { + ev.stopPropagation(); + this._opened = false; + this._pickerWrapperOpen = false; + this._unsubscribeTinyKeys?.(); + fireEvent(this, "picker-closed"); + } + + public disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener("resize", this._handleResize); + this._unsubscribeTinyKeys?.(); + } + + private _handleResize = () => { + this._narrow = + window.matchMedia("(max-width: 870px)").matches || + window.matchMedia("(max-height: 500px)").matches; + + if (!this._openedNarrow && this._pickerWrapperOpen) { + this._popoverWidth = this._containerElement?.offsetWidth || 250; + } + }; + + private _dialogOpened = () => { + this._opened = true; + this._setTextareaFocusStyle(true); + }; + + private _handlePopoverHide = () => { + this._opened = false; + }; + + private _handleNext(ev: MouseEvent): void { + if (ev && ev.stopPropagation) ev.stopPropagation(); + this._shift(true); + } + + private _handlePrev(ev: MouseEvent): void { + if (ev && ev.stopPropagation) ev.stopPropagation(); + this._shift(false); + } + + private _shift(forward: boolean) { + if (!this.startDate) return; + const { start, end } = shiftDateRange( + this.startDate, + this.endDate, + forward, + this.locale, + this.hassConfig + ); + this.startDate = start; + this.endDate = end; + fireEvent(this, "value-changed", { + value: { + startDate: this.startDate, + endDate: this.endDate, + }, + }); + } + + private _closePicker() { + this._pickerWrapperOpen = false; + } + + private _openPicker(ev?: Event) { + if (this.disabled) { + return; + } + if (this._pickerWrapperOpen) { + ev?.stopImmediatePropagation(); + return; + } + this._openedNarrow = this._narrow; + this._popoverWidth = this._containerElement?.offsetWidth || 250; + this._pickerWrapperOpen = true; + this._unsubscribeTinyKeys = tinykeys(this, { + Escape: this._handleEscClose, + }); + } + + private _handleKeydown(ev: KeyboardEvent) { + if (ev.key === "Enter" || ev.key === " ") { + ev.stopPropagation(); + this._openPicker(ev); + } + } + + private _handleEscClose = (ev: KeyboardEvent) => { + ev.stopPropagation(); + }; + + private _setTextareaFocusStyle(focused: boolean) { + const textarea = this.renderRoot.querySelector("ha-textarea"); + if (textarea) { + const foundation = (textarea as any).mdcFoundation; + if (foundation) { + if (focused) { + foundation.activateFocus(); + } else { + foundation.deactivateFocus(); + } + } + } + } + + static styles = [ + css` + ha-icon-button { + direction: var(--direction); + } + + .date-range-inputs { + display: flex; + align-items: center; + gap: var(--ha-space-2); + } + + ha-textarea { + display: inline-block; + width: 340px; + } + @media only screen and (max-width: 460px) { + ha-textarea { + width: 100%; + } + } + + wa-popover { + --wa-space-l: 0; + } + + wa-popover::part(dialog)::backdrop { + opacity: 0; + transition: opacity var(--ha-animation-duration-normal) ease-out; + } + + wa-popover.open::part(dialog)::backdrop { + opacity: 1; + } + + :host(:not([backdrop])) wa-popover::part(dialog)::backdrop { + background: none; + } + + wa-popover::part(body) { + min-width: max(var(--body-width), 250px); + max-width: calc( + 100vw - var(--safe-area-inset-left) - var( + --safe-area-inset-right + ) - var(--ha-space-8) + ); + overflow: hidden; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-date-range-picker": HaDateRangePicker; + } +} diff --git a/src/components/ha-dialog-date-picker.ts b/src/components/date-picker/ha-dialog-date-picker.ts similarity index 70% rename from src/components/ha-dialog-date-picker.ts rename to src/components/date-picker/ha-dialog-date-picker.ts index b4219840a0..e1fa32f876 100644 --- a/src/components/ha-dialog-date-picker.ts +++ b/src/components/date-picker/ha-dialog-date-picker.ts @@ -8,16 +8,22 @@ import { formatDateMonth, formatDateShort, formatDateYear, -} from "../common/datetime/format_date"; -import { configContext, localeContext, localizeContext } from "../data/context"; -import { DialogMixin } from "../dialogs/dialog-mixin"; -import "./ha-button"; -import type { DatePickerDialogParams } from "./ha-date-input"; -import "./ha-dialog"; -import "./ha-dialog-footer"; -import "./ha-icon-button"; -import "./ha-icon-button-next"; -import "./ha-icon-button-prev"; + formatISODateOnly, +} from "../../common/datetime/format_date"; +import { + configContext, + localeContext, + localizeContext, +} from "../../data/context"; +import { DialogMixin } from "../../dialogs/dialog-mixin"; +import "../ha-button"; +import type { DatePickerDialogParams } from "../ha-date-input"; +import "../ha-dialog"; +import "../ha-dialog-footer"; +import "../ha-icon-button"; +import "../ha-icon-button-next"; +import "../ha-icon-button-prev"; +import { datePickerStyles } from "./styles"; type CalendarDate = HTMLElementTagNameMap["calendar-date"]; @@ -75,7 +81,7 @@ export class HaDialogDatePicker extends DialogMixin( ? { year: this._pickerYear, title: formatDateShort(date, this.locale, this.hassConfig), - dateString: this.params.value.substring(0, 10), + dateString: formatISODateOnly(date, this.locale, this.hassConfig), } : undefined; } @@ -160,7 +166,8 @@ export class HaDialogDatePicker extends DialogMixin( this._value = { year: formatDateYear(date, this.locale, this.hassConfig), title: formatDateShort(date, this.locale, this.hassConfig), - dateString: value || date.toISOString().substring(0, 10), + dateString: + value || formatISODateOnly(date, this.locale, this.hassConfig), }; if (setFocusDay) { @@ -196,81 +203,14 @@ export class HaDialogDatePicker extends DialogMixin( this.closeDialog(); } - static styles = css` - ha-dialog { - --dialog-content-padding: 0; - } - calendar-date { - width: 100%; - } - calendar-date::part(button) { - border: none; - background-color: unset; - border-radius: var(--ha-border-radius-circle); - outline-offset: -2px; - outline-color: var(--ha-color-neutral-60); - } - - calendar-month { - width: 100%; - margin: 0 auto; - min-height: calc(42px * 7); - } - - calendar-month::part(heading) { - display: none; - } - calendar-month::part(day) { - color: var(--disabled-text-color); - font-size: var(--ha-font-size-m); - font-family: var(--ha-font-body); - } - calendar-month::part(button), - calendar-month::part(selected):focus-visible { - color: var(--primary-text-color); - height: 32px; - width: 32px; - margin: var(--ha-space-1); - border-radius: var(--ha-border-radius-circle); - } - calendar-month::part(button):focus-visible { - background-color: inherit; - outline: 1px solid var(--ha-color-neutral-60); - outline-offset: 2px; - } - calendar-month::part(button):hover { - background-color: var(--ha-color-fill-primary-quiet-hover); - } - calendar-month::part(today) { - color: var(--primary-color); - } - calendar-month::part(selected), - calendar-month::part(selected):hover { - color: var(--text-primary-color); - background-color: var(--primary-color); - height: 40px; - width: 40px; - margin: 0; - } - calendar-month::part(selected):focus-visible { - background-color: var(--primary-color); - color: var(--text-primary-color); - } - - .heading { - display: flex; - justify-content: center; - align-items: center; - width: 100%; - font-size: var(--ha-font-size-m); - font-weight: var(--ha-font-weight-medium); - } - .month-year { - flex: 1; - text-align: center; - margin-left: 48px; - } - `; + static styles = [ + datePickerStyles, + css` + ha-dialog { + --dialog-content-padding: 0; + } + `, + ]; } declare global { diff --git a/src/components/date-picker/styles.ts b/src/components/date-picker/styles.ts new file mode 100644 index 0000000000..3a965b7347 --- /dev/null +++ b/src/components/date-picker/styles.ts @@ -0,0 +1,144 @@ +import { css } from "lit"; + +export const datePickerStyles = css` + calendar-range, + calendar-date { + width: 100%; + min-width: 300px; + } + calendar-date::part(button), + calendar-range::part(button) { + border: none; + background-color: unset; + border-radius: var(--ha-border-radius-circle); + outline-offset: -2px; + outline-color: var(--ha-color-neutral-60); + } + + calendar-month { + width: calc(40px * 7); + margin: 0 auto; + min-height: calc(42px * 7); + } + + calendar-month::part(heading) { + display: none; + } + calendar-month::part(day) { + color: var(--disabled-text-color); + font-size: var(--ha-font-size-m); + font-family: var(--ha-font-body); + } + calendar-month::part(button) { + color: var(--primary-text-color); + height: 32px; + width: 32px; + margin: var(--ha-space-1); + border-radius: var(--ha-border-radius-circle); + } + calendar-month::part(button):focus-visible { + background-color: inherit; + outline: 2px solid var(--accent-color); + outline-offset: 2px; + } + calendar-month::part(button):hover { + background-color: var(--ha-color-fill-primary-quiet-hover); + } + calendar-month::part(today) { + color: var(--primary-color); + } + calendar-month::part(range-inner), + calendar-month::part(range-start), + calendar-month::part(range-end), + calendar-month::part(selected), + calendar-month::part(selected):hover { + color: var(--text-primary-color); + background-color: var(--primary-color); + height: 40px; + width: 40px; + margin: 0; + } + calendar-month::part(selected):focus-visible { + background-color: var(--primary-color); + color: var(--text-primary-color); + } + + calendar-month::part(outside) { + cursor: pointer; + } + + .heading { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + font-size: var(--ha-font-size-m); + font-weight: var(--ha-font-weight-medium); + } + .month-year { + flex: 1; + text-align: center; + margin-left: 48px; + } + + @media only screen and (max-width: 500px) { + calendar-month { + min-height: calc(34px * 7); + } + calendar-month::part(day) { + font-size: var(--ha-font-size-s); + } + calendar-month::part(button) { + height: 26px; + width: 26px; + } + calendar-month::part(range-inner), + calendar-month::part(range-start), + calendar-month::part(range-end), + calendar-month::part(selected), + calendar-month::part(selected):hover { + height: 34px; + width: 34px; + } + .heading { + font-size: var(--ha-font-size-s); + } + .month-year { + margin-left: 40px; + } + } +`; + +export const dateRangePickerStyles = css` + calendar-month::part(selected):focus-visible { + background-color: var(--primary-color); + color: var(--text-primary-color); + } + calendar-month::part(range-inner), + calendar-month::part(range-start), + calendar-month::part(range-end), + calendar-month::part(range-inner):hover, + calendar-month::part(range-start):hover, + calendar-month::part(range-end):hover { + color: var(--text-primary-color); + background-color: var(--primary-color); + border-radius: var(--ha-border-radius-square); + display: block; + margin: 0; + } + calendar-month::part(range-start), + calendar-month::part(range-start):hover { + border-top-left-radius: var(--ha-border-radius-circle); + border-bottom-left-radius: var(--ha-border-radius-circle); + } + calendar-month::part(range-end), + calendar-month::part(range-end):hover { + border-top-right-radius: var(--ha-border-radius-circle); + border-bottom-right-radius: var(--ha-border-radius-circle); + } + calendar-month::part(range-start):hover, + calendar-month::part(range-end):hover, + calendar-month::part(range-inner):hover { + color: var(--primary-text-color); + } +`; diff --git a/src/components/date-range-picker.ts b/src/components/date-range-picker.ts deleted file mode 100644 index 4af5a40a17..0000000000 --- a/src/components/date-range-picker.ts +++ /dev/null @@ -1,359 +0,0 @@ -import wrap from "@vue/web-component-wrapper"; -import { customElement } from "lit/decorators"; -import Vue from "vue"; -import DateRangePicker from "vue2-daterange-picker"; -// @ts-ignore -import dateRangePickerStyles from "vue2-daterange-picker/dist/vue2-daterange-picker.css"; -import { - localizeMonths, - localizeWeekdays, -} from "../common/datetime/localize_date"; -import { fireEvent } from "../common/dom/fire_event"; -import { mainWindow } from "../common/dom/get_main_window"; - -// eslint-disable-next-line @typescript-eslint/naming-convention -const CustomDateRangePicker = Vue.extend({ - mixins: [DateRangePicker], - methods: { - // Set the current date to the left picker instead of the right picker because the right is hidden - selectMonthDate() { - const dt: Date = this.end || new Date(); - // @ts-ignore - this.changeLeftMonth({ - year: dt.getFullYear(), - month: dt.getMonth() + 1, - }); - }, - // Fix the start/end date calculation when selecting a date range. The - // original code keeps track of the first clicked date (in_selection) but it - // never sets it to either the start or end date variables, so if the - // in_selection date is between the start and end date that were set by the - // hover the selection will enter a broken state that's counter-intuitive - // when hovering between weeks and leads to a random date when selecting a - // range across months. This bug doesn't seem to be present on v0.6.7 of the - // lib - hoverDate(value: Date) { - if (this.readonly) return; - - if (this.in_selection) { - const pickA = this.in_selection as Date; - const pickB = value; - - this.start = this.normalizeDatetime( - Math.min(pickA.valueOf(), pickB.valueOf()), - this.start - ); - this.end = this.normalizeDatetime( - Math.max(pickA.valueOf(), pickB.valueOf()), - this.end - ); - } - - this.$emit("hover-date", value); - }, - }, -}); - -// eslint-disable-next-line @typescript-eslint/naming-convention -const Component = Vue.extend({ - props: { - timePicker: { - type: Boolean, - default: true, - }, - twentyfourHours: { - type: Boolean, - default: true, - }, - openingDirection: { - type: String, - default: "right", - }, - disabled: { - type: Boolean, - default: false, - }, - ranges: { - type: Boolean, - default: true, - }, - startDate: { - type: [String, Date], - default() { - return new Date(); - }, - }, - endDate: { - type: [String, Date], - default() { - return new Date(); - }, - }, - firstDay: { - type: Number, - default: 1, - }, - autoApply: { - type: Boolean, - default: false, - }, - language: { - type: String, - default: "en", - }, - opensVertical: { - type: String, - default: undefined, - }, - }, - render(createElement) { - // @ts-expect-error - return createElement(CustomDateRangePicker, { - props: { - "time-picker": this.timePicker, - "auto-apply": this.autoApply, - opens: this.openingDirection, - "show-dropdowns": false, - "time-picker24-hour": this.twentyfourHours, - disabled: this.disabled, - ranges: this.ranges ? {} : false, - "locale-data": { - firstDay: this.firstDay, - daysOfWeek: localizeWeekdays(this.language, true), - monthNames: localizeMonths(this.language, false), - }, - }, - model: { - value: { - startDate: this.startDate, - endDate: this.endDate, - }, - callback: (value) => { - fireEvent(this.$el as HTMLElement, "change", value); - }, - expression: "dateRange", - }, - on: { - toggle: (open: boolean) => { - fireEvent(this.$el as HTMLElement, "toggle", { open }); - }, - }, - scopedSlots: { - input() { - return createElement("slot", { - domProps: { name: "input" }, - }); - }, - header() { - return createElement("slot", { - domProps: { name: "header" }, - }); - }, - ranges() { - return createElement("slot", { - domProps: { name: "ranges" }, - }); - }, - footer() { - return createElement("slot", { - domProps: { name: "footer" }, - }); - }, - }, - }); - }, -}); - -// Assertion corrects HTMLElement type from package -// eslint-disable-next-line @typescript-eslint/naming-convention -const WrappedElement = wrap( - Vue, - Component -) as unknown as CustomElementConstructor; - -@customElement("date-range-picker") -class DateRangePickerElement extends WrappedElement { - constructor() { - super(); - const style = document.createElement("style"); - style.innerHTML = ` - ${dateRangePickerStyles} - .calendars { - display: flex; - flex-wrap: nowrap !important; - } - .daterangepicker { - top: auto; - box-shadow: var(--ha-card-box-shadow, none); - background-color: var(--card-background-color); - border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg)); - border-width: var(--ha-card-border-width, 1px); - border-style: solid; - border-color: var( - --ha-card-border-color, - var(--divider-color, #e0e0e0) - ); - color: var(--primary-text-color); - min-width: initial !important; - max-height: var(--date-range-picker-max-height); - overflow-y: auto; - } - .daterangepicker:before { - display: none; - } - .daterangepicker:after { - border-bottom: 6px solid var(--card-background-color); - } - .daterangepicker .calendar-table { - background-color: var(--card-background-color); - border: none; - } - .daterangepicker .calendar-table td, - .daterangepicker .calendar-table th { - background-color: transparent; - color: var(--secondary-text-color); - border-radius: var(--ha-border-radius-square); - outline: none; - min-width: 32px; - height: 32px; - } - .daterangepicker td.off, - .daterangepicker td.off.end-date, - .daterangepicker td.off.in-range, - .daterangepicker td.off.start-date { - background-color: var(--secondary-background-color); - color: var(--disabled-text-color); - } - .daterangepicker td.in-range { - background-color: var(--light-primary-color); - color: var(--text-light-primary-color, var(--primary-text-color)); - } - .daterangepicker td.active, - .daterangepicker td.active:hover { - background-color: var(--primary-color); - color: var(--text-primary-color); - } - .daterangepicker td.start-date.end-date { - border-radius: var(--ha-border-radius-circle); - } - .daterangepicker td.start-date { - border-radius: var(--ha-border-radius-circle) var(--ha-border-radius-square) var(--ha-border-radius-square) var(--ha-border-radius-circle); - } - .daterangepicker td.end-date { - border-radius: var(--ha-border-radius-square) var(--ha-border-radius-circle) var(--ha-border-radius-circle) var(--ha-border-radius-square); - } - .reportrange-text { - background: none !important; - padding: 0 !important; - border: none !important; - } - .daterangepicker .calendar-table .next span, - .daterangepicker .calendar-table .prev span { - border: solid var(--primary-text-color); - border-width: 0 2px 2px 0; - } - .daterangepicker .ranges li { - outline: none; - } - .daterangepicker .ranges li:hover { - background-color: var(--secondary-background-color); - } - .daterangepicker .ranges li.active { - background-color: var(--primary-color); - color: var(--text-primary-color); - } - .daterangepicker select.ampmselect, - .daterangepicker select.hourselect, - .daterangepicker select.minuteselect, - .daterangepicker select.secondselect { - background: var(--card-background-color); - border: 1px solid var(--divider-color); - color: var(--primary-color); - } - .daterangepicker .drp-buttons .btn { - border: 1px solid var(--primary-color); - background-color: transparent; - color: var(--primary-color); - border-radius: var(--ha-border-radius-sm); - padding: 8px; - cursor: pointer; - } - .calendars-container { - flex-direction: column; - align-items: center; - } - .drp-calendar.col.right .calendar-table { - display: none; - } - .daterangepicker.show-ranges .drp-calendar.left { - border-left: 0px; - } - .daterangepicker .drp-calendar.left { - padding: 8px; - width: unset; - max-width: unset; - min-width: 270px; - } - .daterangepicker.show-calendar .ranges { - margin-top: 0; - padding-top: 8px; - border-right: 1px solid var(--divider-color); - } - @media only screen and (max-width: 800px) { - .calendars { - flex-direction: column; - } - } - .calendar-table { - padding: 0 !important; - } - .calendar-time { - direction: ltr; - } - .daterangepicker.ltr { - direction: var(--direction); - text-align: var(--float-start); - } - .vue-daterange-picker{ - min-width: unset !important; - display: block !important; - } - :host([opens-vertical="up"]) .daterangepicker { - bottom: 100%; - top: auto !important; - } - `; - if (mainWindow.document.dir === "rtl") { - style.innerHTML += ` - .daterangepicker .calendar-table .next span { - transform: rotate(135deg); - -webkit-transform: rotate(135deg); - } - .daterangepicker .calendar-table .prev span { - transform: rotate(-45deg); - -webkit-transform: rotate(-45deg); - } - .daterangepicker td.start-date { - border-radius: var(--ha-border-radius-square) var(--ha-border-radius-circle) var(--ha-border-radius-circle) var(--ha-border-radius-square); - } - .daterangepicker td.end-date { - border-radius: var(--ha-border-radius-circle) var(--ha-border-radius-square) var(--ha-border-radius-square) var(--ha-border-radius-circle); - } - `; - } - - const shadowRoot = this.shadowRoot!; - shadowRoot.appendChild(style); - // Stop click events from reaching the document, otherwise it will close the picker immediately. - shadowRoot.addEventListener("click", (ev) => ev.stopPropagation()); - } -} - -declare global { - interface HTMLElementTagNameMap { - "date-range-picker": DateRangePickerElement; - } - interface HASSDomEvents { - toggle: { open: boolean }; - } -} diff --git a/src/components/ha-base-time-input.ts b/src/components/ha-base-time-input.ts index 7804c0f3e9..7195e74590 100644 --- a/src/components/ha-base-time-input.ts +++ b/src/components/ha-base-time-input.ts @@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit"; import { customElement, property, queryAll } from "lit/decorators"; import { ifDefined } from "lit/directives/if-defined"; import { fireEvent } from "../common/dom/fire_event"; +import { stopPropagation } from "../common/dom/stop_propagation"; import "./ha-icon-button"; import "./ha-input-helper-text"; import "./ha-select"; @@ -133,6 +134,9 @@ export class HaBaseTimeInput extends LitElement { @property({ type: Boolean, reflect: true }) public clearable?: boolean; + @property({ attribute: "placeholder-labels", type: Boolean }) + public placeholderLabels = false; + @queryAll("ha-input") private _inputs?: HaInput[]; static shadowRootOptions = { @@ -158,7 +162,8 @@ export class HaBaseTimeInput extends LitElement { type="number" inputmode="numeric" .value=${this.days.toFixed()} - .label=${this.dayLabel} + .label=${!this.placeholderLabels ? this.dayLabel : ""} + .placeholder=${this.placeholderLabels ? this.dayLabel : ""} name="days" @change=${this._valueChanged} @focusin=${this._onFocus} @@ -178,7 +183,8 @@ export class HaBaseTimeInput extends LitElement { type="number" inputmode="numeric" .value=${this.hours.toFixed()} - .label=${this.hourLabel} + .label=${!this.placeholderLabels ? this.hourLabel : ""} + .placeholder=${this.placeholderLabels ? this.hourLabel : ""} name="hours" @change=${this._valueChanged} @focusin=${this._onFocus} @@ -197,7 +203,8 @@ export class HaBaseTimeInput extends LitElement { type="number" inputmode="numeric" .value=${this._formatValue(this.minutes)} - .label=${this.minLabel} + .label=${!this.placeholderLabels ? this.minLabel : ""} + .placeholder=${this.placeholderLabels ? this.minLabel : ""} @change=${this._valueChanged} @focusin=${this._onFocus} name="minutes" @@ -220,7 +227,8 @@ export class HaBaseTimeInput extends LitElement { inputmode="decimal" step="any" .value=${this._formatValue(this.seconds)} - .label=${this.secLabel} + .label=${!this.placeholderLabels ? this.secLabel : ""} + .placeholder=${this.placeholderLabels ? this.secLabel : ""} @change=${this._valueChanged} @focusin=${this._onFocus} name="seconds" @@ -241,7 +249,8 @@ export class HaBaseTimeInput extends LitElement { id="millisec" type="number" .value=${this._formatValue(this.milliseconds, 3)} - .label=${this.millisecLabel} + .label=${!this.placeholderLabels ? this.millisecLabel : ""} + .placeholder=${this.placeholderLabels ? this.millisecLabel : ""} @change=${this._valueChanged} @focusin=${this._onFocus} name="milliseconds" @@ -263,6 +272,7 @@ export class HaBaseTimeInput extends LitElement { .disabled=${this.disabled} .name=${"amPm"} @selected=${this._valueChanged} + @wa-after-hide=${stopPropagation} .options=${["AM", "PM"]} > `} diff --git a/src/components/ha-date-input.ts b/src/components/ha-date-input.ts index cb94387b3b..42eb43cc3c 100644 --- a/src/components/ha-date-input.ts +++ b/src/components/ha-date-input.ts @@ -11,7 +11,8 @@ import "./ha-svg-icon"; import "./ha-textfield"; import type { HaTextField } from "./ha-textfield"; -const loadDatePickerDialog = () => import("./ha-dialog-date-picker"); +const loadDatePickerDialog = () => + import("./date-picker/ha-dialog-date-picker"); export interface DatePickerDialogParams { value?: string; diff --git a/src/components/ha-date-range-picker.ts b/src/components/ha-date-range-picker.ts deleted file mode 100644 index 1b4f11b88a..0000000000 --- a/src/components/ha-date-range-picker.ts +++ /dev/null @@ -1,422 +0,0 @@ -import type { ActionDetail } from "@material/mwc-list/mwc-list-foundation"; - -import { mdiCalendar } from "@mdi/js"; -import { isThisYear } from "date-fns"; -import { TZDate } from "@date-fns/tz"; -import type { PropertyValues, TemplateResult } from "lit"; -import { LitElement, css, html, nothing } from "lit"; -import { customElement, property, state } from "lit/decorators"; -import { ifDefined } from "lit/directives/if-defined"; -import { shiftDateRange } from "../common/datetime/calc_date"; -import type { DateRange } from "../common/datetime/calc_date_range"; -import { calcDateRange } from "../common/datetime/calc_date_range"; -import { firstWeekdayIndex } from "../common/datetime/first_weekday"; -import { - formatShortDateTime, - formatShortDateTimeWithYear, -} from "../common/datetime/format_date_time"; -import { useAmPm } from "../common/datetime/use_am_pm"; -import { fireEvent } from "../common/dom/fire_event"; -import { TimeZone } from "../data/translation"; -import type { HomeAssistant } from "../types"; -import "./date-range-picker"; -import "./ha-button"; -import "./ha-icon-button"; -import "./ha-icon-button-next"; -import "./ha-icon-button-prev"; -import "./ha-list"; -import "./ha-list-item"; -import "./ha-textarea"; - -export type DateRangePickerRanges = Record; - -declare global { - interface HASSDomEvents { - "preset-selected": { index: number }; - } -} - -const RANGE_KEYS: DateRange[] = ["today", "yesterday", "this_week"]; -const EXTENDED_RANGE_KEYS: DateRange[] = [ - "this_month", - "this_year", - "now-1h", - "now-12h", - "now-24h", - "now-7d", - "now-30d", -]; - -@customElement("ha-date-range-picker") -export class HaDateRangePicker extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @property({ attribute: false }) public startDate!: Date; - - @property({ attribute: false }) public endDate!: Date; - - @property({ attribute: false }) public ranges?: DateRangePickerRanges | false; - - @state() private _ranges?: DateRangePickerRanges; - - @property({ attribute: "auto-apply", type: Boolean }) - public autoApply = false; - - @property({ attribute: "time-picker", type: Boolean }) - public timePicker = false; - - public open(): void { - this._openPicker(); - } - - @property({ type: Boolean }) public disabled = false; - - @property({ type: Boolean }) public minimal = false; - - @state() private _hour24format = false; - - @property({ attribute: "extended-presets", type: Boolean }) - public extendedPresets = false; - - @property({ attribute: "vertical-opening-direction" }) - public verticalOpeningDirection?: "up" | "down"; - - @property({ attribute: false }) public openingDirection?: - | "right" - | "left" - | "center" - | "inline"; - - @state() private _calcedOpeningDirection?: - | "right" - | "left" - | "center" - | "inline"; - - @state() private _calcedVerticalOpeningDirection?: "up" | "down"; - - protected willUpdate(changedProps: PropertyValues) { - if ( - (!this.hasUpdated && this.ranges === undefined) || - (changedProps.has("hass") && - this.hass?.localize !== changedProps.get("hass")?.localize) - ) { - const rangeKeys = this.extendedPresets - ? [...RANGE_KEYS, ...EXTENDED_RANGE_KEYS] - : RANGE_KEYS; - - this._ranges = {}; - rangeKeys.forEach((key) => { - this._ranges![ - this.hass.localize(`ui.components.date-range-picker.ranges.${key}`) - ] = calcDateRange(this.hass, key); - }); - } - } - - protected updated(changedProps: PropertyValues) { - if (changedProps.has("hass")) { - const oldHass = changedProps.get("hass") as HomeAssistant | undefined; - if (!oldHass || oldHass.locale !== this.hass.locale) { - this._hour24format = !useAmPm(this.hass.locale); - } - } - } - - protected render(): TemplateResult { - return html` - -
- ${!this.minimal - ? html`= 459 ? " - " : " - \n") + - (isThisYear(this.endDate) - ? formatShortDateTime( - this.endDate, - this.hass.locale, - this.hass.config - ) - : formatShortDateTimeWithYear( - this.endDate, - this.hass.locale, - this.hass.config - ))} - .label=${this.hass.localize( - "ui.components.date-range-picker.start_date" - ) + - " - " + - this.hass.localize( - "ui.components.date-range-picker.end_date" - )} - .disabled=${this.disabled} - @click=${this._handleInputClick} - readonly - > - - - - ` - : html``} -
- ${this.ranges !== false && (this.ranges || this._ranges) - ? html`
- - ${Object.keys(this.ranges || this._ranges!).map( - (name) => html`${name}` - )} - -
` - : nothing} - -
- `; - } - - private _handleNext(ev: MouseEvent): void { - if (ev && ev.stopPropagation) ev.stopPropagation(); - this._shift(true); - } - - private _handlePrev(ev: MouseEvent): void { - if (ev && ev.stopPropagation) ev.stopPropagation(); - this._shift(false); - } - - private _shift(forward: boolean) { - if (!this.startDate) return; - const { start, end } = shiftDateRange( - this.startDate, - this.endDate, - forward, - this.hass.locale, - this.hass.config - ); - this.startDate = start; - this.endDate = end; - const dateRange = [start, end]; - const dateRangePicker = this._dateRangePicker; - dateRangePicker.clickRange(dateRange); - dateRangePicker.clickedApply(); - } - - private _setDateRange(ev: CustomEvent) { - const dateRange = Object.values(this.ranges || this._ranges!)[ - ev.detail.index - ]; - - fireEvent(this, "preset-selected", { - index: ev.detail.index, - }); - const dateRangePicker = this._dateRangePicker; - dateRangePicker.clickRange(dateRange); - dateRangePicker.clickedApply(); - } - - private _cancelDateRange() { - this._dateRangePicker.clickCancel(); - } - - private _applyDateRange() { - let start = new Date(this._dateRangePicker.start); - let end = new Date(this._dateRangePicker.end); - - if (this.timePicker) { - start.setSeconds(0); - start.setMilliseconds(0); - end.setSeconds(0); - end.setMilliseconds(0); - - if ( - end.getHours() === 0 && - end.getMinutes() === 0 && - start.getFullYear() === end.getFullYear() && - start.getMonth() === end.getMonth() && - start.getDate() === end.getDate() - ) { - end.setDate(end.getDate() + 1); - } - } - - if (this.hass.locale.time_zone === TimeZone.server) { - start = new Date(new TZDate(start, this.hass.config.time_zone).getTime()); - end = new Date(new TZDate(end, this.hass.config.time_zone).getTime()); - } - - if ( - start.getTime() !== this._dateRangePicker.start.getTime() || - end.getTime() !== this._dateRangePicker.end.getTime() - ) { - this._dateRangePicker.clickRange([start, end]); - } - this._dateRangePicker.clickedApply(); - } - - private _formatDate(date: Date): string { - if (this.hass.locale.time_zone === TimeZone.server) { - return new TZDate(date, this.hass.config.time_zone).toISOString(); - } - return date.toISOString(); - } - - private get _dateRangePicker() { - const dateRangePicker = this.shadowRoot!.querySelector( - "date-range-picker" - ) as any; - return dateRangePicker.vueComponent.$children[0]; - } - - private _openPicker() { - if (!this._dateRangePicker.open) { - const datePicker = this.shadowRoot!.querySelector( - "date-range-picker div.date-range-inputs" - ) as any; - datePicker?.click(); - } - } - - private _handleInputClick() { - // close the date picker, so it will open again on the click event - if (this._dateRangePicker.open) { - this._dateRangePicker.open = false; - } - } - - private _handleClick() { - // calculate opening direction if not set - if (!this._dateRangePicker.open) { - if (!this.openingDirection) { - const datePickerPosition = this.getBoundingClientRect().x; - let opens: "right" | "left" | "center" | "inline"; - if (datePickerPosition > (2 * window.innerWidth) / 3) { - opens = "left"; - } else if (datePickerPosition < window.innerWidth / 3) { - opens = "right"; - } else { - opens = "center"; - } - this._calcedOpeningDirection = opens; - } - if (!this.verticalOpeningDirection) { - const rect = this.getBoundingClientRect(); - this._calcedVerticalOpeningDirection = - rect.top > window.innerHeight / 2 ? "up" : "down"; - } - } - } - - private _handleChange(ev: CustomEvent) { - ev.stopPropagation(); - const startDate = ev.detail.startDate; - const endDate = ev.detail.endDate; - - fireEvent(this, "value-changed", { - value: { startDate, endDate }, - }); - } - - static styles = css` - ha-icon-button { - direction: var(--direction); - } - - .date-range-inputs { - display: flex; - align-items: center; - gap: var(--ha-space-2); - } - - .date-range-ranges { - border-right: 1px solid var(--divider-color); - } - - .date-range-footer { - display: flex; - justify-content: flex-end; - padding: 8px; - border-top: 1px solid var(--divider-color); - } - - ha-textarea { - display: inline-block; - width: 340px; - } - @media only screen and (max-width: 460px) { - ha-textarea { - width: 100%; - } - } - @media only screen and (max-width: 800px) { - .date-range-ranges { - border-right: none; - border-bottom: 1px solid var(--divider-color); - } - } - - @media only screen and (max-height: 940px) and (max-width: 800px) { - .date-range-ranges { - overflow: auto; - max-height: calc(70vh - 330px); - min-height: 160px; - } - - :host([header-position]) .date-range-ranges { - max-height: calc(90vh - 430px); - } - } - `; -} - -declare global { - interface HTMLElementTagNameMap { - "ha-date-range-picker": HaDateRangePicker; - } -} diff --git a/src/components/ha-time-input.ts b/src/components/ha-time-input.ts index 70411b01e1..79281ccd3a 100644 --- a/src/components/ha-time-input.ts +++ b/src/components/ha-time-input.ts @@ -26,6 +26,9 @@ export class HaTimeInput extends LitElement { @property({ type: Boolean, reflect: true }) public clearable?: boolean; + @property({ attribute: "placeholder-labels", type: Boolean }) + public placeholderLabels = false; + @query("ha-base-time-input") private _input?: HaBaseTimeInput; public reportValidity(): boolean { @@ -67,6 +70,7 @@ export class HaTimeInput extends LitElement { .required=${this.required} .clearable=${this.clearable && this.value !== undefined} .helper=${this.helper} + .placeholderLabels=${this.placeholderLabels} day-label="dd" hour-label="hh" min-label="mm" diff --git a/src/components/input/ha-input.ts b/src/components/input/ha-input.ts index 0cec11b394..e29a2c39c7 100644 --- a/src/components/input/ha-input.ts +++ b/src/components/input/ha-input.ts @@ -271,7 +271,7 @@ export class HaInput extends LitElement { .type=${this.type} .value=${this.value ?? null} .withClear=${this.withClear} - .placeholder=${this.placeholder && this.label ? this.placeholder : ""} + .placeholder=${this.placeholder} .readonly=${this.readonly} .passwordToggle=${this.passwordToggle} .passwordVisible=${this.passwordVisible} @@ -294,7 +294,8 @@ export class HaInput extends LitElement { .disabled=${this.disabled} class=${classMap({ invalid: this.invalid || this._invalid, - "label-raised": this.value || this.placeholder, + "label-raised": this.value || (this.label && this.placeholder), + "no-label": !this.label, })} @input=${this._handleInput} @change=${this._handleChange} @@ -304,11 +305,7 @@ export class HaInput extends LitElement { > ${this.label || hasLabelSlot ? html`${this._renderLabel( - this.label, - this.placeholder, - this.required - )}${this._renderLabel(this.label, this.required)}` : nothing} { - // fallback to placeholder if no label is provided - const text = label || placeholder; - if (!required) { - return text; - } - - let marker = getComputedStyle(this).getPropertyValue( - "--ha-input-required-marker" - ); - - if (!marker) { - marker = "*"; - } - - if (marker.startsWith('"') && marker.endsWith('"')) { - marker = marker.slice(1, -1); - } - - if (!marker) { - return text; - } - - return `${text}${marker}`; + private _renderLabel = memoizeOne((label: string, required: boolean) => { + if (!required) { + return label; } - ); + + let marker = getComputedStyle(this).getPropertyValue( + "--ha-input-required-marker" + ); + + if (!marker) { + marker = "*"; + } + + if (marker.startsWith('"') && marker.endsWith('"')) { + marker = marker.slice(1, -1); + } + + if (!marker) { + return label; + } + + return `${label}${marker}`; + }); static styles = css` :host { @@ -504,6 +497,9 @@ export class HaInput extends LitElement { padding-top: var(--ha-space-3); padding-inline-start: var(--input-padding-inline-start, 0); } + wa-input.no-label::part(input) { + padding-top: 0; + } :host([type="color"]) wa-input::part(input) { padding-top: var(--ha-space-6); } diff --git a/src/data/energy.ts b/src/data/energy.ts index 673151c7a8..ccdb0f81f9 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -3,27 +3,30 @@ import { addHours, addMilliseconds, addMonths, + addYears, differenceInDays, differenceInMonths, endOfDay, - startOfDay, isFirstDayOfMonth, isLastDayOfMonth, - addYears, + startOfDay, } from "date-fns"; import type { Collection, HassEntity } from "home-assistant-js-websocket"; import { getCollection } from "home-assistant-js-websocket"; import memoizeOne from "memoize-one"; -import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix"; import { calcDate, - calcDateProperty, calcDateDifferenceProperty, + calcDateProperty, } from "../common/datetime/calc_date"; +import type { DateRange } from "../common/datetime/calc_date_range"; +import { calcDateRange } from "../common/datetime/calc_date_range"; import { formatTime24h } from "../common/datetime/format_time"; +import { formatNumber } from "../common/number/format_number"; +import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix"; import { groupBy } from "../common/util/group-by"; -import { fileDownload } from "../util/file_download"; import type { HomeAssistant } from "../types"; +import { fileDownload } from "../util/file_download"; import type { Statistics, StatisticsMetaData, @@ -36,9 +39,6 @@ import { getStatisticMetadata, VOLUME_UNITS, } from "./recorder"; -import { calcDateRange } from "../common/datetime/calc_date_range"; -import type { DateRange } from "../common/datetime/calc_date_range"; -import { formatNumber } from "../common/number/format_number"; export const ENERGY_COLLECTION_KEY_PREFIX = "energy_"; @@ -841,7 +841,7 @@ export const getEnergyDataCollection = ( const period = preferredPeriod === "today" && hour === "0" ? "yesterday" : preferredPeriod; - const [start, end] = calcDateRange(hass, period); + const [start, end] = calcDateRange(hass.locale, hass.config, period); collection.start = calcDate(start, startOfDay, hass.locale, hass.config); collection.end = calcDate(end, endOfDay, hass.locale, hass.config); diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index dfcc1eac31..5dee1a642e 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -26,8 +26,9 @@ import { import { MIN_TIME_BETWEEN_UPDATES } from "../../components/chart/ha-chart-base"; import "../../components/chart/state-history-charts"; import type { StateHistoryCharts } from "../../components/chart/state-history-charts"; -import "../../components/ha-date-range-picker"; +import "../../components/date-picker/ha-date-range-picker"; import "../../components/ha-dropdown"; +import type { HaDropdownSelectEvent } from "../../components/ha-dropdown"; import "../../components/ha-dropdown-item"; import "../../components/ha-icon-button"; import "../../components/ha-icon-button-arrow-prev"; @@ -50,7 +51,6 @@ import { haStyle, haStyleScrollbar } from "../../resources/styles"; import type { HomeAssistant } from "../../types"; import { fileDownload } from "../../util/file_download"; import { addEntitiesToLovelaceView } from "../lovelace/editor/add-entities-to-view"; -import type { HaDropdownSelectEvent } from "../../components/ha-dropdown"; @customElement("ha-panel-history") class HaPanelHistory extends LitElement { @@ -169,7 +169,6 @@ class HaPanelHistory extends LitElement {
- import("../../../components/ha-dialog-date-picker"); + import("../../../components/date-picker/ha-dialog-date-picker"); export const supportsDateSetCardFeature = ( hass: HomeAssistant, diff --git a/src/panels/lovelace/components/hui-energy-period-selector.ts b/src/panels/lovelace/components/hui-energy-period-selector.ts index d4547560c4..5fe0de0573 100644 --- a/src/panels/lovelace/components/hui-energy-period-selector.ts +++ b/src/panels/lovelace/components/hui-energy-period-selector.ts @@ -1,10 +1,10 @@ import { + mdiCheckboxBlankOutline, + mdiCheckboxOutline, mdiChevronLeft, mdiChevronRight, mdiDotsVertical, mdiDownload, - mdiCheckboxBlankOutline, - mdiCheckboxOutline, mdiHomeClock, } from "@mdi/js"; import { @@ -30,8 +30,6 @@ import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import memoizeOne from "memoize-one"; -import { mainWindow } from "../../../common/dom/get_main_window"; -import { stopPropagation } from "../../../common/dom/stop_propagation"; import { calcDate, calcDateDifferenceProperty, @@ -47,13 +45,15 @@ import { formatDateVeryShort, formatDateYear, } from "../../../common/datetime/format_date"; +import { mainWindow } from "../../../common/dom/get_main_window"; +import { stopPropagation } from "../../../common/dom/stop_propagation"; import { debounce } from "../../../common/util/debounce"; -import "../../../components/ha-button"; -import "../../../components/ha-date-range-picker"; +import "../../../components/date-picker/ha-date-range-picker"; import type { DateRangePickerRanges, HaDateRangePicker, -} from "../../../components/ha-date-range-picker"; +} from "../../../components/date-picker/ha-date-range-picker"; +import "../../../components/ha-button"; import "../../../components/ha-dropdown"; import "../../../components/ha-dropdown-item"; import "../../../components/ha-ripple"; @@ -88,6 +88,10 @@ interface OverflowMenuItem { action: () => void; } +type VerticalOpeningDirection = "up" | "down"; + +type OpeningDirection = "right" | "left" | "center" | "inline"; + @customElement("hui-energy-period-selector") export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @@ -102,10 +106,10 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { true; @property({ attribute: "vertical-opening-direction" }) - public verticalOpeningDirection?: "up" | "down"; + public verticalOpeningDirection?: VerticalOpeningDirection; @property({ attribute: "opening-direction" }) - public openingDirection?: "right" | "left" | "center" | "inline"; + public openingDirection?: OpeningDirection; @state() _datepickerOpen = false; @@ -175,7 +179,7 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { RANGE_KEYS.forEach((key) => { this._ranges[ this.hass.localize(`ui.components.date-range-picker.ranges.${key}`) - ] = calcDateRange(this.hass, key); + ] = calcDateRange(this.hass.locale, this.hass.config, key); }); } } @@ -269,7 +273,6 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
@@ -527,16 +532,29 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { ); const today = new Date(); if (range === "month") { - [this._startDate, this._endDate] = calcDateRange(this.hass, "this_month"); + [this._startDate, this._endDate] = calcDateRange( + this.hass.locale, + this.hass.config, + "this_month" + ); } else if (range === "quarter") { [this._startDate, this._endDate] = calcDateRange( - this.hass, + this.hass.locale, + this.hass.config, "this_quarter" ); } else if (range === "year") { - [this._startDate, this._endDate] = calcDateRange(this.hass, "this_year"); + [this._startDate, this._endDate] = calcDateRange( + this.hass.locale, + this.hass.config, + "this_year" + ); } else if (range === "12month") { - [this._startDate, this._endDate] = calcDateRange(this.hass, "now-12m"); + [this._startDate, this._endDate] = calcDateRange( + this.hass.locale, + this.hass.config, + "now-12m" + ); } else if (range === "months") { // Custom month range const difference = calcDateDifferenceProperty( @@ -587,7 +605,8 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { ) { // Pick current week [this._startDate, this._endDate] = calcDateRange( - this.hass, + this.hass.locale, + this.hass.config, "this_week" ); } else { @@ -672,6 +691,20 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { energyCollection.refresh(); } + private _getDatePickerPlacement = memoizeOne( + ( + verticalOpeningDirection: VerticalOpeningDirection | undefined, + openingDirection: OpeningDirection | undefined + ): HaDateRangePicker["popoverPlacement"] => { + const vertical = verticalOpeningDirection === "up" ? "top" : "bottom"; + if (openingDirection === "center" || openingDirection === "inline") { + return vertical; + } + const horizontal = openingDirection === "left" ? "end" : "start"; + return `${vertical}-${horizontal}`; + } + ); + static styles = css` :host { display: block; diff --git a/src/translations/en.json b/src/translations/en.json index c639bc9454..fe4dad806f 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -993,6 +993,8 @@ "end_date": "End date", "select": "Select", "select_date_range": "Select time period", + "time_from": "Time from", + "time_to": "Time to", "ranges": { "today": "Today", "yesterday": "Yesterday", diff --git a/yarn.lock b/yarn.lock index ede96ab829..cd7ac879be 100644 --- a/yarn.lock +++ b/yarn.lock @@ -317,7 +317,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.23.5, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": +"@babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": version: 7.29.0 resolution: "@babel/parser@npm:7.29.0" dependencies: @@ -5241,28 +5241,6 @@ __metadata: languageName: node linkType: hard -"@vue/compiler-sfc@npm:2.7.16": - version: 2.7.16 - resolution: "@vue/compiler-sfc@npm:2.7.16" - dependencies: - "@babel/parser": "npm:^7.23.5" - postcss: "npm:^8.4.14" - prettier: "npm:^1.18.2 || ^2.0.0" - source-map: "npm:^0.6.1" - dependenciesMeta: - prettier: - optional: true - checksum: 10/fd1128fe1b0ebb1e680aa34909d73716ee5e6f4d3460c1c292b47626976d7af25982cdcbfba7cdbd74e1bee865c39813b82dc71c483731c58184d99ef4043d4d - languageName: node - linkType: hard - -"@vue/web-component-wrapper@npm:1.3.0": - version: 1.3.0 - resolution: "@vue/web-component-wrapper@npm:1.3.0" - checksum: 10/60b94cbf34bcaa9c04be867a19981083d0b235ae2dd24d70b9c89cf4b682f80cec0df125553af8ba78deaf162ca1c635e4054bd451897655b4bad07da842fae3 - languageName: node - linkType: hard - "@webcomponents/scoped-custom-element-registry@npm:0.0.10": version: 0.0.10 resolution: "@webcomponents/scoped-custom-element-registry@npm:0.0.10" @@ -6766,13 +6744,6 @@ __metadata: languageName: node linkType: hard -"csstype@npm:^3.1.0": - version: 3.2.3 - resolution: "csstype@npm:3.2.3" - checksum: 10/ad41baf7e2ffac65ab544d79107bf7cd1a4bb9bab9ac3302f59ab4ba655d5e30942a8ae46e10ba160c6f4ecea464cc95b975ca2fefbdeeacd6ac63f12f99fe1f - languageName: node - linkType: hard - "culori@npm:4.0.2": version: 4.0.2 resolution: "culori@npm:4.0.2" @@ -8967,7 +8938,6 @@ __metadata: "@types/webspeechapi": "npm:0.0.29" "@vibrant/color": "npm:4.0.4" "@vitest/coverage-v8": "npm:4.1.0" - "@vue/web-component-wrapper": "npm:1.3.0" "@webcomponents/scoped-custom-element-registry": "npm:0.0.10" "@webcomponents/webcomponentsjs": "npm:2.8.0" babel-loader: "npm:10.1.1" @@ -9052,8 +9022,6 @@ __metadata: ua-parser-js: "npm:2.0.9" vite-tsconfig-paths: "npm:6.1.1" vitest: "npm:4.1.0" - vue: "npm:2.7.16" - vue2-daterange-picker: "npm:0.6.8" webpack-stats-plugin: "npm:1.1.3" webpackbar: "npm:7.0.0" weekstart: "npm:2.0.0" @@ -11780,7 +11748,7 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.4.14, postcss@npm:^8.5.8": +"postcss@npm:^8.5.8": version: 8.5.8 resolution: "postcss@npm:8.5.8" dependencies: @@ -11821,15 +11789,6 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^1.18.2 || ^2.0.0": - version: 2.8.8 - resolution: "prettier@npm:2.8.8" - bin: - prettier: bin-prettier.js - checksum: 10/00cdb6ab0281f98306cd1847425c24cbaaa48a5ff03633945ab4c701901b8e96ad558eb0777364ffc312f437af9b5a07d0f45346266e8245beaf6247b9c62b24 - languageName: node - linkType: hard - "pretty-bytes@npm:^5.3.0": version: 5.6.0 resolution: "pretty-bytes@npm:5.6.0" @@ -12997,7 +12956,7 @@ __metadata: languageName: node linkType: hard -"source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.0, source-map@npm:~0.6.1": +"source-map@npm:^0.6.0, source-map@npm:~0.6.0, source-map@npm:~0.6.1": version: 0.6.1 resolution: "source-map@npm:0.6.1" checksum: 10/59ef7462f1c29d502b3057e822cdbdae0b0e565302c4dd1a95e11e793d8d9d62006cdc10e0fd99163ca33ff2071360cf50ee13f90440806e7ed57d81cba2f7ff @@ -14546,25 +14505,6 @@ __metadata: languageName: node linkType: hard -"vue2-daterange-picker@npm:0.6.8": - version: 0.6.8 - resolution: "vue2-daterange-picker@npm:0.6.8" - dependencies: - vue: "npm:^2.6.10" - checksum: 10/3975051d976fc90eb43d2d55af184fae11c64c6790d11fee8fe756514e909b5804768c71c19330e61f66d1e4e025864c4bf09210696bd130f67716562af3cd75 - languageName: node - linkType: hard - -"vue@npm:2.7.16, vue@npm:^2.6.10": - version: 2.7.16 - resolution: "vue@npm:2.7.16" - dependencies: - "@vue/compiler-sfc": "npm:2.7.16" - csstype: "npm:^3.1.0" - checksum: 10/0371f7bfafd9c6f58ffee6f291fc4ca045033f163e090d8afdfb7550fb9ba71770fe673941083038c88b4a45effe45bb8560c235dc3953c1ff8596f0ad4e4d88 - languageName: node - linkType: hard - "w3c-keyname@npm:^2.2.4": version: 2.2.8 resolution: "w3c-keyname@npm:2.2.8"