From 8541494b3d47b03f79d60ce2111a6e86f574d478 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Tue, 17 Mar 2026 09:18:18 +0100 Subject: [PATCH] Migrate date picker to cally calendar-date (#29994) * Switch date picker to cally calendar-date * Remove app-datepicker styles from date picker dialog Clean up unused CSS overrides in ha-dialog-date-picker. Remove custom properties, focus/body rules, and responsive media queries for app-datepicker and calendar-date so the component uses upstream defaults and avoids duplicate styling * Review * Apply suggestion from @MindFreeze Co-authored-by: Petar Petrov * Fix date parsing in HaDialogDatePicker to handle ISO string format * Update src/components/ha-dialog-date-picker.ts --------- Co-authored-by: Petar Petrov --- package.json | 3 +- src/components/ha-dialog-date-picker.ts | 331 ++++++++++++++++-------- src/translations/en.json | 1 + yarn.lock | 94 ++----- 4 files changed, 238 insertions(+), 191 deletions(-) diff --git a/package.json b/package.json index 13e1328400..7bec55295f 100644 --- a/package.json +++ b/package.json @@ -90,8 +90,8 @@ "@vue/web-component-wrapper": "1.3.0", "@webcomponents/scoped-custom-element-registry": "0.0.10", "@webcomponents/webcomponentsjs": "2.8.0", - "app-datepicker": "5.1.1", "barcode-detector": "3.1.1", + "cally": "0.9.2", "color-name": "2.1.0", "comlink": "4.4.2", "core-js": "3.48.0", @@ -221,7 +221,6 @@ "workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch" }, "resolutions": { - "@material/mwc-button@^0.25.3": "^0.27.0", "lit": "3.3.2", "lit-html": "3.3.2", "clean-css": "5.3.3", diff --git a/src/components/ha-dialog-date-picker.ts b/src/components/ha-dialog-date-picker.ts index c49f25e8ed..b4219840a0 100644 --- a/src/components/ha-dialog-date-picker.ts +++ b/src/components/ha-dialog-date-picker.ts @@ -1,117 +1,189 @@ -import "app-datepicker"; -import { format } from "date-fns"; +import "@home-assistant/webawesome/dist/components/divider/divider"; +import { consume, type ContextType } from "@lit/context"; +import { mdiBackspace, mdiCalendarToday } from "@mdi/js"; +import "cally"; import { css, html, LitElement, nothing } from "lit"; -import { customElement, property, state } from "lit/decorators"; -import { fireEvent } from "../common/dom/fire_event"; -import { nextRender } from "../common/util/render-status"; -import { haStyleDialog } from "../resources/styles"; -import type { HomeAssistant } from "../types"; -import type { DatePickerDialogParams } from "./ha-date-input"; +import { customElement, state } from "lit/decorators"; +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 "./ha-dialog-footer"; +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"; +type CalendarDate = HTMLElementTagNameMap["calendar-date"]; + +/** + * A date picker dialog component that displays a calendar for selecting dates. + * Uses the `cally` library for calendar rendering and supports localization, + * min/max date constraints, and optional clearing of the selected date. + * + * @element ha-dialog-date-picker + * Uses {@link DialogMixin} with {@link DatePickerDialogParams} to manage dialog state and parameters. + */ @customElement("ha-dialog-date-picker") -export class HaDialogDatePicker extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; +export class HaDialogDatePicker extends DialogMixin( + LitElement +) { + @state() + @consume({ context: localizeContext, subscribe: true }) + private localize!: ContextType; - @property() public value?: string; + @state() + @consume({ context: localeContext, subscribe: true }) + private locale!: ContextType; - @property({ type: Boolean }) public disabled = false; + @state() + @consume({ context: configContext, subscribe: true }) + private hassConfig!: ContextType; - @property() public label?: string; + @state() private _value?: { + year: string; + title: string; + dateString: string; + }; - @state() private _params?: DatePickerDialogParams; + /** used to show month in calendar-date header */ + @state() private _pickerMonth?: string; - @state() private _open = false; + /** used to show year in calendar-date header */ + @state() private _pickerYear?: string; - @state() private _value?: string; + /** used for today to navigate focus in cally-calendar-date */ + @state() private _focusDate?: string; - public async showDialog(params: DatePickerDialogParams): Promise { - // app-datepicker has a bug, that it removes its handlers when disconnected, but doesn't add them back when reconnected. - // So we need to wait for the next render to make sure the element is removed and re-created so the handlers are added. - await nextRender(); - this._params = params; - this._value = params.value; - this._open = true; - } + public connectedCallback() { + super.connectedCallback(); - public closeDialog() { - this._open = false; - } + if (this.params) { + const date = this.params.value + ? new Date(`${this.params.value.split("T")[0]}T00:00:00`) + : new Date(); - private _dialogClosed() { - this._params = undefined; - fireEvent(this, "dialog-closed", { dialog: this.localName }); + this._pickerYear = formatDateYear(date, this.locale, this.hassConfig); + this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig); + + this._value = this.params.value + ? { + year: this._pickerYear, + title: formatDateShort(date, this.locale, this.hassConfig), + dateString: this.params.value.substring(0, 10), + } + : undefined; + } } render() { - if (!this._params) { + if (!this.params) { return nothing; } return html` - - -
- ${this._params.canClear - ? html` - ${this.hass.localize("ui.dialogs.date-picker.clear")} - ` - : nothing} - - ${this.hass.localize("ui.dialogs.date-picker.today")} - -
- + > + ` + : nothing} + + + +
+ ${this._pickerMonth} ${this._pickerYear} + +
+ + +
- ${this.hass.localize("ui.common.cancel")} + ${this.localize("ui.common.cancel")} - ${this.hass.localize("ui.common.ok")} + ${this.localize("ui.common.ok")}
`; } - private _valueChanged(ev: CustomEvent) { - this._value = ev.detail.value; + private _valueChanged(ev: Event) { + const dateElement = ev.target as CalendarDate; + if (dateElement.value) { + this._updateValue(dateElement.value); + } + } + + private _updateValue(value?: string, setFocusDay = false) { + const date = value + ? new Date(`${value.split("T")[0]}T00:00:00`) + : new Date(); + this._value = { + year: formatDateYear(date, this.locale, this.hassConfig), + title: formatDateShort(date, this.locale, this.hassConfig), + dateString: value || date.toISOString().substring(0, 10), + }; + + if (setFocusDay) { + this._focusDate = this._value.dateString; + this._pickerMonth = formatDateMonth(date, this.locale, this.hassConfig); + this._pickerYear = formatDateYear(date, this.locale, this.hassConfig); + } + } + + 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 _clear() { - this._params?.onChange(undefined); + this.params?.onChange(undefined); this.closeDialog(); } private _setToday() { - const today = new Date(); - this._value = format(today, "yyyy-MM-dd"); + this._updateValue(undefined, true); } private _setValue() { @@ -120,50 +192,85 @@ export class HaDialogDatePicker extends LitElement { // without changing the date, should return todays date, not undefined. this._setToday(); } - this._params?.onChange(this._value!); + this.params?.onChange(this._value?.dateString); this.closeDialog(); } - static styles = [ - haStyleDialog, - css` - ha-dialog { - --dialog-content-padding: 0; - } - .bottom-actions { - display: flex; - gap: var(--ha-space-4); - justify-content: center; - align-items: center; - width: 100%; - margin-bottom: var(--ha-space-1); - } - app-datepicker { - display: block; - margin-inline: auto; - --app-datepicker-accent-color: var(--primary-color); - --app-datepicker-bg-color: transparent; - --app-datepicker-color: var(--primary-text-color); - --app-datepicker-disabled-day-color: var(--disabled-text-color); - --app-datepicker-focused-day-color: var(--text-primary-color); - --app-datepicker-focused-year-bg-color: var(--primary-color); - --app-datepicker-selector-color: var(--secondary-text-color); - --app-datepicker-separator-color: var(--divider-color); - --app-datepicker-weekday-color: var(--secondary-text-color); - } - app-datepicker::part(calendar-day):focus { - outline: none; - } - app-datepicker::part(body) { - direction: ltr; - } - @media all and (max-width: 450px), all and (max-height: 500px) { - app-datepicker { - width: 100%; - } - } - `, - ]; + 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; + } + `; } declare global { diff --git a/src/translations/en.json b/src/translations/en.json index 5f30d5f812..51dd8fc872 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1517,6 +1517,7 @@ "use_original": "Use original" }, "date-picker": { + "title": "Select date", "today": "Today", "clear": "Clear" }, diff --git a/yarn.lock b/yarn.lock index 7186c1463d..f69810c32e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3458,13 +3458,6 @@ __metadata: languageName: node linkType: hard -"@reallyland/esm@npm:^0.0.1": - version: 0.0.1 - resolution: "@reallyland/esm@npm:0.0.1" - checksum: 10/c06c4d38663e128466485d65dc006ee5bcacde46ae6acf3a846ca8047f9ebef107a3f78b82cb2f6ba65aaea9d4b1dad52642626b6fff2e024ee855e3b01af726 - languageName: node - linkType: hard - "@replit/codemirror-indentation-markers@npm:6.5.3": version: 6.5.3 resolution: "@replit/codemirror-indentation-markers@npm:6.5.3" @@ -4548,15 +4541,6 @@ __metadata: languageName: node linkType: hard -"@types/lodash-es@npm:^4.17.4": - version: 4.17.12 - resolution: "@types/lodash-es@npm:4.17.12" - dependencies: - "@types/lodash": "npm:*" - checksum: 10/56b9a433348b11c31051c6fa9028540a033a08fb80b400c589d740446c19444d73b217cf1471d4036448ef686a83e8cf2a35d1fadcb3f2105f26701f94aebb07 - languageName: node - linkType: hard - "@types/lodash.merge@npm:4.6.9": version: 4.6.9 resolution: "@types/lodash.merge@npm:4.6.9" @@ -4644,20 +4628,6 @@ __metadata: languageName: node linkType: hard -"@types/parse5@npm:^6.0.0": - version: 6.0.3 - resolution: "@types/parse5@npm:6.0.3" - checksum: 10/834d40c9b1a8a99a9574b0b3f6629cf48adcff2eda01a35d701f1de5dcf46ce24223684647890aba9f985d6c801b233f878168683de0ae425940403c383fba8f - languageName: node - linkType: hard - -"@types/prismjs@npm:^1.16.5": - version: 1.26.5 - resolution: "@types/prismjs@npm:1.26.5" - checksum: 10/617099479db9550119d0f84272dc79d64b2cf3e0d7a17167fe740d55fdf0f155697d935409464392d164e62080c2c88d649cf4bc4fdd30a87127337536657277 - languageName: node - linkType: hard - "@types/qrcode@npm:1.5.6": version: 1.5.6 resolution: "@types/qrcode@npm:1.5.6" @@ -5454,18 +5424,6 @@ __metadata: languageName: node linkType: hard -"app-datepicker@npm:5.1.1": - version: 5.1.1 - resolution: "app-datepicker@npm:5.1.1" - dependencies: - "@material/mwc-button": "npm:^0.25.3" - lit: "npm:^2.2.1" - nodemod: "npm:2.8.4" - tslib: "npm:^2.3.1" - checksum: 10/0c16a597e18ce0f3ce20b4cd0aab9f29f09324ac79642e5e62e12f2fb7993e649352f9544d74a640029619a29b135bf0b105ed7336e097b5ad6e6b6c78b23e6a - languageName: node - linkType: hard - "arch@npm:^2.2.0": version: 2.2.0 resolution: "arch@npm:2.2.0" @@ -5689,6 +5647,13 @@ __metadata: languageName: node linkType: hard +"atomico@npm:^1.79.2": + version: 1.79.2 + resolution: "atomico@npm:1.79.2" + checksum: 10/89cdb46cccab4e156d05464b1b1d4ba755868b75d69973c94c251798ffced6a2ed1cba80dfeb128693bd36ab836c0105d99801ef8ecdc162d131b0be029ba9a2 + languageName: node + linkType: hard + "available-typed-arrays@npm:^1.0.7": version: 1.0.7 resolution: "available-typed-arrays@npm:1.0.7" @@ -6172,6 +6137,15 @@ __metadata: languageName: node linkType: hard +"cally@npm:0.9.2": + version: 0.9.2 + resolution: "cally@npm:0.9.2" + dependencies: + atomico: "npm:^1.79.2" + checksum: 10/eeaff4d6f86a41247b51e432b1117ffbf967bd51c2e849d64ccfb7c477dde72ede88187c4497acd5e2cf1837f069832323411bc9505f7b6e3cd5f5b75cf7988e + languageName: node + linkType: hard + "camel-case@npm:^4.1.1, camel-case@npm:^4.1.2": version: 4.1.2 resolution: "camel-case@npm:4.1.2" @@ -8913,11 +8887,11 @@ __metadata: "@vue/web-component-wrapper": "npm:1.3.0" "@webcomponents/scoped-custom-element-registry": "npm:0.0.10" "@webcomponents/webcomponentsjs": "npm:2.8.0" - app-datepicker: "npm:5.1.1" babel-loader: "npm:10.1.1" babel-plugin-template-html-minifier: "npm:4.1.0" barcode-detector: "npm:3.1.1" browserslist-useragent-regexp: "npm:4.1.3" + cally: "npm:0.9.2" color-name: "npm:2.1.0" comlink: "npm:4.4.2" core-js: "npm:3.48.0" @@ -10467,17 +10441,6 @@ __metadata: languageName: node linkType: hard -"lit-ntml@npm:^2.18.2": - version: 2.20.0 - resolution: "lit-ntml@npm:2.20.0" - dependencies: - "@reallyland/esm": "npm:^0.0.1" - parse5: "npm:^6.0.1" - tslib: "npm:^2.0.2" - checksum: 10/db24a9a3914896d24bc7a51502445cbf508a53968729faf3dfb86909b6bc32ebf7348fac873ec3e3fc6fbf158a12d655684c9ed78599b425d8a1349d42fa2bef - languageName: node - linkType: hard - "lit@npm:3.3.2": version: 3.3.2 resolution: "lit@npm:3.3.2" @@ -11119,20 +11082,6 @@ __metadata: languageName: node linkType: hard -"nodemod@npm:2.8.4": - version: 2.8.4 - resolution: "nodemod@npm:2.8.4" - dependencies: - "@types/lodash-es": "npm:^4.17.4" - "@types/parse5": "npm:^6.0.0" - "@types/prismjs": "npm:^1.16.5" - lit-ntml: "npm:^2.18.2" - normalize-diacritics: "npm:^2.13.2" - tslib: "npm:^2.1.0" - checksum: 10/3cccce96dea824791f30648a7d5064a4d4791448ce20b1ffdcae394b1009bca7c939efebe3ee00d3b2ba6311760318a364d4cc01f0f6663ada3eeb4a747d0777 - languageName: node - linkType: hard - "nopt@npm:^9.0.0": version: 9.0.0 resolution: "nopt@npm:9.0.0" @@ -11144,15 +11093,6 @@ __metadata: languageName: node linkType: hard -"normalize-diacritics@npm:^2.13.2": - version: 2.14.0 - resolution: "normalize-diacritics@npm:2.14.0" - dependencies: - tslib: "npm:^2.1.0" - checksum: 10/1c2297eb1821e728793e935f744ce12a5969b05db90300e84f06a418e998ce06184d172c608c532d290ae6af2ea2f71ad561e4340ea7473f6329b417deca90bd - languageName: node - linkType: hard - "normalize-path@npm:3.0.0, normalize-path@npm:^3.0.0, normalize-path@npm:~3.0.0": version: 3.0.0 resolution: "normalize-path@npm:3.0.0"