Skip to content

Commit

Permalink
Add target temperature tile feature for climate and water heater (#17697
Browse files Browse the repository at this point in the history
)
  • Loading branch information
piitaya authored Aug 29, 2023
1 parent 6f99a39 commit 7040c6d
Show file tree
Hide file tree
Showing 11 changed files with 644 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
title: Control Number Buttons
---
100 changes: 100 additions & 0 deletions gallery/src/pages/components/ha-control-number-buttons.ts
Original file line number Diff line number Diff line change
@@ -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`
<ha-card>
<div class="card-content">
<label id=${id}>${label}</label>
<pre>Config: ${JSON.stringify(config)}</pre>
<ha-control-number-buttons
.value=${this.value}
.min=${config.min}
.max=${config.max}
.step=${config.step}
class=${ifDefined(config.class)}
@value-changed=${this._valueChanged}
.label=${label}
>
</ha-control-number-buttons>
</div>
</ha-card>
`;
})}
`;
}

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;
}
}
4 changes: 2 additions & 2 deletions src/common/number/clamp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
258 changes: 258 additions & 0 deletions src/components/ha-control-number-buttons.ts
Original file line number Diff line number Diff line change
@@ -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`
<div class="container">
<div
id="input"
class="value"
role="number-button"
tabindex="0"
aria-valuenow=${this.value}
aria-valuemin=${this.min}
aria-valuemax=${this.max}
aria-label=${ifDefined(this.label)}
.disabled=${this.disabled}
@keydown=${this._handleKeyDown}
>
${displayedValue}
</div>
<button
class="button minus"
type="button"
tabindex="-1"
aria-label="decrement"
@click=${this._handleMinusButton}
.disabled=${this.disabled ||
(this.min != null && this._value <= this.min)}
>
<ha-svg-icon aria-hidden .path=${mdiMinus}></ha-svg-icon>
</button>
<button
class="button plus"
type="button"
tabindex="-1"
aria-label="increment"
@click=${this._handlePlusButton}
.disabled=${this.disabled ||
(this.max != null && this._value >= this.max)}
>
<ha-svg-icon aria-hidden .path=${mdiPlus}></ha-svg-icon>
</button>
</div>
`;
}

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
);
}

Expand Down
Loading

0 comments on commit 7040c6d

Please sign in to comment.