diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 4f2e82c56b..1ff067e3c8 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -55,7 +55,7 @@ jobs:
script/release
- name: Upload release assets
- uses: softprops/action-gh-release@v2.0.9
+ uses: softprops/action-gh-release@v2.1.0
with:
files: |
dist/*.whl
diff --git a/build-scripts/gulp/download-translations.js b/build-scripts/gulp/download-translations.js
index b9f2c97583..b12eb228f0 100644
--- a/build-scripts/gulp/download-translations.js
+++ b/build-scripts/gulp/download-translations.js
@@ -127,6 +127,7 @@ gulp.task("fetch-lokalise", async function () {
replace_breaks: false,
json_unescaped_slashes: true,
export_empty_as: "skip",
+ filter_data: ["verified"],
})
.then((download) => fetch(download.bundle_url))
.then((response) => {
diff --git a/package.json b/package.json
index fbc8dcc112..bfc7041414 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,7 @@
"dependencies": {
"@babel/runtime": "7.26.0",
"@braintree/sanitize-url": "7.1.0",
- "@codemirror/autocomplete": "6.18.2",
+ "@codemirror/autocomplete": "6.18.3",
"@codemirror/commands": "6.7.1",
"@codemirror/language": "6.10.3",
"@codemirror/legacy-modes": "6.4.2",
@@ -121,7 +121,7 @@
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"lit": "2.8.0",
"luxon": "3.5.0",
- "marked": "14.1.4",
+ "marked": "15.0.0",
"memoize-one": "6.0.0",
"node-vibrant": "3.2.1-alpha.1",
"proxy-polyfill": "0.3.2",
@@ -153,7 +153,7 @@
},
"devDependencies": {
"@babel/core": "7.26.0",
- "@babel/helper-define-polyfill-provider": "0.6.2",
+ "@babel/helper-define-polyfill-provider": "0.6.3",
"@babel/plugin-proposal-decorators": "7.25.9",
"@babel/plugin-transform-runtime": "7.25.9",
"@babel/preset-env": "7.26.0",
@@ -166,7 +166,7 @@
"@octokit/rest": "21.0.2",
"@open-wc/dev-server-hmr": "0.1.4",
"@types/babel__plugin-transform-runtime": "7.9.5",
- "@types/chromecast-caf-receiver": "6.0.17",
+ "@types/chromecast-caf-receiver": "6.0.18",
"@types/chromecast-caf-sender": "1.0.10",
"@types/color-name": "2.0.0",
"@types/glob": "8.1.0",
diff --git a/src/common/datetime/format_duration.ts b/src/common/datetime/format_duration.ts
index c530a20d4a..9279a8b0ce 100644
--- a/src/common/datetime/format_duration.ts
+++ b/src/common/datetime/format_duration.ts
@@ -1,5 +1,6 @@
import type { HaDurationData } from "../../components/ha-duration-input";
import type { FrontendLocaleData } from "../../data/translation";
+import { formatListWithAnds } from "../string/format-list";
const leftPad = (num: number) => (num < 10 ? `0${num}` : num);
@@ -42,3 +43,62 @@ export const formatDuration = (
}
return null;
};
+
+export const formatDurationLong = (
+ locale: FrontendLocaleData,
+ duration: HaDurationData
+) => {
+ const d = duration.days || 0;
+ const h = duration.hours || 0;
+ const m = duration.minutes || 0;
+ const s = duration.seconds || 0;
+ const ms = duration.milliseconds || 0;
+
+ const parts: string[] = [];
+ if (d > 0) {
+ parts.push(
+ Intl.NumberFormat(locale.language, {
+ style: "unit",
+ unit: "day",
+ unitDisplay: "long",
+ }).format(d)
+ );
+ }
+ if (h > 0) {
+ parts.push(
+ Intl.NumberFormat(locale.language, {
+ style: "unit",
+ unit: "hour",
+ unitDisplay: "long",
+ }).format(h)
+ );
+ }
+ if (m > 0) {
+ parts.push(
+ Intl.NumberFormat(locale.language, {
+ style: "unit",
+ unit: "minute",
+ unitDisplay: "long",
+ }).format(m)
+ );
+ }
+ if (s > 0) {
+ parts.push(
+ Intl.NumberFormat(locale.language, {
+ style: "unit",
+ unit: "second",
+ unitDisplay: "long",
+ }).format(s)
+ );
+ }
+ if (ms > 0) {
+ parts.push(
+ Intl.NumberFormat(locale.language, {
+ style: "unit",
+ unit: "millisecond",
+ unitDisplay: "long",
+ }).format(ms)
+ );
+ }
+ return formatListWithAnds(locale, parts);
+};
diff --git a/src/components/data-table/ha-data-table-labels.ts b/src/components/data-table/ha-data-table-labels.ts
index 31dc5be185..b35656be82 100644
--- a/src/components/data-table/ha-data-table-labels.ts
+++ b/src/components/data-table/ha-data-table-labels.ts
@@ -113,7 +113,6 @@ class HaDataTableLabels extends LitElement {
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
- outline: 1px solid var(--outline-color);
}
ha-button-menu {
border-radius: 10px;
diff --git a/src/components/ha-grid-size-picker.ts b/src/components/ha-grid-size-picker.ts
index f3b5607c21..49a293e33d 100644
--- a/src/components/ha-grid-size-picker.ts
+++ b/src/components/ha-grid-size-picker.ts
@@ -2,9 +2,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../panels/lovelace/editor/card-editor/ha-grid-layout-slider";
import "./ha-icon-button";
-
import { mdiRestore } from "@mdi/js";
-import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { conditionalClamp } from "../common/number/clamp";
@@ -20,7 +18,7 @@ export class HaGridSizeEditor extends LitElement {
@property({ attribute: false }) public rows = 8;
- @property({ attribute: false }) public columns = 4;
+ @property({ attribute: false }) public columns = 12;
@property({ attribute: false }) public rowMin?: number;
@@ -32,6 +30,8 @@ export class HaGridSizeEditor extends LitElement {
@property({ attribute: false }) public isDefault?: boolean;
+ @property({ attribute: false }) public step: number = 1;
+
@state() public _localValue?: CardGridSize = { rows: 1, columns: 1 };
protected willUpdate(changedProperties) {
@@ -51,8 +51,9 @@ export class HaGridSizeEditor extends LitElement {
const rowMin = this.rowMin ?? 1;
const rowMax = this.rowMax ?? this.rows;
- const columnMin = this.columnMin ?? 1;
- const columnMax = this.columnMax ?? this.columns;
+ const columnMin = Math.ceil((this.columnMin ?? 1) / this.step) * this.step;
+ const columnMax =
+ Math.ceil((this.columnMax ?? this.columns) / this.step) * this.step;
const rowValue = autoHeight ? rowMin : this._localValue?.rows;
const columnValue = this._localValue?.columns;
@@ -67,9 +68,11 @@ export class HaGridSizeEditor extends LitElement {
.max=${columnMax}
.range=${this.columns}
.value=${fullWidth ? this.columns : this.value?.columns}
+ .step=${this.step}
@value-changed=${this._valueChanged}
@slider-moved=${this._sliderMoved}
.disabled=${disabledColumns}
+ tooltip-mode="always"
>
${!this.isDefault
? html`
@@ -102,34 +106,44 @@ export class HaGridSizeEditor extends LitElement {
`
: nothing}
-
-
- ${Array(this.rows * this.columns)
+
+
+ ${Array(this.rows)
.fill(0)
.map((_, index) => {
- const row = Math.floor(index / this.columns) + 1;
- const column = (index % this.columns) + 1;
+ const row = index + 1;
return html`
-
+
+ ${Array(this.columns)
+ .fill(0)
+ .map((__, columnIndex) => {
+ const column = columnIndex + 1;
+ if (
+ column % this.step !== 0 ||
+ (this.columns > 24 && column % 3 !== 0)
+ ) {
+ return nothing;
+ }
+ return html`
+ |
+ `;
+ })}
+
`;
})}
-
-
+
+
`;
@@ -223,42 +237,40 @@ export class HaGridSizeEditor extends LitElement {
}
.reset {
grid-area: reset;
+ --mdc-icon-button-size: 36px;
}
.preview {
position: relative;
grid-area: preview;
}
- .preview > div {
- position: relative;
- display: grid;
- grid-template-columns: repeat(var(--total-columns), 1fr);
- grid-template-rows: repeat(var(--total-rows), 25px);
- gap: 4px;
+ .preview table,
+ .preview tr,
+ .preview td {
+ border: 2px dotted var(--divider-color);
+ border-collapse: collapse;
}
- .preview .cell {
- background-color: var(--disabled-color);
- grid-column: span 1;
- grid-row: span 1;
- border-radius: 4px;
- opacity: 0.2;
- cursor: pointer;
- }
- .preview .selected {
- position: absolute;
- pointer-events: none;
- top: 0;
- left: 0;
- height: 100%;
+ .preview table {
width: 100%;
}
- .selected .cell {
- background-color: var(--primary-color);
- grid-column: 1 / span min(var(--columns, 0), var(--total-columns));
- grid-row: 1 / span min(var(--rows, 0), var(--total-rows));
- opacity: 0.5;
+ .preview tr {
+ height: 30px;
}
- .preview.full-width .selected .cell {
- grid-column: 1 / -1;
+ .preview td {
+ cursor: pointer;
+ }
+ .preview-card {
+ position: absolute;
+ top: 0;
+ left: 0;
+ background-color: var(--primary-color);
+ opacity: 0.3;
+ border-radius: 8px;
+ height: calc(var(--rows, 1) * 30px);
+ width: calc(var(--columns, 1) * 100% / var(--total-columns, 12));
+ pointer-events: none;
+ transition:
+ width ease-in-out 180ms,
+ height ease-in-out 180ms;
}
`,
];
diff --git a/src/components/ha-label.ts b/src/components/ha-label.ts
index 668f28017e..1b15d914ed 100644
--- a/src/components/ha-label.ts
+++ b/src/components/ha-label.ts
@@ -26,6 +26,7 @@ class HaLabel extends LitElement {
0.15
);
--ha-label-background-opacity: 1;
+ border: 1px solid var(--outline-color);
position: relative;
box-sizing: border-box;
display: inline-flex;
diff --git a/src/data/automation_i18n.ts b/src/data/automation_i18n.ts
index 5f8b9eb95c..2651f71246 100644
--- a/src/data/automation_i18n.ts
+++ b/src/data/automation_i18n.ts
@@ -1,6 +1,9 @@
import type { HassConfig } from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
-import { formatDuration } from "../common/datetime/format_duration";
+import {
+ formatDuration,
+ formatDurationLong,
+} from "../common/datetime/format_duration";
import {
formatTime,
formatTimeWithSeconds,
@@ -720,6 +723,38 @@ const tryDescribeTrigger = (
}`;
}
+ // Calendar Trigger
+ if (trigger.trigger === "calendar") {
+ const calendarEntity = hass.states[trigger.entity_id]
+ ? computeStateName(hass.states[trigger.entity_id])
+ : trigger.entity_id;
+
+ let offsetChoice = trigger.offset.startsWith("-") ? "before" : "after";
+ let offset: string | string[] = trigger.offset.startsWith("-")
+ ? trigger.offset.substring(1).split(":")
+ : trigger.offset.split(":");
+ const duration = {
+ hours: offset.length > 0 ? +offset[0] : 0,
+ minutes: offset.length > 1 ? +offset[1] : 0,
+ seconds: offset.length > 2 ? +offset[2] : 0,
+ };
+ offset = formatDurationLong(hass.locale, duration);
+ if (offset === "") {
+ offsetChoice = "other";
+ }
+
+ return hass.localize(
+ `${triggerTranslationBaseKey}.calendar.description.full`,
+ {
+ eventChoice: trigger.event,
+ offsetChoice: offsetChoice,
+ offset: offset,
+ hasCalendar: trigger.entity_id ? "true" : "false",
+ calendar: calendarEntity,
+ }
+ );
+ }
+
return (
hass.localize(
`ui.panel.config.automation.editor.triggers.type.${trigger.trigger}.label`
diff --git a/src/panels/config/helpers/forms/dialog-schedule-block-info.ts b/src/panels/config/helpers/forms/dialog-schedule-block-info.ts
index 7063e57ab2..b4815eec10 100644
--- a/src/panels/config/helpers/forms/dialog-schedule-block-info.ts
+++ b/src/panels/config/helpers/forms/dialog-schedule-block-info.ts
@@ -1,5 +1,6 @@
import type { CSSResultGroup } from "lit";
import { html, LitElement, nothing } from "lit";
+import memoizeOne from "memoize-one";
import { property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { createCloseHeading } from "../../../../components/ha-dialog";
@@ -13,19 +14,6 @@ import type {
} from "./show-dialog-schedule-block-info";
import type { SchemaUnion } from "../../../../components/ha-form/types";
-const SCHEMA = [
- {
- name: "from",
- required: true,
- selector: { time: { no_second: true } },
- },
- {
- name: "to",
- required: true,
- selector: { time: { no_second: true } },
- },
-];
-
class DialogScheduleBlockInfo extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -35,10 +23,39 @@ class DialogScheduleBlockInfo extends LitElement {
@state() private _params?: ScheduleBlockInfoDialogParams;
+ private _expand = false;
+
+ private _schema = memoizeOne((expand: boolean) => [
+ {
+ name: "from",
+ required: true,
+ selector: { time: { no_second: true } },
+ },
+ {
+ name: "to",
+ required: true,
+ selector: { time: { no_second: true } },
+ },
+ {
+ name: "advanced_settings",
+ type: "expandable" as const,
+ flatten: true,
+ expanded: expand,
+ schema: [
+ {
+ name: "data",
+ required: false,
+ selector: { object: {} },
+ },
+ ],
+ },
+ ]);
+
public showDialog(params: ScheduleBlockInfoDialogParams): void {
this._params = params;
this._error = undefined;
this._data = params.block;
+ this._expand = !!params.block?.data;
}
public closeDialog(): void {
@@ -66,7 +83,7 @@ class DialogScheduleBlockInfo extends LitElement {
) => {
+ private _computeLabelCallback = (
+ schema: SchemaUnion>
+ ) => {
switch (schema.name) {
case "from":
return this.hass!.localize("ui.dialogs.helper_settings.schedule.start");
case "to":
return this.hass!.localize("ui.dialogs.helper_settings.schedule.end");
+ case "data":
+ return this.hass!.localize("ui.dialogs.helper_settings.schedule.data");
+ case "advanced_settings":
+ return this.hass!.localize(
+ "ui.dialogs.helper_settings.schedule.advanced_settings"
+ );
}
return "";
};
diff --git a/src/panels/config/helpers/forms/show-dialog-schedule-block-info.ts b/src/panels/config/helpers/forms/show-dialog-schedule-block-info.ts
index 14a5909592..0a95789f86 100644
--- a/src/panels/config/helpers/forms/show-dialog-schedule-block-info.ts
+++ b/src/panels/config/helpers/forms/show-dialog-schedule-block-info.ts
@@ -3,6 +3,7 @@ import { fireEvent } from "../../../../common/dom/fire_event";
export interface ScheduleBlockInfo {
from: string;
to: string;
+ data?: Record;
}
export interface ScheduleBlockInfoDialogParams {
diff --git a/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-color-switch.ts b/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-color-switch.ts
index d7facea8ac..69ee23b3f1 100644
--- a/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-color-switch.ts
+++ b/src/panels/config/integrations/integration-panels/zwave_js/capability-controls/zwave_js-capability-control-color-switch.ts
@@ -8,6 +8,18 @@ import "../../../../../../components/ha-circular-progress";
import { extractApiErrorMessage } from "../../../../../../data/hassio/common";
import "./zwave_js-capability-control-multilevel-switch";
+enum ColorComponent {
+ "Warm White" = 0,
+ "Cold White",
+ Red,
+ Green,
+ Blue,
+ Amber,
+ Cyan,
+ Purple,
+ Index,
+}
+
@customElement("zwave_js-capability-control-color_switch")
class ZWaveJSCapabilityColorSwitch extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -20,7 +32,7 @@ class ZWaveJSCapabilityColorSwitch extends LitElement {
@property({ type: Number }) public version!: number;
- @state() private _color_components?: number[];
+ @state() private _color_components?: ColorComponent[];
@state() private _error?: string;
@@ -37,7 +49,9 @@ class ZWaveJSCapabilityColorSwitch extends LitElement {
${this.hass.localize(
"ui.panel.config.zwave_js.node_installer.capability_controls.color_switch.color_component"
)}:
- ${color}
+ ${this.hass.localize(
+ `ui.panel.config.zwave_js.node_installer.capability_controls.color_switch.colors.${color}`
+ )}
= {};
@state() private _error?: string;
@@ -183,17 +187,19 @@ class ZWaveJSNodeConfig extends LitElement {
`
)}
-
-
- ${this.hass.localize(
- "ui.panel.config.zwave_js.node_config.reset_to_default.button_label"
- )}
-
-
+ ${this._canResetAll
+ ? html`
+
+ ${this.hass.localize(
+ "ui.panel.config.zwave_js.node_config.reset_to_default.button_label"
+ )}
+
+
`
+ : nothing}
${this.hass.localize(
"ui.panel.config.zwave_js.node_config.custom_config"
@@ -468,10 +474,19 @@ class ZWaveJSNodeConfig extends LitElement {
return;
}
- [this._nodeMetadata, this._config] = await Promise.all([
+ let capabilities: ZWaveJSNodeCapabilities | undefined;
+ [this._nodeMetadata, this._config, capabilities] = await Promise.all([
fetchZwaveNodeMetadata(this.hass, device.id),
fetchZwaveNodeConfigParameters(this.hass, device.id),
+ fetchZwaveNodeCapabilities(this.hass, device.id),
]);
+ this._canResetAll =
+ capabilities &&
+ Object.values(capabilities).some((endpoint) =>
+ endpoint.some(
+ (capability) => capability.id === 0x70 && capability.version >= 4
+ )
+ );
}
private async _openResetDialog(event: Event) {
diff --git a/src/panels/config/labels/ha-config-labels.ts b/src/panels/config/labels/ha-config-labels.ts
index 1d974bb7a5..2e52ddb663 100644
--- a/src/panels/config/labels/ha-config-labels.ts
+++ b/src/panels/config/labels/ha-config-labels.ts
@@ -106,7 +106,8 @@ export class HaConfigLabels extends LitElement {
style="
background-color: ${computeCssColor(label.color)};
border-radius: 10px;
- outline: 1px solid var(--outline-color);
+ border: 1px solid var(--outline-color);
+ box-sizing: border-box;
width: 20px;
height: 20px;"
>
`
diff --git a/src/panels/config/script/ha-script-editor.ts b/src/panels/config/script/ha-script-editor.ts
index a3eaaea6a4..88da99c7af 100644
--- a/src/panels/config/script/ha-script-editor.ts
+++ b/src/panels/config/script/ha-script-editor.ts
@@ -79,6 +79,8 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
@state() private _errors?: string;
+ @state() private _yamlErrors?: string;
+
@state() private _entityId?: string;
@state() private _mode: "gui" | "yaml" = "gui";
@@ -602,12 +604,14 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
private _yamlChanged(ev: CustomEvent) {
ev.stopPropagation();
+ this._dirty = true;
if (!ev.detail.isValid) {
+ this._yamlErrors = ev.detail.errorMsg;
return;
}
+ this._yamlErrors = undefined;
this._config = ev.detail.value;
this._errors = undefined;
- this._dirty = true;
}
private async confirmUnsavedChanged(): Promise {
@@ -723,7 +727,21 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
history.back();
}
- private _switchUiMode() {
+ private async _switchUiMode() {
+ if (this._yamlErrors) {
+ const result = await showConfirmationDialog(this, {
+ text: html`${this.hass.localize(
+ "ui.panel.config.automation.editor.switch_ui_yaml_error"
+ )}
${this._yamlErrors}`,
+ confirmText: this.hass!.localize("ui.common.continue"),
+ destructive: true,
+ dismissText: this.hass!.localize("ui.common.cancel"),
+ });
+ if (!result) {
+ return;
+ }
+ }
+ this._yamlErrors = undefined;
this._mode = "gui";
}
@@ -763,6 +781,13 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
}
private async _saveScript(): Promise {
+ if (this._yamlErrors) {
+ showToast(this, {
+ message: this._yamlErrors,
+ });
+ return;
+ }
+
if (!this.scriptId) {
const saved = await this._promptScriptAlias();
if (!saved) {
diff --git a/src/panels/lovelace/common/compute-card-grid-size.ts b/src/panels/lovelace/common/compute-card-grid-size.ts
index cc0be5ac76..49bc4946ca 100644
--- a/src/panels/lovelace/common/compute-card-grid-size.ts
+++ b/src/panels/lovelace/common/compute-card-grid-size.ts
@@ -3,16 +3,11 @@ import type { LovelaceGridOptions, LovelaceLayoutOptions } from "../types";
export const GRID_COLUMN_MULTIPLIER = 3;
-export const multiplyBy = (
+const multiplyBy = (
value: T,
multiplier: number
): T => (typeof value === "number" ? ((value * multiplier) as T) : value);
-export const divideBy = (
- value: T,
- divider: number
-): T => (typeof value === "number" ? (Math.ceil(value / divider) as T) : value);
-
export const migrateLayoutToGridOptions = (
options: LovelaceLayoutOptions
): LovelaceGridOptions => {
@@ -42,6 +37,9 @@ export type CardGridSize = {
columns: number | "full";
};
+export const isPreciseMode = (options: LovelaceGridOptions) =>
+ typeof options.columns === "number" && options.columns % 3 !== 0;
+
export const computeCardGridSize = (
options: LovelaceGridOptions
): CardGridSize => {
diff --git a/src/panels/lovelace/common/directives/action-handler-directive.ts b/src/panels/lovelace/common/directives/action-handler-directive.ts
index d39db84b6b..ef931d895b 100644
--- a/src/panels/lovelace/common/directives/action-handler-directive.ts
+++ b/src/panels/lovelace/common/directives/action-handler-directive.ts
@@ -149,7 +149,10 @@ class ActionHandler extends HTMLElement implements ActionHandlerType {
element.actionHandler.end = (ev: Event) => {
// Don't respond when moved or scrolled while touch
- if (["touchend", "touchcancel"].includes(ev.type) && this.cancelled) {
+ if (
+ ev.type === "touchcancel" ||
+ (ev.type === "touchend" && this.cancelled)
+ ) {
return;
}
const target = ev.target as HTMLElement;
diff --git a/src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts b/src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts
index 206436ce95..e133be40c1 100644
--- a/src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts
+++ b/src/panels/lovelace/editor/card-editor/ha-grid-layout-slider.ts
@@ -23,6 +23,8 @@ const A11Y_KEY_CODES = new Set([
"End",
]);
+type TooltipMode = "never" | "always" | "interaction";
+
@customElement("ha-grid-layout-slider")
export class HaGridLayoutSlider extends LitElement {
@property({ type: Boolean, reflect: true })
@@ -34,6 +36,9 @@ export class HaGridLayoutSlider extends LitElement {
@property({ attribute: "touch-action" })
public touchAction?: string;
+ @property({ attribute: "tooltip-mode" })
+ public tooltipMode: TooltipMode = "interaction";
+
@property({ type: Number })
public value?: number;
@@ -52,6 +57,9 @@ export class HaGridLayoutSlider extends LitElement {
@state()
public pressed = false;
+ @state()
+ public tooltipVisible = false;
+
private _mc?: HammerManager;
private get _range() {
@@ -135,11 +143,13 @@ export class HaGridLayoutSlider extends LitElement {
this._mc.on("panstart", () => {
if (this.disabled) return;
this.pressed = true;
+ this._showTooltip();
savedValue = this.value;
});
this._mc.on("pancancel", () => {
if (this.disabled) return;
this.pressed = false;
+ this._hideTooltip();
this.value = savedValue;
});
this._mc.on("panmove", (e) => {
@@ -152,6 +162,7 @@ export class HaGridLayoutSlider extends LitElement {
this._mc.on("panend", (e) => {
if (this.disabled) return;
this.pressed = false;
+ this._hideTooltip();
const percentage = this._getPercentageFromEvent(e);
const value = this._percentageToValue(percentage);
this.value = this._steppedValue(this._boundedValue(value));
@@ -223,6 +234,23 @@ export class HaGridLayoutSlider extends LitElement {
fireEvent(this, "value-changed", { value: this.value });
}
+ private _tooltipTimeout?: number;
+
+ _showTooltip() {
+ if (this._tooltipTimeout != null) window.clearTimeout(this._tooltipTimeout);
+ this.tooltipVisible = true;
+ }
+
+ _hideTooltip(delay?: number) {
+ if (!delay) {
+ this.tooltipVisible = false;
+ return;
+ }
+ this._tooltipTimeout = window.setTimeout(() => {
+ this.tooltipVisible = false;
+ }, delay);
+ }
+
private _getPercentageFromEvent = (e: HammerInput) => {
if (this.vertical) {
const y = e.center.y;
@@ -236,6 +264,30 @@ export class HaGridLayoutSlider extends LitElement {
return Math.max(Math.min(1, (x - offset) / total), 0);
};
+ private _renderTooltip() {
+ if (this.tooltipMode === "never") return nothing;
+
+ const position = this.vertical ? "left" : "top";
+
+ const visible =
+ this.tooltipMode === "always" ||
+ (this.tooltipVisible && this.tooltipMode === "interaction");
+
+ const value = this._boundedValue(this._steppedValue(this.value ?? 0));
+
+ return html`
+
+ ${value}
+
+ `;
+ }
+
protected render(): TemplateResult {
return html`
+
+ ${Array(this._range / this.step)
+ .fill(0)
+ .map((_, i) => {
+ const percentage = i / (this._range / this.step);
+ const disabled =
+ this.min >= i * this.step || i * this.step > this.max;
+ if (disabled) {
+ return nothing;
+ }
+ return html`
+
+ `;
+ })}
${this.value !== undefined
? html``
: nothing}
+ ${this._renderTooltip()}
`;
@@ -269,7 +341,7 @@ export class HaGridLayoutSlider extends LitElement {
return css`
:host {
display: block;
- --grid-layout-slider: 48px;
+ --grid-layout-slider: 36px;
height: var(--grid-layout-slider);
width: 100%;
outline: none;
@@ -297,6 +369,7 @@ export class HaGridLayoutSlider extends LitElement {
}
.slider * {
pointer-events: none;
+ user-select: none;
}
.track {
position: absolute;
@@ -315,12 +388,11 @@ export class HaGridLayoutSlider extends LitElement {
position: absolute;
inset: 0;
background: var(--disabled-color);
- opacity: 0.2;
+ opacity: 0.4;
}
.active {
position: absolute;
- background: grey;
- opacity: 0.7;
+ background: var(--primary-color);
top: 0;
right: calc(var(--max) * 100%);
bottom: 0;
@@ -351,6 +423,27 @@ export class HaGridLayoutSlider extends LitElement {
height: 16px;
width: 100%;
}
+ .dot {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ opacity: 0.6;
+ margin: auto;
+ width: 4px;
+ height: 4px;
+ flex-shrink: 0;
+ transform: translate(-50%, 0);
+ background: var(--card-background-color);
+ left: calc(var(--value, 0%) * 100%);
+ border-radius: 2px;
+ }
+ :host([vertical]) .dot {
+ transform: translate(0, -50%);
+ left: 0;
+ right: 0;
+ bottom: inherit;
+ top: calc(var(--value, 0%) * 100%);
+ }
.handle::after {
position: absolute;
inset: 0;
@@ -358,7 +451,7 @@ export class HaGridLayoutSlider extends LitElement {
border-radius: 2px;
height: 100%;
margin: auto;
- background: grey;
+ background: var(--primary-color);
content: "";
}
:host([vertical]) .handle::after {
@@ -374,9 +467,88 @@ export class HaGridLayoutSlider extends LitElement {
:host(:disabled) .active {
background: var(--disabled-color);
}
+
+ .tooltip {
+ position: absolute;
+ background-color: var(--clear-background-color);
+ color: var(--primary-text-color);
+ font-size: var(--control-slider-tooltip-font-size);
+ border-radius: 0.8em;
+ padding: 0.2em 0.4em;
+ opacity: 0;
+ white-space: nowrap;
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
+ transition:
+ opacity 180ms ease-in-out,
+ left 180ms ease-in-out,
+ bottom 180ms ease-in-out;
+ --handle-spacing: calc(2 * var(--handle-margin) + var(--handle-size));
+ --slider-tooltip-margin: 0px;
+ --slider-tooltip-range: 100%;
+ --slider-tooltip-offset: 0px;
+ --slider-tooltip-position: calc(
+ min(
+ max(
+ var(--value) * var(--slider-tooltip-range) +
+ var(--slider-tooltip-offset),
+ 0%
+ ),
+ 100%
+ )
+ );
+ }
+ .tooltip.start {
+ --slider-tooltip-offset: calc(-0.5 * (var(--handle-spacing)));
+ }
+ .tooltip.end {
+ --slider-tooltip-offset: calc(0.5 * (var(--handle-spacing)));
+ }
+ .tooltip.cursor {
+ --slider-tooltip-range: calc(100% - var(--handle-spacing));
+ --slider-tooltip-offset: calc(0.5 * (var(--handle-spacing)));
+ }
+ .tooltip.show-handle {
+ --slider-tooltip-range: calc(100% - var(--handle-spacing));
+ --slider-tooltip-offset: calc(0.5 * (var(--handle-spacing)));
+ }
+ .tooltip.visible {
+ opacity: 1;
+ }
+ .tooltip.top {
+ transform: translate3d(-50%, -100%, 0);
+ top: var(--slider-tooltip-margin);
+ left: 50%;
+ }
+ .tooltip.bottom {
+ transform: translate3d(-50%, 100%, 0);
+ bottom: var(--slider-tooltip-margin);
+ left: 50%;
+ }
+ .tooltip.left {
+ transform: translate3d(-100%, -50%, 0);
+ top: 50%;
+ left: var(--slider-tooltip-margin);
+ }
+ .tooltip.right {
+ transform: translate3d(100%, -50%, 0);
+ top: 50%;
+ right: var(--slider-tooltip-margin);
+ }
+ :host(:not([vertical])) .tooltip.top,
+ :host(:not([vertical])) .tooltip.bottom {
+ left: var(--slider-tooltip-position);
+ }
+ :host([vertical]) .tooltip.right,
+ :host([vertical]) .tooltip.left {
+ top: var(--slider-tooltip-position);
+ }
+
.pressed .handle {
transition: none;
}
+ .pressed .tooltip {
+ transition: opacity 180ms ease-in-out;
+ }
`;
}
}
diff --git a/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts b/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts
index 494dcd9edb..a79e6eeccd 100644
--- a/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts
+++ b/src/panels/lovelace/editor/card-editor/hui-card-layout-editor.ts
@@ -26,16 +26,12 @@ import type { HuiCard } from "../../cards/hui-card";
import type { CardGridSize } from "../../common/compute-card-grid-size";
import {
computeCardGridSize,
- divideBy,
GRID_COLUMN_MULTIPLIER,
+ isPreciseMode,
migrateLayoutToGridOptions,
- multiplyBy,
} from "../../common/compute-card-grid-size";
import type { LovelaceGridOptions } from "../../types";
-const computePreciseMode = (columns?: number | string) =>
- typeof columns === "number" && columns % 3 !== 0;
-
@customElement("hui-card-layout-editor")
export class HuiCardLayoutEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -63,22 +59,6 @@ export class HuiCardLayoutEditor extends LitElement {
private _computeCardGridSize = memoizeOne(computeCardGridSize);
- private _simplifyOptions = (
- options: LovelaceGridOptions
- ): LovelaceGridOptions => ({
- ...options,
- columns: divideBy(options.columns, GRID_COLUMN_MULTIPLIER),
- max_columns: divideBy(options.max_columns, GRID_COLUMN_MULTIPLIER),
- min_columns: divideBy(options.min_columns, GRID_COLUMN_MULTIPLIER),
- });
-
- private _standardizeOptions = (options: LovelaceGridOptions) => ({
- ...options,
- columns: multiplyBy(options.columns, GRID_COLUMN_MULTIPLIER),
- max_columns: multiplyBy(options.max_columns, GRID_COLUMN_MULTIPLIER),
- min_columns: multiplyBy(options.min_columns, GRID_COLUMN_MULTIPLIER),
- });
-
private _isDefault = memoizeOne(
(options?: LovelaceGridOptions) =>
options?.columns === undefined && options?.rows === undefined
@@ -101,14 +81,11 @@ export class HuiCardLayoutEditor extends LitElement {
this._defaultGridOptions
);
- const gridOptions = this._preciseMode
- ? options
- : this._simplifyOptions(options);
+ const gridOptions = options;
const gridValue = this._computeCardGridSize(gridOptions);
const columnSpan = this.sectionConfig.column_span ?? 1;
- const gridTotalColumns =
- (12 * columnSpan) / (this._preciseMode ? 1 : GRID_COLUMN_MULTIPLIER);
+ const gridTotalColumns = 12 * columnSpan;
return html`