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
+
+
+
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;
+ }
+}