diff --git a/cast/src/receiver/layout/hc-lovelace.ts b/cast/src/receiver/layout/hc-lovelace.ts index 87cc165d9774..0cf3d5121d45 100644 --- a/cast/src/receiver/layout/hc-lovelace.ts +++ b/cast/src/receiver/layout/hc-lovelace.ts @@ -14,6 +14,7 @@ import "../../../../src/panels/lovelace/views/hui-view"; import "../../../../src/panels/lovelace/views/hui-view-container"; import type { HomeAssistant } from "../../../../src/types"; import "./hc-launch-screen"; +import "../../../../src/panels/lovelace/views/hui-view-background"; (window as any).loadCardHelpers = () => import("../../../../src/panels/lovelace/custom-card-helpers"); @@ -57,11 +58,8 @@ class HcLovelace extends LitElement { const background = viewConfig.background || this.lovelaceConfig.background; return html` - + + string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = true; + + protected render() { + const options = + this.selector.button_toggle?.options?.map((option) => + typeof option === "object" + ? (option as SelectOption) + : ({ value: option, label: option } as SelectOption) + ) || []; + + const translationKey = this.selector.button_toggle?.translation_key; + + if (this.localizeValue && translationKey) { + options.forEach((option) => { + const localizedLabel = this.localizeValue!( + `${translationKey}.options.${option.value}` + ); + if (localizedLabel) { + option.label = localizedLabel; + } + }); + } + + if (this.selector.button_toggle?.sort) { + options.sort((a, b) => + caseInsensitiveStringCompare( + a.label, + b.label, + this.hass.locale.language + ) + ); + } + + const toggleButtons: ToggleButton[] = options.map((item: SelectOption) => ({ + label: item.label, + value: item.value, + })); + + return html` + ${this.label} + + `; + } + + private _valueChanged(ev) { + ev.stopPropagation(); + + const value = ev.detail?.value || ev.target.value; + if (this.disabled || value === undefined || value === (this.value ?? "")) { + return; + } + fireEvent(this, "value-changed", { + value: value, + }); + } + + static styles = css` + :host { + position: relative; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-button_toggle": HaButtonToggleSelector; + } +} diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index a41b602b4d64..6b50b9329f89 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -51,6 +51,7 @@ const LOAD_ELEMENTS = { icon: () => import("./ha-selector-icon"), media: () => import("./ha-selector-media"), theme: () => import("./ha-selector-theme"), + button_toggle: () => import("./ha-selector-button-toggle"), trigger: () => import("./ha-selector-trigger"), tts: () => import("./ha-selector-tts"), tts_voice: () => import("./ha-selector-tts-voice"), diff --git a/src/data/lovelace/config/view.ts b/src/data/lovelace/config/view.ts index dd784c628a1e..89a96b1a54f9 100644 --- a/src/data/lovelace/config/view.ts +++ b/src/data/lovelace/config/view.ts @@ -7,8 +7,22 @@ export interface ShowViewConfig { user?: string; } -interface LovelaceViewBackgroundConfig { +export interface LovelaceViewBackgroundConfig { image?: string; + transparency?: number; + size?: "auto" | "cover" | "contain"; + alignment?: + | "top left" + | "top center" + | "top right" + | "center left" + | "center" + | "center right" + | "bottom left" + | "bottom center" + | "bottom right"; + repeat?: "repeat" | "no-repeat"; + attachment?: "scroll" | "fixed"; } export interface LovelaceBaseViewConfig { diff --git a/src/data/selector.ts b/src/data/selector.ts index 7228624d70d4..40fb7e36ce1a 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -26,6 +26,7 @@ export type Selector = | AreaFilterSelector | AttributeSelector | BooleanSelector + | ButtonToggleSelector | ColorRGBSelector | ColorTempSelector | ConditionSelector @@ -108,6 +109,14 @@ export interface BooleanSelector { boolean: {} | null; } +export interface ButtonToggleSelector { + button_toggle: { + options: readonly string[] | readonly SelectOption[]; + translation_key?: string; + sort?: boolean; + } | null; +} + export interface ColorRGBSelector { // eslint-disable-next-line @typescript-eslint/ban-types color_rgb: {} | null; diff --git a/src/panels/lovelace/editor/view-editor/hui-view-background-editor.ts b/src/panels/lovelace/editor/view-editor/hui-view-background-editor.ts index 1ab7e5fe1181..794e78e5a552 100644 --- a/src/panels/lovelace/editor/view-editor/hui-view-background-editor.ts +++ b/src/panels/lovelace/editor/view-editor/hui-view-background-editor.ts @@ -1,13 +1,14 @@ import "@material/mwc-list/mwc-list-item"; import type { CSSResultGroup } from "lit"; +import memoizeOne from "memoize-one"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-form/ha-form"; +import type { SchemaUnion } from "../../../../components/ha-form/types"; import "../../../../components/ha-selector/ha-selector-image"; import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view"; -import type { HomeAssistant, ValueChangedEvent } from "../../../../types"; - -const SELECTOR = { image: { original: true } }; +import type { HomeAssistant } from "../../../../types"; @customElement("hui-view-background-editor") export class HuiViewBackgroundEditor extends LitElement { @@ -19,44 +20,162 @@ export class HuiViewBackgroundEditor extends LitElement { this._config = config; } + private _localizeValueCallback = (key: string) => + this.hass.localize(key as any); + + private _schema = memoizeOne((showSettings: boolean) => [ + { + name: "image", + selector: { image: { original: true } }, + }, + ...(showSettings + ? ([ + { + name: "settings", + flatten: true, + expanded: true, + type: "expandable" as const, + schema: [ + { + name: "transparency", + selector: { + number: { min: 1, max: 100, mode: "slider" }, + }, + }, + { + name: "attachment", + selector: { + button_toggle: { + translation_key: + "ui.panel.lovelace.editor.edit_view.background.attachment", + options: ["scroll", "fixed"], + }, + }, + }, + { + name: "size", + selector: { + select: { + translation_key: + "ui.panel.lovelace.editor.edit_view.background.size", + options: ["auto", "cover", "contain"], + }, + }, + }, + { + name: "alignment", + selector: { + select: { + translation_key: + "ui.panel.lovelace.editor.edit_view.background.alignment", + options: [ + "top left", + "top center", + "top right", + "center left", + "center", + "center right", + "bottom left", + "bottom center", + "bottom right", + ], + }, + }, + }, + { + name: "repeat", + selector: { + select: { + translation_key: + "ui.panel.lovelace.editor.edit_view.background.repeat", + options: ["repeat", "no-repeat"], + }, + }, + }, + ], + }, + ] as const) + : []), + ]); + protected render() { if (!this.hass) { return nothing; } - const background = this._config?.background; - const backgroundUrl = - typeof background === "string" - ? background.match(/url\(['"]?([^'"]+)['"]?\)/)?.[1] - : background?.image; + let background = this._config?.background; + if (typeof background === "string") { + const backgroundUrl = background.match(/url\(['"]?([^'"]+)['"]?\)/)?.[1]; + + background = { + image: backgroundUrl, + }; + } + + background = { + transparency: 100, + alignment: "center", + size: "auto", + repeat: "no-repeat", + attachment: "scroll", + ...background, + }; return html` - + .data=${background} + .schema=${this._schema(true)} + .computeLabel=${this._computeLabelCallback} + @value-changed=${this._valueChanged} + .localizeValue=${this._localizeValueCallback} + > `; } - private _backgroundChanged(ev: ValueChangedEvent) { - const backgroundUrl = ev.detail.value; + private _valueChanged(ev: CustomEvent): void { const config = { ...this._config, - background: { - ...(typeof this._config.background === "string" - ? {} - : this._config.background), - image: backgroundUrl || undefined, - }, + background: ev.detail.value, }; fireEvent(this, "view-config-changed", { config }); } + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { + switch (schema.name) { + case "image": + return this.hass.localize( + "ui.panel.lovelace.editor.edit_view.background.image" + ); + case "transparency": + return this.hass.localize( + "ui.panel.lovelace.editor.edit_view.background.transparency" + ); + case "alignment": + return this.hass.localize( + "ui.panel.lovelace.editor.edit_view.background.alignment.name" + ); + case "size": + return this.hass.localize( + "ui.panel.lovelace.editor.edit_view.background.size.name" + ); + case "repeat": + return this.hass.localize( + "ui.panel.lovelace.editor.edit_view.background.repeat.name" + ); + case "attachment": + return this.hass.localize( + "ui.panel.lovelace.editor.edit_view.background.attachment.name" + ); + default: + return this.hass.localize( + `ui.panel.lovelace.editor.edit_view.background.${schema.name}` + ); + } + }; + static get styles(): CSSResultGroup { return css` :host { diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts index 3fd083ccf448..2ade6634cb58 100644 --- a/src/panels/lovelace/hui-root.ts +++ b/src/panels/lovelace/hui-root.ts @@ -78,6 +78,7 @@ import type { Lovelace } from "./types"; import "./views/hui-view"; import "./views/hui-view-container"; import type { HUIView } from "./views/hui-view"; +import "./views/hui-view-background"; @customElement("hui-root") class HUIRoot extends LitElement { @@ -469,11 +470,11 @@ class HUIRoot extends LitElement { + `; diff --git a/src/panels/lovelace/views/hui-view-background.ts b/src/panels/lovelace/views/hui-view-background.ts new file mode 100644 index 000000000000..bf322b4170ba --- /dev/null +++ b/src/panels/lovelace/views/hui-view-background.ts @@ -0,0 +1,130 @@ +import { css, LitElement, nothing } from "lit"; +import type { CSSResultGroup, PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators"; +import type { HomeAssistant } from "../../../types"; +import type { LovelaceViewBackgroundConfig } from "../../../data/lovelace/config/view"; + +@customElement("hui-view-background") +export class HUIViewBackground extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) background?: + | string + | LovelaceViewBackgroundConfig + | undefined; + + protected render() { + return nothing; + } + + private _applyTheme() { + const computedStyles = getComputedStyle(this); + const themeBackground = computedStyles.getPropertyValue( + "--lovelace-background" + ); + + const fixedBackground = this._isFixedBackground( + this.background || themeBackground + ); + const viewBackground = this._computeBackgroundProperty(this.background); + this.toggleAttribute("fixed-background", fixedBackground); + this.style.setProperty("--view-background", viewBackground); + + const viewBackgroundOpacity = this._computeBackgroundOpacityProperty( + this.background + ); + this.style.setProperty("--view-background-opacity", viewBackgroundOpacity); + } + + private _isFixedBackground( + background?: string | LovelaceViewBackgroundConfig + ) { + if (typeof background === "string") { + return background.split(" ").includes("fixed"); + } + if (typeof background === "object" && background.attachment === "fixed") { + return true; + } + return false; + } + + private _computeBackgroundProperty( + background?: string | LovelaceViewBackgroundConfig + ) { + if (typeof background === "object" && background.image) { + const size = background.size ?? "auto"; + const alignment = background.alignment ?? "center"; + const repeat = background.repeat ?? "no-repeat"; + return `${alignment} / ${size} ${repeat} url('${background.image}')`; + } + if (typeof background === "string") { + return background; + } + return null; + } + + private _computeBackgroundOpacityProperty( + background?: string | LovelaceViewBackgroundConfig + ) { + if (typeof background === "object" && background.image) { + if (background.transparency) { + return `${background.transparency}%`; + } + } + return null; + } + + protected willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + if (changedProperties.has("hass") && this.hass) { + const oldHass = changedProperties.get("hass"); + if ( + !oldHass || + this.hass.themes !== oldHass.themes || + this.hass.selectedTheme !== oldHass.selectedTheme + ) { + this._applyTheme(); + return; + } + } + + if (changedProperties.has("background")) { + this._applyTheme(); + } + } + + static get styles(): CSSResultGroup { + return css` + /* Fixed background hack for Safari iOS */ + :host([fixed-background]) { + display: block; + z-index: -1; + position: fixed; + background-attachment: scroll !important; + } + :host(:not([fixed-background])) { + z-index: -1; + position: absolute; + } + :host { + top: 0; + left: 0; + right: 0; + bottom: 0; + height: 100%; + width: 100%; + background: var( + --view-background, + var(--lovelace-background, var(--primary-background-color)) + ); + opacity: var(--view-background-opacity); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-view-background": HUIViewBackground; + } +} diff --git a/src/panels/lovelace/views/hui-view-container.ts b/src/panels/lovelace/views/hui-view-container.ts index bf74f55dc654..5a344b93b280 100644 --- a/src/panels/lovelace/views/hui-view-container.ts +++ b/src/panels/lovelace/views/hui-view-container.ts @@ -45,23 +45,6 @@ class HuiViewContainer extends LitElement { ); } - private _isFixedBackground(background?: BackgroundConfig) { - if (typeof background === "string") { - return background.split(" ").includes("fixed"); - } - return false; - } - - private _computeBackgroundProperty(background?: BackgroundConfig) { - if (typeof background === "object" && background.image) { - return `center / cover no-repeat url('${background.image}')`; - } - if (typeof background === "string") { - return background; - } - return null; - } - protected willUpdate(changedProperties: PropertyValues) { super.willUpdate(changedProperties); if (changedProperties.has("hass") && this.hass) { @@ -76,7 +59,7 @@ class HuiViewContainer extends LitElement { } } - if (changedProperties.has("theme") || changedProperties.has("background")) { + if (changedProperties.has("theme")) { this._applyTheme(); } } @@ -89,18 +72,6 @@ class HuiViewContainer extends LitElement { if (this.hass) { applyThemesOnElement(this, this.hass?.themes, this.theme); } - - const computedStyles = getComputedStyle(this); - const themeBackground = computedStyles.getPropertyValue( - "--lovelace-background" - ); - - const fixedBackground = this._isFixedBackground( - this.background || themeBackground - ); - const viewBackground = this._computeBackgroundProperty(this.background); - this.toggleAttribute("fixed-background", fixedBackground); - this.style.setProperty("--view-background", viewBackground); } static get styles(): CSSResultGroup { @@ -108,30 +79,6 @@ class HuiViewContainer extends LitElement { :host { display: relative; } - /* Fixed background hack for Safari iOS */ - :host([fixed-background]) ::slotted(*):before { - display: block; - content: ""; - z-index: -1; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - height: 100%; - width: 100%; - background: var( - --view-background, - var(--lovelace-background, var(--primary-background-color)) - ); - background-attachment: scroll !important; - } - :host(:not([fixed-background])) { - background: var( - --view-background, - var(--lovelace-background, var(--primary-background-color)) - ); - } `; } } diff --git a/src/translations/en.json b/src/translations/en.json index 8a78108a97c7..48c93626d205 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5926,7 +5926,45 @@ "header_name": "{name} View Configuration", "add": "Add view", "background": { - "title": "Add a background to the view" + "settings": "Background settings", + "image": "Background image", + "size": { + "name": "Background size", + "options": { + "auto": "Original", + "cover": "Fill view", + "contain": "Fit view" + } + }, + "alignment": { + "name": "Background alignment", + "options": { + "top left": "Top left", + "top center": "Top center", + "top right": "Top right", + "center left": "Center left", + "center": "Center", + "center right": "Center right", + "bottom left": "Bottom left", + "bottom center": "Bottom center", + "bottom right": "Bottom right" + } + }, + "transparency": "Background transparency", + "repeat": { + "name": "Background repeat", + "options": { + "repeat": "Repeat (tile)", + "no-repeat": "No repeat" + } + }, + "attachment": { + "name": "Background attachment", + "options": { + "scroll": "Scroll", + "fixed": "Fixed" + } + } }, "edit": "Edit view", "delete": "Delete view",