ha-frontend/src/components/ha-control-number-buttons.ts

297 lines
7.7 KiB
TypeScript

import { ResizeController } from "@lit-labs/observers/resize-controller";
import { mdiMinus, mdiPlus } from "@mdi/js";
import {
CSSResultGroup,
LitElement,
TemplateResult,
css,
html,
nothing,
} from "lit";
import { customElement, property, query } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { conditionalClamp } from "../common/number/clamp";
import { formatNumber } from "../common/number/format_number";
import { blankBeforeUnit } from "../common/translations/blank_before_unit";
import { FrontendLocaleData } from "../data/translation";
import "./ha-svg-icon";
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() public unit?: string;
@property({ attribute: false })
public formatOptions: Intl.NumberFormatOptions = {};
@query("#input") _input!: HTMLDivElement;
private _hideUnit = new ResizeController(this, {
callback: (entries) => {
const width = entries[0]?.contentRect.width;
return width < 100;
},
});
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 (this.disabled) return;
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 value =
this.value != null
? formatNumber(this.value, this.locale, this.formatOptions)
: "";
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.locale)}${this.unit}`
: "";
return html`
<div class="container">
<div
id="input"
class="value"
role="spinbutton"
tabindex=${this.disabled ? "-1" : "0"}
aria-valuenow=${ifDefined(this.value)}
aria-valuetext=${`${value}${unit}`}
aria-valuemin=${ifDefined(this.min)}
aria-valuemax=${ifDefined(this.max)}
aria-label=${ifDefined(this.label)}
?disabled=${this.disabled}
@keydown=${this._handleKeyDown}
>
${value}
${unit && !this._hideUnit.value
? html`<span class="unit">${unit}</span>`
: nothing}
</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 .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 .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%;
container-type: inline-size;
container-name: container;
}
.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;
cursor: not-allowed;
}
.button.minus {
left: 0;
}
.button.plus {
right: 0;
}
.unit {
white-space: pre;
}
@container container (max-width: 100px) {
.unit {
display: none;
}
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-control-number-buttons": HaControlNumberButton;
}
}