Compare commits

...
Author SHA1 Message Date
Paul Bottein 50271ca9d3 Hide the clear button of offset durations 2026-09-30 18:06:56 +02:00
Paul Bottein 7d04cc40c7 Simplify duration sign handling and compute typed negative values 2026-09-30 15:08:45 +02:00
Paul Bottein 60db165cc4 Merge branch 'dev' into duration-selector-modes 2026-09-30 14:42:52 +02:00
Paul Bottein f25077481c Keep the duration sign at zero and use selector units everywhere 2026-09-30 14:30:30 +02:00
Paul Bottein 747873879f Forget the emitted duration after an external change 2026-09-29 17:07:56 +02:00
Paul Bottein 17d8284226 Merge branch 'dev' into duration-selector-modes 2026-09-29 16:35:12 +02:00
Paul Bottein 83a716eaca Keep the offset type as local state and refresh duration fields with live 2026-09-29 15:29:08 +02:00
Paul Bottein c75db44d7d Simplify duration sign handling around a single normalizeDuration 2026-09-29 15:06:48 +02:00
Paul Bottein 488277cf8a Write negative durations as negative components instead of a negative key 2026-09-29 14:50:52 +02:00
Paul Bottein 67bdbd8d90 Translate the duration selector mode in the selector editor 2026-09-28 17:52:43 +02:00
Paul Bottein 6c089561d6 Reset the offset type when the duration changes from outside 2026-09-28 17:51:32 +02:00
Paul Bottein c0da749dc6 Merge remote-tracking branch 'origin/dev' into duration-selector-modes
# Conflicts:
#	src/components/ha-duration-input.ts
2026-09-28 16:01:38 +02:00
Paul Bottein d203799e57 Merge remote-tracking branch 'origin/dev' into duration-selector-modes
# Conflicts:
#	src/components/ha-duration-input.ts
#	src/components/ha-selector/ha-selector-duration.ts
2026-09-14 10:46:10 +02:00
Paul Bottein 03ed72147d Simplify the duration sign handling 2026-09-11 10:08:07 +02:00
Paul Bottein b585d41bf5 Address remaining Copilot comments on the duration selector 2026-09-11 09:50:52 +02:00
Paul Bottein 3e069f2eac Rename the offset type state 2026-09-10 22:51:16 +02:00
Paul Bottein 94e7fdba47 Keep the offset type when the duration is cleared to zero 2026-09-10 22:50:30 +02:00
Paul Bottein 0b6e3f20b1 Keep the pending offset type in local state instead of writing negative false 2026-09-10 22:49:47 +02:00
Paul Bottein 4ef97bc2d0 Omit the negative flag for positive signed durations 2026-09-10 22:48:33 +02:00
Paul Bottein 6bc6be27ad Merge the duration sign helpers into normalizeDuration 2026-09-10 22:46:32 +02:00
Paul Bottein 33b7a59aef Test the duration sign and value helpers 2026-09-10 22:44:03 +02:00
Paul Bottein 3c08f602dd Derive the magnitude of mixed-sign legacy durations from their total 2026-09-10 22:43:02 +02:00
Paul Bottein 7d564c8047 Migrate allow_negative to mode when editing a duration selector 2026-09-10 22:34:17 +02:00
Paul Bottein 6c352e904d Remove allow_negative from the selector editor 2026-09-10 21:17:00 +02:00
Paul Bottein c12fa5591c Expose the duration selector mode in the selector editor 2026-09-10 21:14:01 +02:00
Paul Bottein f389679bf3 Address review comments on the duration selector 2026-09-10 19:25:44 +02:00
Paul Bottein 9b8bbafa7e Remove translation key from the duration selector 2026-09-10 19:24:13 +02:00
Paul Bottein b0f0eb1819 Add signed and offset modes to the duration selector 2026-09-10 19:07:46 +02:00
12 changed files with 625 additions and 198 deletions
@@ -384,6 +384,14 @@ const SCHEMAS: {
selector: { config_entry: {} },
},
duration: { name: "Duration", selector: { duration: {} } },
signed_duration: {
name: "Signed duration",
selector: { duration: { mode: "signed" } },
},
offset_duration: {
name: "Offset",
selector: { duration: { mode: "offset", enable_day: true } },
},
app: { name: "App", selector: { app: {} } },
serial_port: {
name: "Serial port",
+56
View File
@@ -0,0 +1,56 @@
import type { HaDurationData } from "../../components/ha-duration-input";
import { durationDataToSeconds } from "./duration_to_seconds";
export interface NormalizedDuration {
negative: boolean;
duration: HaDurationData;
}
export interface DurationUnits {
enableDay: boolean;
enableSecond: boolean;
enableMillisecond: boolean;
}
export const normalizeDuration = (
duration: HaDurationData,
{ enableDay, enableSecond, enableMillisecond }: DurationUnits
): NormalizedDuration => {
const total = Math.round(durationDataToSeconds(duration) * 1000);
let rest = Math.abs(total);
const result: HaDurationData = {};
if (enableDay) {
result.days = Math.floor(rest / 86400000);
rest %= 86400000;
}
result.hours = Math.floor(rest / 3600000);
rest %= 3600000;
result.minutes = Math.floor(rest / 60000);
rest %= 60000;
if (enableMillisecond) {
result.seconds = Math.floor(rest / 1000);
result.milliseconds = rest % 1000;
} else {
result.seconds = rest / 1000;
}
if (!enableSecond && !result.seconds) {
delete result.seconds;
}
return { negative: total < 0, duration: result };
};
export const applyDurationSign = (
duration: HaDurationData,
negative: boolean
): HaDurationData => {
if (!negative) {
return duration;
}
const signed = { ...duration };
for (const field of Object.keys(signed) as (keyof HaDurationData)[]) {
if (signed[field]) {
signed[field] = -signed[field];
}
}
return signed;
};
+120 -1
View File
@@ -1,18 +1,28 @@
import { mdiClose } from "@mdi/js";
import { mdiClose, mdiMenuDown, mdiMinus, mdiPlus } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import "./ha-dropdown";
import type { HaDropdownSelectEvent } from "./ha-dropdown";
import "./ha-dropdown-item";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-input-helper-text";
import "./ha-select";
import type { HaSelectSelectEvent } from "./ha-select";
import "./input/ha-input";
import type { HaInput } from "./input/ha-input";
const SIGNS = [
{ negative: false, label: "+" },
{ negative: true, label: "−" },
];
export interface TimeChangedEvent {
negative?: boolean;
days?: number;
hours: number;
minutes: number;
@@ -132,6 +142,10 @@ export class HaBaseTimeInput extends LitElement {
*/
@property({ attribute: false }) amPm: "AM" | "PM" = "AM";
@property({ attribute: "enable-sign", type: Boolean }) enableSign = false;
@property({ type: Boolean }) negative = false;
@property({ type: Boolean, reflect: true }) public clearable?: boolean;
@property({ attribute: "placeholder-labels", type: Boolean })
@@ -165,6 +179,43 @@ export class HaBaseTimeInput extends LitElement {
role="group"
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
${
this.enableSign
? html`<ha-dropdown
placement="bottom-start"
@wa-select=${this._signSelected}
@wa-after-hide=${stopPropagation}
@wa-hide=${stopPropagation}
>
<button
slot="trigger"
type="button"
class="sign"
aria-label=${this.negative ? "-" : "+"}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${this.negative ? mdiMinus : mdiPlus}
></ha-svg-icon>
<ha-svg-icon
class="chevron"
.path=${mdiMenuDown}
></ha-svg-icon>
</button>
${SIGNS.map(
({ negative, label }) => html`
<ha-dropdown-item
.value=${negative ? "-" : "+"}
.selected=${this.negative === negative}
>
${label}
</ha-dropdown-item>
`
)}
</ha-dropdown>
<div class="sign-divider"></div>`
: nothing
}
${
this.enableDay
? html`
@@ -322,18 +373,35 @@ export class HaBaseTimeInput extends LitElement {
fireEvent(this, "value-changed");
}
private _signSelected(ev: HaDropdownSelectEvent): void {
ev.stopPropagation();
const negative = ev.detail.item.value === "-";
if (negative === this.negative) {
return;
}
this.negative = negative;
this._fireValue();
}
private _valueChanged(ev: InputEvent | HaSelectSelectEvent): void {
const textField = ev.currentTarget as HaInput;
this[textField.name || ""] =
textField.name === "amPm"
? (ev as HaSelectSelectEvent).detail.value
: Number(textField.value);
this._fireValue();
}
private _fireValue(): void {
const value: TimeChangedEvent = {
hours: this.hours,
minutes: this.minutes,
seconds: this.seconds,
milliseconds: this.milliseconds,
};
if (this.enableSign) {
value.negative = this.negative;
}
if (this.enableDay) {
value.days = this.days;
}
@@ -409,6 +477,10 @@ export class HaBaseTimeInput extends LitElement {
padding-inline-start: var(--ha-space-4);
}
.sign-divider + ha-input::part(wa-base) {
padding-inline-start: var(--ha-space-2);
}
ha-input:last-child::part(wa-base) {
padding-inline-end: var(--ha-space-4);
}
@@ -421,6 +493,53 @@ export class HaBaseTimeInput extends LitElement {
text-align: center;
}
.sign {
display: flex;
align-items: center;
box-sizing: border-box;
height: 56px;
padding: 0;
padding-inline: var(--ha-space-3) var(--ha-space-1);
border: none;
border-bottom: 1px solid var(--ha-color-border-neutral-loud);
background-color: var(--ha-color-form-background);
color: var(--ha-color-text-secondary);
cursor: pointer;
--mdc-icon-size: 20px;
}
.sign .chevron {
--mdc-icon-size: 18px;
}
.sign:hover {
background-color: var(--ha-color-form-background-hover);
}
.sign:disabled {
cursor: default;
color: var(--ha-color-text-disabled);
}
.sign:focus-visible {
outline: 2px solid var(--ha-color-border-primary-normal);
outline-offset: -2px;
}
ha-dropdown-item {
font-size: var(--ha-font-size-l);
text-align: center;
}
.sign-divider {
display: flex;
align-items: center;
box-sizing: border-box;
height: 56px;
padding-inline: 0 var(--ha-space-1);
background-color: var(--ha-color-form-background);
border-bottom: 1px solid var(--ha-color-border-neutral-loud);
}
.sign-divider::after {
content: "";
width: 1px;
height: 24px;
background-color: var(--ha-color-border-neutral-quiet);
}
.time-separator,
ha-icon-button {
background-color: var(--ha-color-form-background);
+33 -182
View File
@@ -1,12 +1,16 @@
import { mdiMinusThick, mdiPlusThick } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { live } from "lit/directives/live";
import type { DurationUnits } from "../common/datetime/normalize_duration";
import {
applyDurationSign,
normalizeDuration,
} from "../common/datetime/normalize_duration";
import { fireEvent } from "../common/dom/fire_event";
import type { ValueChangedEvent } from "../types";
import "./ha-base-time-input";
import type { HaBaseTimeInput, TimeChangedEvent } from "./ha-base-time-input";
import "./ha-button-toggle-group";
export interface HaDurationData {
days?: number;
@@ -16,8 +20,6 @@ export interface HaDurationData {
milliseconds?: number;
}
const FIELDS = ["milliseconds", "seconds", "minutes", "hours", "days"];
@customElement("ha-duration-input")
export class HaDurationInput extends LitElement {
@property({ attribute: false }) public data?: HaDurationData;
@@ -44,8 +46,6 @@ export class HaDurationInput extends LitElement {
@query("ha-base-time-input", true) private _input?: HaBaseTimeInput;
private _toggleNegative = false;
static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
@@ -56,24 +56,9 @@ export class HaDurationInput extends LitElement {
}
protected render(): TemplateResult {
const normalized = this.data && normalizeDuration(this.data, this._units);
return html`
<div class="row">
${
this.allowNegative
? html`
<ha-button-toggle-group
size="s"
.buttons=${[
{ label: "+", iconPath: mdiPlusThick, value: "+" },
{ label: "-", iconPath: mdiMinusThick, value: "-" },
]}
.active=${this._negative ? "-" : "+"}
.disabled=${this.disabled}
@value-changed=${this._negativeChanged}
></ha-button-toggle-group>
`
: nothing
}
<ha-base-time-input
.label=${this.label}
.helper=${this.helper}
@@ -85,12 +70,14 @@ export class HaDurationInput extends LitElement {
.enableSecond=${this.enableSecond}
.enableMillisecond=${this.enableMillisecond}
.enableDay=${this.enableDay}
.enableSign=${this.allowNegative}
.negative=${normalized?.negative ?? false}
format="24"
.days=${this._days}
.hours=${this._hours}
.minutes=${this._minutes}
.seconds=${this._seconds}
.milliseconds=${this._milliseconds}
.days=${live(this._fieldValue(normalized?.duration, "days"))}
.hours=${live(this._fieldValue(normalized?.duration, "hours"))}
.minutes=${live(this._fieldValue(normalized?.duration, "minutes"))}
.seconds=${live(this._fieldValue(normalized?.duration, "seconds"))}
.milliseconds=${live(this._fieldValue(normalized?.duration, "milliseconds"))}
@value-changed=${this._durationChanged}
no-hours-limit
day-label="dd"
@@ -103,170 +90,37 @@ export class HaDurationInput extends LitElement {
`;
}
// Fold units we do not render into the smallest unit we do, so a value the
// user cannot see is not silently dropped on the next edit.
private get _data(): HaDurationData | undefined {
if (!this.data) {
return this.data;
}
const data = { ...this.data };
if (!this.enableDay && data.days) {
data.hours = (data.hours || 0) + data.days * 24;
delete data.days;
}
if (!this.enableMillisecond && data.milliseconds) {
data.seconds = (data.seconds || 0) + data.milliseconds / 1000;
delete data.milliseconds;
}
return data;
private get _units(): DurationUnits {
return {
enableDay: this.enableDay,
enableSecond: this.enableSecond,
enableMillisecond: this.enableMillisecond,
};
}
private get _negative() {
return (
this._toggleNegative ||
(this._data?.days
? this._data.days < 0
: this._data?.hours
? this._data.hours < 0
: this._data?.minutes
? this._data.minutes < 0
: this._data?.seconds
? this._data.seconds < 0
: this._data?.milliseconds
? this._data.milliseconds < 0
: false)
);
}
private get _days() {
return this._data?.days
? this.allowNegative
? Math.abs(Number(this._data.days))
: Number(this._data.days)
: this.required || this._data
? 0
: NaN;
}
private get _hours() {
return this._data?.hours
? this.allowNegative
? Math.abs(Number(this._data.hours))
: Number(this._data.hours)
: this.required || this._data
? 0
: NaN;
}
private get _minutes() {
return this._data?.minutes
? this.allowNegative
? Math.abs(Number(this._data.minutes))
: Number(this._data.minutes)
: this.required || this._data
? 0
: NaN;
}
private get _seconds() {
return this._data?.seconds
? this.allowNegative
? Math.abs(Number(this._data.seconds))
: Number(this._data.seconds)
: this.required || this._data
? 0
: NaN;
}
private get _milliseconds() {
return this._data?.milliseconds
? this.allowNegative
? Math.abs(Number(this._data.milliseconds))
: Number(this._data.milliseconds)
: this.required || this._data
? 0
: NaN;
private _fieldValue(
data: HaDurationData | undefined,
field: keyof HaDurationData
): number {
return data?.[field] ?? (this.required ? 0 : NaN);
}
private _durationChanged(
ev: ValueChangedEvent<TimeChangedEvent | undefined>
) {
ev.stopPropagation();
const value = ev.detail.value ? { ...ev.detail.value } : undefined;
if (value) {
value.hours ||= 0;
value.minutes ||= 0;
if ("days" in value) value.days ||= 0;
if ("seconds" in value) value.seconds ||= 0;
if ("milliseconds" in value) value.milliseconds ||= 0;
if (this.allowNegative) {
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = Math.abs(value[t]);
}
});
}
if (!this.enableMillisecond && !value.milliseconds) {
// @ts-ignore
delete value.milliseconds;
} else if (value.milliseconds > 999) {
value.seconds += Math.floor(value.milliseconds / 1000);
value.milliseconds %= 1000;
}
if (!this.enableSecond && !value.seconds) {
// @ts-ignore
delete value.seconds;
} else if (this.enableSecond && value.seconds > 59) {
value.minutes = (value.minutes ?? 0) + Math.floor(value.seconds / 60);
value.seconds %= 60;
}
if (value.minutes > 59) {
value.hours += Math.floor(value.minutes / 60);
value.minutes %= 60;
}
if (this.enableDay && value.hours > 24) {
value.days = (value.days ?? 0) + Math.floor(value.hours / 24);
value.hours %= 24;
}
if (this._negative) {
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = -Math.abs(value[t]);
}
});
}
if (!ev.detail.value) {
fireEvent(this, "value-changed", { value: undefined });
return;
}
const { negative: negativeSign, ...fields } = ev.detail.value;
const typed = applyDurationSign(fields, negativeSign ?? false);
const { negative, duration } = normalizeDuration(typed, this._units);
fireEvent(this, "value-changed", {
value,
value: applyDurationSign(duration, this.allowNegative && negative),
});
}
private _negativeChanged(ev) {
ev.stopPropagation();
const negative = (ev.detail?.value || ev.target.value) === "-";
this._toggleNegative = negative;
if (this._data) {
const value = { ...this._data };
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
}
});
fireEvent(this, "value-changed", {
value,
});
}
}
static styles = css`
.row {
display: flex;
@@ -277,9 +131,6 @@ export class HaDurationInput extends LitElement {
flex: 1;
--time-input-flex: 1;
}
ha-button-toggle-group {
margin: var(--ha-space-2);
}
`;
}
+1 -1
View File
@@ -145,7 +145,7 @@ export class HaSelect extends LitElement {
type="button"
class=${this._opened ? "opened" : ""}
compact
aria-label=${ifDefined(this.label)}
aria-label=${ifDefined(this.ariaLabel || this.label)}
@clear=${this._clearValue}
.label=${this.label}
.value=${valueLabel}
@@ -1,10 +1,52 @@
import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import {
mdiClockMinusOutline,
mdiClockOutline,
mdiClockPlusOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import type { PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import type { DurationSelector } from "../../data/selector";
import { durationDataToSeconds } from "../../common/datetime/duration_to_seconds";
import { createDurationData } from "../../common/datetime/create_duration_data";
import {
applyDurationSign,
normalizeDuration,
} from "../../common/datetime/normalize_duration";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import type {
DurationSelector,
DurationSelectorMode,
} from "../../data/selector";
import {
getDurationSelectorMode,
getDurationSelectorUnits,
} from "../../data/selector";
import type { ValueChangedEvent } from "../../types";
import "../ha-duration-input";
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
import { createDurationData } from "../../common/datetime/create_duration_data";
import "../ha-input-helper-text";
import "../ha-select";
import type { HaSelectSelectEvent } from "../ha-select";
type OffsetType = "none" | "before" | "after";
const offsetTypeOf = (data?: HaDurationData): OffsetType => {
const total = data ? durationDataToSeconds(data) : 0;
if (!total) {
return "none";
}
return total < 0 ? "before" : "after";
};
const OFFSET_TYPES: { value: OffsetType; iconPath: string }[] = [
{ value: "none", iconPath: mdiClockOutline },
{ value: "before", iconPath: mdiClockMinusOutline },
{ value: "after", iconPath: mdiClockPlusOutline },
];
@customElement("ha-selector-duration")
export class HaTimeDuration extends LitElement {
@@ -21,7 +63,13 @@ export class HaTimeDuration extends LitElement {
@property({ type: Boolean }) public required = true;
@query("ha-duration-input", true) private _input?: HaDurationInput;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@query("ha-duration-input") private _input?: HaDurationInput;
@state() private _offsetType: OffsetType = "none";
public reportValidity(): boolean {
return this._input?.reportValidity() ?? true;
@@ -32,21 +80,152 @@ export class HaTimeDuration extends LitElement {
createDurationData(value)
);
protected willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("value")) {
const type = offsetTypeOf(this._data(this.value));
if (type !== "none") {
this._offsetType = type;
}
}
}
private _offsetTypeOptions = memoizeOne((localize: LocalizeFunc) =>
OFFSET_TYPES.map(({ value, iconPath }) => ({
value,
iconPath,
label: localize(`ui.components.selectors.duration.offset.${value}`),
}))
);
protected render() {
const mode = getDurationSelectorMode(this.selector.duration);
const data = this._data(this.value);
if (mode !== "offset") {
return this._renderInput(data, mode, this.label, this.helper);
}
return html`
<div class="container">
${
this.label
? html`<label id="label"
>${this.label}${this.required ? "*" : ""}</label
>`
: nothing
}
<div
class="inputs"
role="group"
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
<ha-select
aria-label=${ifDefined(this.label)}
.value=${this._offsetType}
.options=${this._offsetTypeOptions(this._localize)}
.disabled=${this.disabled}
@selected=${this._offsetTypeChanged}
></ha-select>
${
this._offsetType === "none"
? nothing
: html`<div
class="value-row"
role="group"
aria-labelledby="duration-label"
@value-changed=${this._durationChanged}
>
<span id="duration-label" class="value-label"
>${this._localize(
"ui.components.selectors.duration.duration"
)}${this.required ? "*" : ""}</span
>
${this._renderInput(data, mode)}
</div>`
}
</div>
${
this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
</div>
`;
}
private _renderInput(
data: HaDurationData | undefined,
mode: DurationSelectorMode,
label?: string,
helper?: string
) {
return html`
<ha-duration-input
.label=${this.label}
.helper=${this.helper}
.data=${this._data(this.value)}
.label=${label}
.helper=${helper}
.data=${data}
.disabled=${this.disabled}
.required=${this.required}
.enableDay=${this.selector.duration?.enable_day}
.enableMillisecond=${this.selector.duration?.enable_millisecond}
.allowNegative=${this.selector.duration?.allow_negative}
.required=${this.required || mode === "offset"}
.enableDay=${this.selector.duration?.enable_day ?? false}
.enableMillisecond=${this.selector.duration?.enable_millisecond ?? false}
.allowNegative=${mode === "signed"}
.enableSecond=${this.selector.duration?.enable_second ?? true}
></ha-duration-input>
`;
}
private _durationChanged(ev: ValueChangedEvent<HaDurationData | undefined>) {
ev.stopPropagation();
this._fireValue(this._offsetType, ev.detail.value);
}
private _offsetTypeChanged(ev: HaSelectSelectEvent<OffsetType>) {
ev.stopPropagation();
this._fireValue(ev.detail.value ?? "none", this._data(this.value));
}
private _fireValue(type: OffsetType, data: HaDurationData = {}) {
const { duration } = normalizeDuration(
type === "none" ? {} : data,
getDurationSelectorUnits(this.selector.duration)
);
const value = applyDurationSign(duration, type === "before");
this._offsetType = type;
fireEvent(this, "value-changed", { value });
}
static styles = css`
.container {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
label {
display: block;
font-size: var(--ha-font-size-s);
line-height: var(--ha-line-height-condensed);
color: var(--ha-color-text-primary);
padding-inline-start: 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 {
@@ -72,8 +72,14 @@ const SELECTOR_SCHEMAS = {
selector: { boolean: {} },
},
{
name: "allow_negative",
selector: { boolean: {} },
name: "mode",
selector: {
select: {
mode: "dropdown",
translation_key: "duration_mode",
options: ["positive", "signed", "offset"],
},
},
},
] as const,
entity: [
@@ -253,6 +259,9 @@ export class HaSelectorSelector extends LitElement {
type,
...(typeof value0 === "object" ? value0 : []),
};
if (type === "duration" && data.allow_negative) {
data.mode ??= "signed";
}
}
const schema = this._schema(type, this._localize!);
@@ -264,6 +273,7 @@ export class HaSelectorSelector extends LitElement {
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
.localizeValue=${this._localizeValueCallback}
@value-changed=${this._valueChanged}
.narrow=${this.narrow}
></ha-form>
@@ -293,6 +303,9 @@ export class HaSelectorSelector extends LitElement {
this._yamlMode = false;
}
delete value.type;
if (type === "duration" && value.mode !== undefined) {
delete value.allow_negative;
}
let newValue;
if (type === "manual") {
@@ -313,6 +326,9 @@ export class HaSelectorSelector extends LitElement {
`ui.components.selectors.selector.${schema.name}` as LocalizeKeys
) || schema.name;
private _localizeValueCallback = (key: string): string =>
this._localize!(`ui.components.selectors.selector.${key}` as LocalizeKeys);
static styles = css`
.title {
font-size: var(--ha-font-size-l);
+17
View File
@@ -3,6 +3,7 @@ import type {
HassServiceTarget,
} from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import type { DurationUnits } from "../common/datetime/normalize_duration";
import type { EntityNameItem } from "../common/entity/compute_entity_name_display";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { supportsFeature } from "../common/entity/supports-feature";
@@ -266,15 +267,31 @@ export interface LegacyDeviceSelector {
};
}
export type DurationSelectorMode = "positive" | "signed" | "offset";
export interface DurationSelector {
duration: {
enable_day?: boolean;
enable_millisecond?: boolean;
allow_negative?: boolean;
enable_second?: boolean;
mode?: DurationSelectorMode;
} | null;
}
export const getDurationSelectorMode = (
config: DurationSelector["duration"]
): DurationSelectorMode =>
config?.mode ?? (config?.allow_negative ? "signed" : "positive");
export const getDurationSelectorUnits = (
config: DurationSelector["duration"]
): DurationUnits => ({
enableDay: !!config?.enable_day,
enableSecond: config?.enable_second ?? true,
enableMillisecond: !!config?.enable_millisecond,
});
interface EntitySelectorFilter {
integration?: string;
domain?: string | readonly string[];
@@ -1,9 +1,13 @@
import { ensureArray } from "../../common/array/ensure-array";
import { createDurationData } from "../../common/datetime/create_duration_data";
import { formatDurationLong } from "../../common/datetime/format_duration";
import { normalizeDuration } from "../../common/datetime/normalize_duration";
import { computeAreaName } from "../../common/entity/compute_area_name";
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 { getDurationSelectorMode, getDurationSelectorUnits } from "../selector";
export const formatSelectorValue = (
hass: HomeAssistant,
@@ -130,6 +134,27 @@ export const formatSelectorValue = (
.join(", ");
}
if ("duration" in selector) {
const data = createDurationData(value);
if (!data) {
return "";
}
const { negative, duration } = normalizeDuration(
data,
getDurationSelectorUnits(selector.duration)
);
const formatted = formatDurationLong(hass.locale, duration);
const mode = getDurationSelectorMode(selector.duration);
if (!formatted || mode === "positive") {
return formatted;
}
const sign = negative ? "negative" : "positive";
return hass.localize(
`ui.components.selectors.duration.summary.${mode}_${sign}`,
{ duration: formatted }
);
}
return ensureArray(value)
.map((v) =>
v != null && typeof v === "object" ? JSON.stringify(v) : String(v)
+22
View File
@@ -571,6 +571,20 @@
"background": {
"yaml_info": "Background image is set via YAML editor."
},
"duration": {
"duration": "Duration",
"offset": {
"none": "No offset",
"before": "Before",
"after": "After"
},
"summary": {
"offset_negative": "{duration} before",
"offset_positive": "{duration} after",
"signed_negative": "-{duration}",
"signed_positive": "+{duration}"
}
},
"location": {
"latitude": "[%key:ui::panel::config::zone::detail::latitude%]",
"longitude": "[%key:ui::panel::config::zone::detail::longitude%]",
@@ -599,6 +613,14 @@
"options": "Selector options",
"type": "Type",
"multiple": "Multiple",
"mode": "Mode",
"duration_mode": {
"options": {
"positive": "Positive only",
"signed": "Positive or negative",
"offset": "Before or after"
}
},
"types": {
"action": "Action",
"area": "Area",
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
applyDurationSign,
normalizeDuration,
} from "../../../src/common/datetime/normalize_duration";
const ALL_UNITS = {
enableDay: true,
enableSecond: true,
enableMillisecond: true,
};
describe("normalizeDuration", () => {
it("splits the sign from the components", () => {
expect(normalizeDuration({ hours: -1, minutes: -30 }, ALL_UNITS)).toEqual({
negative: true,
duration: { days: 0, hours: 1, minutes: 30, seconds: 0, milliseconds: 0 },
});
});
it("uses the total when components have mixed signs", () => {
expect(
normalizeDuration({ hours: 1, minutes: -30 }, ALL_UNITS)
).toMatchObject({
negative: false,
duration: { hours: 0, minutes: 30 },
});
});
it("folds units that are not shown into the smaller ones", () => {
expect(
normalizeDuration(
{ days: 1, hours: 2, seconds: 5, milliseconds: 500 },
{ enableDay: false, enableSecond: true, enableMillisecond: false }
)
).toEqual({
negative: false,
duration: { hours: 26, minutes: 0, seconds: 5.5 },
});
});
it("leaves out zero seconds when seconds are not shown", () => {
expect(
normalizeDuration(
{ hours: 1 },
{ enableDay: false, enableSecond: false, enableMillisecond: false }
)
).toEqual({ negative: false, duration: { hours: 1, minutes: 0 } });
});
});
describe("applyDurationSign", () => {
it("negates non zero components only when negative", () => {
expect(
applyDurationSign({ hours: 1, minutes: 30, seconds: 0 }, true)
).toEqual({ hours: -1, minutes: -30, seconds: 0 });
expect(applyDurationSign({ hours: 1 }, false)).toEqual({ hours: 1 });
});
});
+75
View File
@@ -61,3 +61,78 @@ describe("formatSelectorValue", () => {
expect(result).toContain("••••••••");
});
});
describe("formatSelectorValue duration selector", () => {
const localizedHass = {
locale: { language: "en" },
localize: (key: string, values?: Record<string, unknown>) => {
const suffix = key.split("ui.components.selectors.duration.summary.")[1];
return {
offset_negative: `${values?.duration} before`,
offset_positive: `${values?.duration} after`,
signed_negative: `-${values?.duration}`,
signed_positive: `+${values?.duration}`,
}[suffix]!;
},
} as unknown as HomeAssistant;
it("formats a positive duration without a sign", () => {
expect(
formatSelectorValue(
localizedHass,
{ hours: 1, minutes: 30 },
{ duration: {} }
)
).toBe("1 hour, 30 minutes");
});
it("formats signed durations with an explicit sign", () => {
expect(
formatSelectorValue(
localizedHass,
{ minutes: -30 },
{ duration: { mode: "signed" } }
)
).toBe("-30 minutes");
expect(
formatSelectorValue(localizedHass, "00:30:00", {
duration: { allow_negative: true },
})
).toBe("+30 minutes");
});
it("formats offsets as before or after", () => {
expect(
formatSelectorValue(
localizedHass,
{ hours: -1, minutes: -30 },
{ duration: { mode: "offset" } }
)
).toBe("1 hour, 30 minutes before");
expect(
formatSelectorValue(localizedHass, "-00:10:00", {
duration: { mode: "offset" },
})
).toBe("10 minutes before");
expect(
formatSelectorValue(localizedHass, 45, { duration: { mode: "offset" } })
).toBe("45 seconds after");
expect(
formatSelectorValue(
localizedHass,
{ minutes: -5 },
{ duration: { mode: "offset" } }
)
).toBe("5 minutes before");
});
it("returns an empty string for a zero offset", () => {
expect(
formatSelectorValue(
localizedHass,
{ hours: 0, minutes: 0, seconds: 0 },
{ duration: { mode: "offset" } }
)
).toBe("");
});
});