diff --git a/src/components/ha-numeric-arrow-input.ts b/src/components/ha-numeric-arrow-input.ts new file mode 100644 index 000000000000..9c5c5a4ac0e0 --- /dev/null +++ b/src/components/ha-numeric-arrow-input.ts @@ -0,0 +1,121 @@ +import { css, html, LitElement } from "lit"; +import { customElement, property, query } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { mdiMinus, mdiPlus } from "@mdi/js"; +import { fireEvent } from "../common/dom/fire_event"; +import type { HaIconButton } from "./ha-icon-button"; +import "./ha-textfield"; +import "./ha-icon-button"; +import { clampValue } from "../data/number"; + +@customElement("ha-numeric-arrow-input") +export class HaNumericArrowInput extends LitElement { + @property({ attribute: false }) public disabled = false; + + @property({ attribute: false }) public required = false; + + @property({ attribute: false }) public min?: number; + + @property({ attribute: false }) public max?: number; + + @property({ attribute: false }) public step?: number; + + @property({ attribute: false }) public padStart?: number; + + @property({ attribute: false }) public labelUp = "Increase"; + + @property({ attribute: false }) public labelDown = "Decrease"; + + @property({ attribute: false }) public value = 0; + + @query("ha-icon-button[data-direction='up']") + private _upButton!: HaIconButton; + + @query("ha-icon-button[data-direction='down']") + private _downButton!: HaIconButton; + + private _paddedValue = memoizeOne((value: number, padStart?: number) => + value.toString().padStart(padStart ?? 0, "0") + ); + + render() { + return html`
+ + ${this._paddedValue(this.value, this.padStart)} + +
`; + } + + private _keyDown(ev: KeyboardEvent) { + if (ev.key === "ArrowUp") { + this._upButton.focus(); + this._up(); + } + if (ev.key === "ArrowDown") { + this._downButton.focus(); + this._down(); + } + } + + private _up() { + const newValue = this.value + (this.step ?? 1); + fireEvent( + this, + "value-changed", + clampValue({ value: newValue, min: this.min, max: this.max }) + ); + } + + private _down() { + const newValue = this.value - (this.step ?? 1); + fireEvent( + this, + "value-changed", + clampValue({ value: newValue, min: this.min, max: this.max }) + ); + } + + static styles = css` + .numeric-arrow-input-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + } + + .numeric-arrow-input-container ha-icon-button { + --mdc-icon-button-size: 24px; + color: var(--secondary-text-color); + } + + .numeric-arrow-input-value { + color: var(--primary-text-color); + font-size: 16px; + font-weight: 500; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-numeric-arrow-input": HaNumericArrowInput; + } +} diff --git a/src/components/ha-time-picker.ts b/src/components/ha-time-picker.ts new file mode 100644 index 000000000000..3c7254252dc2 --- /dev/null +++ b/src/components/ha-time-picker.ts @@ -0,0 +1,281 @@ +import type { PropertyValues } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { clampValue } from "../data/number"; +import { useAmPm } from "../common/datetime/use_am_pm"; +import { fireEvent } from "../common/dom/fire_event"; +import type { FrontendLocaleData } from "../data/translation"; +import type { HomeAssistant } from "../types"; +import type { ClampedValue } from "../data/number"; +import "./ha-base-time-input"; +import "./ha-button"; +import "./ha-numeric-arrow-input"; + +@customElement("ha-time-picker") +export class HaTimePicker extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public locale!: FrontendLocaleData; + + @property({ attribute: false }) public value?: string; + + @property({ attribute: false }) public disabled = false; + + @property({ attribute: false }) public required = false; + + @property({ attribute: false }) public enableSeconds = false; + + @state() private _hours = 0; + + @state() private _minutes = 0; + + @state() private _seconds = 0; + + @state() private _useAmPm = false; + + @state() private _isPm = false; + + protected firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + this._useAmPm = useAmPm(this.locale); + + let hours = NaN; + let minutes = NaN; + let seconds = NaN; + let isPm = false; + + if (this.value) { + const parts = this.value?.split(":") || []; + minutes = parts[1] ? Number(parts[1]) : 0; + seconds = parts[2] ? Number(parts[2]) : 0; + const hour24 = parts[0] ? Number(parts[0]) : 0; + + if (this._useAmPm) { + if (hour24 === 0) { + hours = 12; + isPm = false; + } else if (hour24 < 12) { + hours = hour24; + isPm = false; + } else if (hour24 === 12) { + hours = 12; + isPm = true; + } else { + hours = hour24 - 12; + isPm = true; + } + } else { + hours = hour24; + } + } + + this._hours = hours; + this._minutes = minutes; + this._seconds = seconds; + this._isPm = isPm; + } + + protected render() { + return html`
+ + : + + ${this.enableSeconds + ? html` + : + + ` + : nothing} + ${this._useAmPm + ? html` + + ${this._isPm ? "PM" : "AM"} + + ` + : nothing} +
`; + } + + protected updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + + if (changedProperties.has("_hours")) { + this._timeUpdated(); + } + + if (changedProperties.has("_minutes")) { + this._timeUpdated(); + } + + if (changedProperties.has("_seconds")) { + this._timeUpdated(); + } + + if (changedProperties.has("_useAmPm")) { + this._timeUpdated(); + } + + if (changedProperties.has("_isPm")) { + this._timeUpdated(); + } + } + + private _hoursChanged(ev: CustomEvent) { + ev.stopPropagation?.(); + this._hours = ev.detail.value; + } + + private _minutesChanged(ev: CustomEvent) { + ev.stopPropagation?.(); + this._minutes = ev.detail.value; + if (ev.detail.clamped) { + if (ev.detail.value === 0) { + this._hoursChanged({ + detail: clampValue({ + value: this._hours - 1, + min: this._useAmPm ? 1 : 0, + max: this._useAmPm ? 12 : 23, + }), + } as CustomEvent); + this._minutes = 59; + } + + if (ev.detail.value === 59) { + this._hoursChanged({ + detail: clampValue({ + value: this._hours + 1, + min: this._useAmPm ? 1 : 0, + max: this._useAmPm ? 12 : 23, + }), + } as CustomEvent); + const hourMax = this._useAmPm ? 12 : 23; + if (this._hours < hourMax) { + this._minutes = 0; + } + } + } + } + + private _secondsChanged(ev: CustomEvent) { + ev.stopPropagation?.(); + this._seconds = ev.detail.value; + if (ev.detail.clamped) { + if (ev.detail.value === 0) { + this._minutesChanged({ + detail: clampValue({ value: this._minutes - 1, min: 0, max: 59 }), + } as CustomEvent); + this._seconds = 59; + } + + if (ev.detail.value === 59) { + this._minutesChanged({ + detail: clampValue({ value: this._minutes + 1, min: 0, max: 59 }), + } as CustomEvent); + const hourMax = this._useAmPm ? 12 : 23; + if (!(this._hours === hourMax && this._minutes === 59)) { + this._seconds = 0; + } + } + } + } + + private _toggleAmPm() { + this._isPm = !this._isPm; + } + + private _timeUpdated() { + let hour24 = this._hours; + + if (this._useAmPm) { + if (this._hours === 12) { + hour24 = this._isPm ? 12 : 0; + } else { + hour24 = this._isPm ? this._hours + 12 : this._hours; + } + } + + const timeParts = [ + hour24.toString().padStart(2, "0"), + this._minutes.toString().padStart(2, "0"), + this._seconds.toString().padStart(2, "0"), + ]; + + const time = timeParts.join(":"); + if (time === this.value) { + return; + } + this.value = time; + fireEvent(this, "change"); + fireEvent(this, "value-changed", { value: time }); + } + + static styles = css` + .time-picker-container { + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + } + + .time-picker-separator { + color: var(--primary-text-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-time-picker": HaTimePicker; + } +} diff --git a/src/data/number.ts b/src/data/number.ts index af5de70d1b54..379c53099941 100644 --- a/src/data/number.ts +++ b/src/data/number.ts @@ -12,3 +12,35 @@ export const getNumberDeviceClassConvertibleUnits = ( type: "number/device_class_convertible_units", device_class: deviceClass, }); + +export interface ClampedValue { + clamped: boolean; + value: number; +} + +/** + * Clamp a value between a minimum and maximum value + * @param value - The value to clamp + * @param min - The minimum value + * @param max - The maximum value + * @returns The clamped value + */ +export const clampValue = ({ + value, + min, + max, +}: { + value: number; + min?: number; + max?: number; +}): ClampedValue => { + if (max !== undefined && value > max) { + return { clamped: true, value: max }; + } + + if (min !== undefined && value < min) { + return { clamped: true, value: min }; + } + + return { clamped: false, value }; +}; diff --git a/src/fake_data/demo_panels.ts b/src/fake_data/demo_panels.ts index ec3154041939..c36412166b66 100644 --- a/src/fake_data/demo_panels.ts +++ b/src/fake_data/demo_panels.ts @@ -79,6 +79,13 @@ export const demoPanels: Panels = { config: null, url_path: "energy", }, + "time-picker": { + component_name: "time-picker", + icon: "hass:clock-outline", + title: "time_picker", + config: null, + url_path: "time-picker", + }, // config: { // component_name: "config", // icon: "hass:cog", diff --git a/src/layouts/partial-panel-resolver.ts b/src/layouts/partial-panel-resolver.ts index 1a8b665e483a..28637eb105bd 100644 --- a/src/layouts/partial-panel-resolver.ts +++ b/src/layouts/partial-panel-resolver.ts @@ -30,6 +30,7 @@ const COMPONENTS = { my: () => import("../panels/my/ha-panel-my"), profile: () => import("../panels/profile/ha-panel-profile"), todo: () => import("../panels/todo/ha-panel-todo"), + "time-picker": () => import("../panels/time-picker/ha-panel-time-picker"), "media-browser": () => import("../panels/media-browser/ha-panel-media-browser"), }; diff --git a/src/panels/developer-tools/developer-tools-router.ts b/src/panels/developer-tools/developer-tools-router.ts index 9b7f8b5fc7fa..f0e1b37c3a42 100644 --- a/src/panels/developer-tools/developer-tools-router.ts +++ b/src/panels/developer-tools/developer-tools-router.ts @@ -54,6 +54,10 @@ class DeveloperToolsRouter extends HassRouterPage { tag: "developer-tools-debug", load: () => import("./debug/developer-tools-debug"), }, + "time-picker": { + tag: "developer-tools-time-picker", + load: () => import("../time-picker/ha-panel-time-picker"), + }, }, }; diff --git a/src/panels/developer-tools/ha-panel-developer-tools.ts b/src/panels/developer-tools/ha-panel-developer-tools.ts index 6bf21123930f..1d91c07af9fd 100644 --- a/src/panels/developer-tools/ha-panel-developer-tools.ts +++ b/src/panels/developer-tools/ha-panel-developer-tools.ts @@ -80,6 +80,13 @@ class PanelDeveloperTools extends LitElement { Assist + + Time Picker + +

Time picker demo

+

+ This page demonstrates the ha-time-picker component with various + configurations and use cases. +

+ + +
+

Time picker

+
+
+ +
${this._timeValue}
+
+

Current value: ${this._timeValue}

+
+
+ +
+

Time picker with seconds

+
+
+ +
${this._timeValue2}
+
+

Current value: ${this._timeValue2}

+
+
+ `; + } + + private _onTimeChanged(ev: CustomEvent) { + this._timeValue = ev.detail.value; + } + + private _onTime2Changed(ev: CustomEvent) { + this._timeValue2 = ev.detail.value; + } +} + +declare global { + interface HTMLElementTagNameMap { + "developer-tools-time-picker": DeveloperToolsTimePicker; + } +}