Compare commits

...
Author SHA1 Message Date
Paul Bottein 6ccc78114c Add offset selector 2026-09-10 13:46:10 +02:00
10 changed files with 263 additions and 30 deletions
@@ -319,6 +319,7 @@ const SCHEMAS: {
selector: { config_entry: {} },
},
duration: { name: "Duration", selector: { duration: {} } },
offset: { name: "Offset", selector: { offset: { enable_day: true } } },
app: { name: "App", selector: { app: {} } },
number_box: {
name: "Number Box",
@@ -0,0 +1,31 @@
import type { HaDurationData } from "../../components/ha-duration-input";
export const durationValueToData = (
value?: HaDurationData | string | number
): HaDurationData | undefined => {
if (typeof value === "number") {
return { seconds: value };
}
if (typeof value === "string") {
const negative = value.trim()[0] === "-";
const parts = value
.split(":")
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
if (parts.length === 1) {
return { seconds: parts[0] };
}
if (parts.length === 2) {
return { hours: parts[0], minutes: parts[1] };
}
if (parts.length === 3) {
return {
hours: parts[0],
minutes: parts[1],
seconds: parts[2],
};
}
return undefined;
}
return value;
};
@@ -42,6 +42,7 @@ const SELECTOR_FALLBACK_VALUES = {
number: (selector) => selector.number?.min ?? 0,
numeric_threshold: undefined,
object: undefined,
offset: undefined,
period: undefined,
qr_code: undefined,
select: undefined,
@@ -74,6 +74,7 @@ const SELECTOR_INITIAL_VALUES = {
};
},
object: (selector) => (selector.object?.multiple ? [] : ""),
offset: () => ({ type: "none" }),
period: undefined,
qr_code: undefined,
select: (selector) => {
@@ -1,6 +1,7 @@
import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
import type { DurationSelector } from "../../data/selector";
import "../ha-duration-input";
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
@@ -26,35 +27,7 @@ export class HaTimeDuration extends LitElement {
return this._input?.reportValidity() ?? true;
}
private _data = memoizeOne(
(value?: HaDurationData | string | number): HaDurationData | undefined => {
if (typeof value === "number") {
return { seconds: value };
}
if (typeof value === "string") {
const negative = value.trim()[0] === "-";
const parts = value
.split(":")
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
if (parts.length === 1) {
return { seconds: parts[0] };
}
if (parts.length === 2) {
return { hours: parts[0], minutes: parts[1] };
}
if (parts.length === 3) {
return {
hours: parts[0],
minutes: parts[1],
seconds: parts[2],
};
}
return undefined;
}
return value;
}
);
private _data = memoizeOne(durationValueToData);
protected render() {
return html`
@@ -0,0 +1,182 @@
import {
mdiClockMinusOutline,
mdiClockOutline,
mdiClockPlusOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import type {
OffsetSelector,
OffsetSelectorValue,
OffsetType,
} from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-duration-input";
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
import "../ha-input-helper-text";
import "../ha-select";
const OFFSET_TYPES: { value: OffsetType; iconPath: string }[] = [
{ value: "none", iconPath: mdiClockOutline },
{ value: "before", iconPath: mdiClockMinusOutline },
{ value: "after", iconPath: mdiClockPlusOutline },
];
const DEFAULT_DURATION: HaDurationData = { hours: 0, minutes: 0, seconds: 0 };
const isOffsetValue = (value: unknown): value is OffsetSelectorValue =>
typeof value === "object" && value !== null && "type" in value;
@customElement("ha-selector-offset")
export class HaOffsetSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: OffsetSelector;
@property({ attribute: false }) public value?: OffsetSelectorValue;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@query("ha-duration-input", true) private _durationInput?: HaDurationInput;
public reportValidity(): boolean {
return this._durationInput?.reportValidity() ?? true;
}
private get _value(): OffsetSelectorValue | undefined {
return isOffsetValue(this.value) ? this.value : undefined;
}
private _duration = memoizeOne(durationValueToData);
private _typeOptions = memoizeOne((localize: LocalizeFunc) =>
OFFSET_TYPES.map(({ value, iconPath }) => ({
value,
iconPath,
label: localize(`ui.components.selectors.offset.${value}`),
}))
);
protected render() {
const type = this._value?.type ?? "none";
return html`
<div class="container">
${
this.label
? html`<label>${this.label}${this.required ? "*" : ""}</label>`
: nothing
}
<div class="inputs">
<ha-select
.value=${type}
.options=${this._typeOptions(this.hass.localize)}
.disabled=${this.disabled}
@selected=${this._typeChanged}
></ha-select>
${
type !== "none"
? html`<div class="value-row">
<span class="value-label"
>${this.hass.localize(
"ui.components.selectors.offset.duration"
)}${this.required ? "*" : ""}</span
>
<ha-duration-input
.data=${this._duration(this._value?.duration)}
.disabled=${this.disabled}
.required=${this.required}
.enableDay=${this.selector.offset?.enable_day}
.enableMillisecond=${
this.selector.offset?.enable_millisecond
}
@value-changed=${this._durationChanged}
></ha-duration-input>
</div>`
: nothing
}
</div>
${
this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
</div>
`;
}
private _durationChanged(ev: CustomEvent<{ value?: HaDurationData }>) {
ev.stopPropagation();
const type = this._value?.type;
if (!type || type === "none") {
return;
}
fireEvent(this, "value-changed", {
value: { type, duration: ev.detail.value ?? DEFAULT_DURATION },
});
}
private _typeChanged(ev: CustomEvent<{ value?: string }>) {
ev.stopPropagation();
const type = ev.detail.value as OffsetType | undefined;
if (!type || type === this._value?.type) {
return;
}
fireEvent(this, "value-changed", {
value:
type === "none"
? { type }
: {
type,
duration:
this._duration(this._value?.duration) ?? DEFAULT_DURATION,
},
});
}
static styles = css`
.container {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
label {
display: block;
font-weight: var(--ha-font-weight-medium);
margin-bottom: var(--ha-space-1);
}
.inputs,
.value-row {
--ha-input-padding-bottom: 0;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.value-label {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
ha-select {
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-offset": HaOffsetSelector;
}
}
@@ -43,6 +43,7 @@ const LOAD_ELEMENTS = {
number: () => import("./ha-selector-number"),
numeric_threshold: () => import("./ha-selector-numeric-threshold"),
object: () => import("./ha-selector-object"),
offset: () => import("./ha-selector-offset"),
period: () => import("./ha-selector-period"),
qr_code: () => import("./ha-selector-qr-code"),
select: () => import("./ha-selector-select"),
+16
View File
@@ -22,6 +22,7 @@ import type {
EntityRegistryEntry,
} from "./entity/entity_registry";
import type { EntitySources } from "./entity/entity_sources";
import type { HaDurationData } from "../components/ha-duration-input";
export type ThresholdMode = "crossed" | "changed" | "is";
@@ -63,6 +64,7 @@ export type Selector =
| NumberSelector
| NumericThresholdSelector
| ObjectSelector
| OffsetSelector
| PeriodSelector
| AssistPipelineSelector
| QRCodeSelector
@@ -446,6 +448,20 @@ export interface ObjectSelector {
} | null;
}
export type OffsetType = "none" | "before" | "after";
export interface OffsetSelector {
offset: {
enable_day?: boolean;
enable_millisecond?: boolean;
} | null;
}
export interface OffsetSelectorValue {
type: OffsetType;
duration?: HaDurationData | string | number;
}
export type PeriodKey =
| "today"
| "yesterday"
+18 -1
View File
@@ -1,9 +1,11 @@
import { ensureArray } from "../../common/array/ensure-array";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
import { formatDurationLong } from "../../common/datetime/format_duration";
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import type { HomeAssistant } from "../../types";
import type { Selector } from "../selector";
import type { OffsetSelectorValue, Selector } from "../selector";
export const formatSelectorValue = (
hass: HomeAssistant,
@@ -123,6 +125,21 @@ export const formatSelectorValue = (
.join(", ");
}
if ("offset" in selector) {
const { type, duration } = value as OffsetSelectorValue;
const durationData = durationValueToData(duration);
if (type === "none" || !durationData) {
return "";
}
const formattedDuration = formatDurationLong(hass.locale, durationData);
if (!formattedDuration) {
return "";
}
return hass.localize(`ui.components.selectors.offset.summary.${type}`, {
duration: formattedDuration,
});
}
return ensureArray(value)
.map((v) =>
v != null && typeof v === "object" ? JSON.stringify(v) : String(v)
+10
View File
@@ -660,6 +660,16 @@
"outside": "Outside range"
}
},
"offset": {
"none": "No offset",
"before": "Before",
"after": "After",
"duration": "Duration",
"summary": {
"before": "{duration} before",
"after": "{duration} after"
}
},
"automation_behavior": {
"trigger": {
"options": {