diff --git a/gallery/src/pages/components/ha-control-number-buttons.markdown b/gallery/src/pages/components/ha-control-number-buttons.markdown
new file mode 100644
index 0000000000..1c06f0ade3
--- /dev/null
+++ b/gallery/src/pages/components/ha-control-number-buttons.markdown
@@ -0,0 +1,3 @@
+---
+title: Control Number Buttons
+---
diff --git a/gallery/src/pages/components/ha-control-number-buttons.ts b/gallery/src/pages/components/ha-control-number-buttons.ts
new file mode 100644
index 0000000000..8afa9ee487
--- /dev/null
+++ b/gallery/src/pages/components/ha-control-number-buttons.ts
@@ -0,0 +1,100 @@
+import { LitElement, TemplateResult, css, html } from "lit";
+import { customElement, state } from "lit/decorators";
+import "../../../../src/components/ha-card";
+import "../../../../src/components/ha-control-number-buttons";
+import { repeat } from "lit/directives/repeat";
+import { ifDefined } from "lit/directives/if-defined";
+
+const buttons: {
+ id: string;
+ label: string;
+ min?: number;
+ max?: number;
+ step?: number;
+ class?: string;
+}[] = [
+ {
+ id: "basic",
+ label: "Basic",
+ },
+ {
+ id: "min_max_step",
+ label: "With min/max and step",
+ min: 5,
+ max: 25,
+ step: 0.5,
+ },
+ {
+ id: "custom",
+ label: "Custom",
+ class: "custom",
+ },
+];
+
+@customElement("demo-components-ha-control-number-buttons")
+export class DemoHarControlNumberButtons extends LitElement {
+ @state() value = 5;
+
+ private _valueChanged(ev) {
+ this.value = ev.detail.value;
+ }
+
+ protected render(): TemplateResult {
+ return html`
+ ${repeat(buttons, (button) => {
+ const { id, label, ...config } = button;
+ return html`
+
+
+
+
Config: ${JSON.stringify(config)}
+
+
+
+
+ `;
+ })}
+ `;
+ }
+
+ static get styles() {
+ return css`
+ ha-card {
+ max-width: 600px;
+ margin: 24px auto;
+ }
+ pre {
+ margin-top: 0;
+ margin-bottom: 8px;
+ }
+ p {
+ margin: 0;
+ }
+ label {
+ font-weight: 600;
+ }
+ .custom {
+ color: #2196f3;
+ --control-number-buttons-color: #2196f3;
+ --control-number-buttons-background-color: #2196f3;
+ --control-number-buttons-background-opacity: 0.1;
+ --control-number-buttons-thickness: 100px;
+ --control-number-buttons-border-radius: 24px;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "demo-components-ha-control-number-buttons": DemoHarControlNumberButtons;
+ }
+}
diff --git a/src/common/number/clamp.ts b/src/common/number/clamp.ts
index 5591885f2e..e5d6bbcb63 100644
--- a/src/common/number/clamp.ts
+++ b/src/common/number/clamp.ts
@@ -4,7 +4,7 @@ export const clamp = (value: number, min: number, max: number) =>
// Variant that only applies the clamping to a border if the border is defined
export const conditionalClamp = (value: number, min?: number, max?: number) => {
let result: number;
- result = min ? Math.max(value, min) : value;
- result = max ? Math.min(result, max) : result;
+ result = min != null ? Math.max(value, min) : value;
+ result = max != null ? Math.min(result, max) : result;
return result;
};
diff --git a/src/components/ha-control-number-buttons.ts b/src/components/ha-control-number-buttons.ts
new file mode 100644
index 0000000000..95c16f5fa2
--- /dev/null
+++ b/src/components/ha-control-number-buttons.ts
@@ -0,0 +1,258 @@
+import { mdiMinus, mdiPlus } from "@mdi/js";
+import { CSSResultGroup, LitElement, TemplateResult, css, html } from "lit";
+import { customElement, property, query } from "lit/decorators";
+import { ifDefined } from "lit/directives/if-defined";
+import { conditionalClamp } from "../common/number/clamp";
+import { formatNumber } from "../common/number/format_number";
+import { FrontendLocaleData } from "../data/translation";
+import { fireEvent } from "../common/dom/fire_event";
+
+const A11Y_KEY_CODES = new Set([
+ "ArrowRight",
+ "ArrowUp",
+ "ArrowLeft",
+ "ArrowDown",
+ "PageUp",
+ "PageDown",
+ "Home",
+ "End",
+]);
+
+@customElement("ha-control-number-buttons")
+export class HaControlNumberButton extends LitElement {
+ @property({ attribute: false }) public locale?: FrontendLocaleData;
+
+ @property({ type: Boolean, reflect: true }) disabled = false;
+
+ @property() public label?: string;
+
+ @property({ type: Number }) public step?: number;
+
+ @property({ type: Number }) public value?: number;
+
+ @property({ type: Number }) public min?: number;
+
+ @property({ type: Number }) public max?: number;
+
+ @property({ attribute: "false" })
+ public formatOptions: Intl.NumberFormatOptions = {};
+
+ @query("#input") _input!: HTMLDivElement;
+
+ private boundedValue(value: number) {
+ const clamped = conditionalClamp(value, this.min, this.max);
+ return Math.round(clamped / this._step) * this._step;
+ }
+
+ private get _step() {
+ return this.step ?? 1;
+ }
+
+ private get _value() {
+ return this.value ?? 0;
+ }
+
+ private get _tenPercentStep() {
+ if (this.max == null || this.min == null) return this._step;
+ const range = this.max - this.min / 10;
+
+ if (range <= this._step) return this._step;
+ return Math.max(range / 10);
+ }
+
+ private _handlePlusButton() {
+ this._increment();
+ fireEvent(this, "value-changed", { value: this.value });
+ this._input.focus();
+ }
+
+ private _handleMinusButton() {
+ this._decrement();
+ fireEvent(this, "value-changed", { value: this.value });
+ this._input.focus();
+ }
+
+ private _increment() {
+ this.value = this.boundedValue(this._value + this._step);
+ }
+
+ private _decrement() {
+ this.value = this.boundedValue(this._value - this._step);
+ }
+
+ _handleKeyDown(e: KeyboardEvent) {
+ if (!A11Y_KEY_CODES.has(e.code)) return;
+ e.preventDefault();
+ switch (e.code) {
+ case "ArrowRight":
+ case "ArrowUp":
+ this._increment();
+ break;
+ case "ArrowLeft":
+ case "ArrowDown":
+ this._decrement();
+ break;
+ case "PageUp":
+ this.value = this.boundedValue(this._value + this._tenPercentStep);
+ break;
+ case "PageDown":
+ this.value = this.boundedValue(this._value - this._tenPercentStep);
+ break;
+ case "Home":
+ if (this.min != null) {
+ this.value = this.min;
+ }
+ break;
+ case "End":
+ if (this.max != null) {
+ this.value = this.max;
+ }
+ break;
+ }
+ fireEvent(this, "value-changed", { value: this.value });
+ }
+
+ protected render(): TemplateResult {
+ const displayedValue =
+ this.value != null
+ ? formatNumber(this.value, this.locale, this.formatOptions)
+ : "-";
+
+ return html`
+
+
+ ${displayedValue}
+
+
+
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ :host {
+ display: block;
+ --control-number-buttons-focus-color: var(--primary-color);
+ --control-number-buttons-background-color: var(--disabled-color);
+ --control-number-buttons-background-opacity: 0.2;
+ --control-number-buttons-border-radius: 10px;
+ --mdc-icon-size: 16px;
+ height: 40px;
+ width: 200px;
+ color: var(--primary-text-color);
+ -webkit-tap-highlight-color: transparent;
+ font-style: normal;
+ font-weight: 500;
+ transition: color 180ms ease-in-out;
+ }
+ :host([disabled]) {
+ color: var(--disabled-color);
+ }
+ .container {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ }
+ .value {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ position: relative;
+ width: 100%;
+ height: 100%;
+ padding: 0 44px;
+ border-radius: var(--control-number-buttons-border-radius);
+ padding: 0;
+ margin: 0;
+ box-sizing: border-box;
+ line-height: 0;
+ overflow: hidden;
+ /* For safari border-radius overflow */
+ z-index: 0;
+ font-size: inherit;
+ color: inherit;
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ outline: none;
+ }
+ .value::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 100%;
+ background-color: var(--control-number-buttons-background-color);
+ transition:
+ background-color 180ms ease-in-out,
+ opacity 180ms ease-in-out;
+ opacity: var(--control-number-buttons-background-opacity);
+ }
+ .value:focus-visible {
+ box-shadow: 0 0 0 2px var(--control-number-buttons-focus-color);
+ }
+ .button {
+ color: inherit;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ padding: 0;
+ width: 35px;
+ height: 40px;
+ border: none;
+ background: none;
+ cursor: pointer;
+ outline: none;
+ }
+ .button[disabled] {
+ opacity: 0.4;
+ pointer-events: none;
+ }
+ .button.minus {
+ left: 0;
+ }
+ .button.plus {
+ right: 0;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-control-number-buttons": HaControlNumberButton;
+ }
+}
diff --git a/src/dialogs/more-info/components/climate/ha-more-info-climate-temperature.ts b/src/dialogs/more-info/components/climate/ha-more-info-climate-temperature.ts
index 1262bb168d..9207bda5e9 100644
--- a/src/dialogs/more-info/components/climate/ha-more-info-climate-temperature.ts
+++ b/src/dialogs/more-info/components/climate/ha-more-info-climate-temperature.ts
@@ -10,6 +10,7 @@ import {
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
+import { UNIT_F } from "../../../../common/const";
import { computeAttributeValueDisplay } from "../../../../common/entity/compute_attribute_display";
import { stateActive } from "../../../../common/entity/state_active";
import { stateColorCss } from "../../../../common/entity/state_color";
@@ -67,7 +68,7 @@ export class HaMoreInfoClimateTemperature extends LitElement {
private get _step() {
return (
this.stateObj.attributes.target_temp_step ||
- (this.hass.config.unit_system.temperature.indexOf("F") === -1 ? 0.5 : 1)
+ (this.hass.config.unit_system.temperature === UNIT_F ? 1 : 0.5)
);
}
diff --git a/src/dialogs/more-info/components/water_heater/ha-more-info-water_heater-temperature.ts b/src/dialogs/more-info/components/water_heater/ha-more-info-water_heater-temperature.ts
index 8977e6e000..f23177a596 100644
--- a/src/dialogs/more-info/components/water_heater/ha-more-info-water_heater-temperature.ts
+++ b/src/dialogs/more-info/components/water_heater/ha-more-info-water_heater-temperature.ts
@@ -9,6 +9,7 @@ import {
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
+import { UNIT_F } from "../../../../common/const";
import { stateActive } from "../../../../common/entity/state_active";
import { stateColorCss } from "../../../../common/entity/state_color";
import { supportsFeature } from "../../../../common/entity/supports-feature";
@@ -44,7 +45,7 @@ export class HaMoreInfoWaterHeaterTemperature extends LitElement {
private get _step() {
return (
this.stateObj.attributes.target_temp_step ||
- (this.hass.config.unit_system.temperature.indexOf("F") === -1 ? 0.5 : 1)
+ (this.hass.config.unit_system.temperature === UNIT_F ? 1 : 0.5)
);
}
diff --git a/src/panels/lovelace/create-element/create-tile-feature-element.ts b/src/panels/lovelace/create-element/create-tile-feature-element.ts
index f85e660378..41f6e2b699 100644
--- a/src/panels/lovelace/create-element/create-tile-feature-element.ts
+++ b/src/panels/lovelace/create-element/create-tile-feature-element.ts
@@ -1,14 +1,15 @@
import "../tile-features/hui-alarm-modes-tile-feature";
import "../tile-features/hui-climate-hvac-modes-tile-feature";
+import "../tile-features/hui-target-temperature-tile-feature";
import "../tile-features/hui-cover-open-close-tile-feature";
import "../tile-features/hui-cover-position-tile-feature";
import "../tile-features/hui-cover-tilt-position-tile-feature";
import "../tile-features/hui-cover-tilt-tile-feature";
import "../tile-features/hui-fan-speed-tile-feature";
+import "../tile-features/hui-lawn-mower-commands-tile-feature";
import "../tile-features/hui-light-brightness-tile-feature";
import "../tile-features/hui-light-color-temp-tile-feature";
import "../tile-features/hui-vacuum-commands-tile-feature";
-import "../tile-features/hui-lawn-mower-commands-tile-feature";
import "../tile-features/hui-water-heater-operation-modes-tile-feature";
import { LovelaceTileFeatureConfig } from "../tile-features/types";
import {
@@ -17,17 +18,18 @@ import {
} from "./create-element-base";
const TYPES: Set = new Set([
- "cover-open-close",
- "cover-position",
- "cover-tilt",
- "cover-tilt-position",
- "light-brightness",
- "light-color-temp",
- "vacuum-commands",
- "lawn-mower-commands",
- "fan-speed",
"alarm-modes",
"climate-hvac-modes",
+ "cover-open-close",
+ "cover-position",
+ "cover-tilt-position",
+ "cover-tilt",
+ "fan-speed",
+ "lawn-mower-commands",
+ "light-brightness",
+ "light-color-temp",
+ "target-temperature",
+ "vacuum-commands",
"water-heater-operation-modes",
]);
diff --git a/src/panels/lovelace/editor/config-elements/hui-tile-card-features-editor.ts b/src/panels/lovelace/editor/config-elements/hui-tile-card-features-editor.ts
index 6a38b0a672..8f20d3afa0 100644
--- a/src/panels/lovelace/editor/config-elements/hui-tile-card-features-editor.ts
+++ b/src/panels/lovelace/editor/config-elements/hui-tile-card-features-editor.ts
@@ -37,6 +37,7 @@ import { supportsLightColorTempTileFeature } from "../../tile-features/hui-light
import { supportsVacuumCommandTileFeature } from "../../tile-features/hui-vacuum-commands-tile-feature";
import { supportsWaterHeaterOperationModesTileFeature } from "../../tile-features/hui-water-heater-operation-modes-tile-feature";
import { LovelaceTileFeatureConfig } from "../../tile-features/types";
+import { supportsTargetTemperatureTileFeature } from "../../tile-features/hui-target-temperature-tile-feature";
type FeatureType = LovelaceTileFeatureConfig["type"];
type SupportsFeature = (stateObj: HassEntity) => boolean;
@@ -44,6 +45,7 @@ type SupportsFeature = (stateObj: HassEntity) => boolean;
const FEATURE_TYPES: FeatureType[] = [
"alarm-modes",
"climate-hvac-modes",
+ "target-temperature",
"cover-open-close",
"cover-position",
"cover-tilt-position",
@@ -76,6 +78,7 @@ const SUPPORTS_FEATURE_TYPES: Record =
"lawn-mower-commands": supportsLawnMowerCommandTileFeature,
"light-brightness": supportsLightBrightnessTileFeature,
"light-color-temp": supportsLightColorTempTileFeature,
+ "target-temperature": supportsTargetTemperatureTileFeature,
"vacuum-commands": supportsVacuumCommandTileFeature,
"water-heater-operation-modes":
supportsWaterHeaterOperationModesTileFeature,
@@ -151,8 +154,10 @@ export class HuiTileCardFeaturesEditor extends LitElement {
const customFeatureEntry = CUSTOM_FEATURE_ENTRIES[customType];
return customFeatureEntry?.name || type;
}
- return this.hass!.localize(
- `ui.panel.lovelace.editor.card.tile.features.types.${type}.label`
+ return (
+ this.hass!.localize(
+ `ui.panel.lovelace.editor.card.tile.features.types.${type}.label`
+ ) || type
);
}
diff --git a/src/panels/lovelace/tile-features/hui-target-temperature-tile-feature.ts b/src/panels/lovelace/tile-features/hui-target-temperature-tile-feature.ts
new file mode 100644
index 0000000000..34fbf1ac83
--- /dev/null
+++ b/src/panels/lovelace/tile-features/hui-target-temperature-tile-feature.ts
@@ -0,0 +1,254 @@
+import { HassEntity } from "home-assistant-js-websocket";
+import { css, html, LitElement, nothing, PropertyValues } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { styleMap } from "lit/directives/style-map";
+import { UNIT_F } from "../../../common/const";
+import { computeDomain } from "../../../common/entity/compute_domain";
+import { computeStateDomain } from "../../../common/entity/compute_state_domain";
+import { stateColorCss } from "../../../common/entity/state_color";
+import { supportsFeature } from "../../../common/entity/supports-feature";
+import { debounce } from "../../../common/util/debounce";
+import "../../../components/ha-control-button-group";
+import "../../../components/ha-control-number-buttons";
+import { ClimateEntity, ClimateEntityFeature } from "../../../data/climate";
+import { UNAVAILABLE } from "../../../data/entity";
+import {
+ WaterHeaterEntity,
+ WaterHeaterEntityFeature,
+} from "../../../data/water_heater";
+import { HomeAssistant } from "../../../types";
+import { LovelaceTileFeature } from "../types";
+import { TargetTemperatureTileFeatureConfig } from "./types";
+
+type Target = "value" | "low" | "high";
+
+export const supportsTargetTemperatureTileFeature = (stateObj: HassEntity) => {
+ const domain = computeDomain(stateObj.entity_id);
+ return (
+ (domain === "climate" &&
+ (supportsFeature(stateObj, ClimateEntityFeature.TARGET_TEMPERATURE) ||
+ supportsFeature(
+ stateObj,
+ ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
+ ))) ||
+ (domain === "water_heater" &&
+ supportsFeature(stateObj, WaterHeaterEntityFeature.TARGET_TEMPERATURE))
+ );
+};
+
+@customElement("hui-target-temperature-tile-feature")
+class HuiTargetTemperatureTileFeature
+ extends LitElement
+ implements LovelaceTileFeature
+{
+ @property({ attribute: false }) public hass?: HomeAssistant;
+
+ @property({ attribute: false }) public stateObj?:
+ | ClimateEntity
+ | WaterHeaterEntity;
+
+ @state() private _config?: TargetTemperatureTileFeatureConfig;
+
+ @state() private _targetTemperature: Partial> = {};
+
+ static getStubConfig(): TargetTemperatureTileFeatureConfig {
+ return {
+ type: "target-temperature",
+ };
+ }
+
+ public setConfig(config: TargetTemperatureTileFeatureConfig): void {
+ if (!config) {
+ throw new Error("Invalid configuration");
+ }
+ this._config = config;
+ }
+
+ protected willUpdate(changedProp: PropertyValues): void {
+ super.willUpdate(changedProp);
+ if (changedProp.has("stateObj")) {
+ this._targetTemperature = {
+ value: this.stateObj!.attributes.temperature,
+ low:
+ "target_temp_low" in this.stateObj!.attributes
+ ? this.stateObj!.attributes.target_temp_low
+ : undefined,
+ high:
+ "target_temp_high" in this.stateObj!.attributes
+ ? this.stateObj!.attributes.target_temp_high
+ : undefined,
+ };
+ }
+ }
+
+ private get _step() {
+ return (
+ this.stateObj!.attributes.target_temp_step ||
+ (this.hass!.config.unit_system.temperature === UNIT_F ? 1 : 0.5)
+ );
+ }
+
+ private get _min() {
+ return this.stateObj!.attributes.min_temp;
+ }
+
+ private get _max() {
+ return this.stateObj!.attributes.max_temp;
+ }
+
+ private async _valueChanged(ev: CustomEvent) {
+ const value = (ev.detail as any).value;
+ if (isNaN(value)) return;
+ const target = (ev.currentTarget as any).target ?? "value";
+
+ this._targetTemperature = {
+ ...this._targetTemperature,
+ [target]: value,
+ };
+ this._debouncedCallService(target);
+ }
+
+ private _debouncedCallService = debounce(
+ (target: Target) => this._callService(target),
+ 1000
+ );
+
+ private _callService(type: string) {
+ const domain = computeStateDomain(this.stateObj!);
+ if (type === "high" || type === "low") {
+ this.hass!.callService(domain, "set_temperature", {
+ entity_id: this.stateObj!.entity_id,
+ target_temp_low: this._targetTemperature.low,
+ target_temp_high: this._targetTemperature.high,
+ });
+ return;
+ }
+ this.hass!.callService(domain, "set_temperature", {
+ entity_id: this.stateObj!.entity_id,
+ temperature: this._targetTemperature.value,
+ });
+ }
+
+ protected render() {
+ if (
+ !this._config ||
+ !this.hass ||
+ !this.stateObj ||
+ !supportsTargetTemperatureTileFeature(this.stateObj)
+ ) {
+ return nothing;
+ }
+
+ const stateColor = stateColorCss(this.stateObj);
+ const digits = this._step.toString().split(".")?.[1]?.length ?? 0;
+
+ const options = {
+ maximumFractionDigits: digits,
+ minimumFractionDigits: digits,
+ };
+
+ const domain = computeStateDomain(this.stateObj!);
+
+ if (
+ (domain === "climate" &&
+ supportsFeature(
+ this.stateObj,
+ ClimateEntityFeature.TARGET_TEMPERATURE
+ )) ||
+ (domain === "water_heater" &&
+ supportsFeature(
+ this.stateObj,
+ WaterHeaterEntityFeature.TARGET_TEMPERATURE
+ ))
+ ) {
+ return html`
+
+
+
+
+ `;
+ }
+
+ if (
+ domain === "climate" &&
+ supportsFeature(
+ this.stateObj,
+ ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
+ )
+ ) {
+ return html`
+
+
+
+
+
+
+ `;
+ }
+
+ return nothing;
+ }
+
+ static get styles() {
+ return css`
+ ha-control-button-group {
+ margin: 0 12px 12px 12px;
+ --control-button-group-spacing: 12px;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "hui-target-temperature-tile-feature": HuiTargetTemperatureTileFeature;
+ }
+}
diff --git a/src/panels/lovelace/tile-features/types.ts b/src/panels/lovelace/tile-features/types.ts
index 71e3ad711a..6401c28ec5 100644
--- a/src/panels/lovelace/tile-features/types.ts
+++ b/src/panels/lovelace/tile-features/types.ts
@@ -40,6 +40,10 @@ export interface ClimateHvacModesTileFeatureConfig {
hvac_modes?: HvacMode[];
}
+export interface TargetTemperatureTileFeatureConfig {
+ type: "target-temperature";
+}
+
export interface WaterHeaterOperationModesTileFeatureConfig {
type: "water-heater-operation-modes";
operation_modes?: OperationMode[];
@@ -81,6 +85,7 @@ export type LovelaceTileFeatureConfig =
| LightBrightnessTileFeatureConfig
| LightColorTempTileFeatureConfig
| VacuumCommandsTileFeatureConfig
+ | TargetTemperatureTileFeatureConfig
| WaterHeaterOperationModesTileFeatureConfig;
export type LovelaceTileFeatureContext = {
diff --git a/src/translations/en.json b/src/translations/en.json
index f05f25ca9a..71961fca7a 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -5031,6 +5031,9 @@
"label": "Climate HVAC modes",
"hvac_modes": "HVAC modes"
},
+ "target-temperature": {
+ "label": "Target temperature"
+ },
"water-heater-operation-modes": {
"label": "Water heater operation modes",
"operation_modes": "Operation modes"