+
+
+ ${displayType === "compact"
+ ? this._renderAlertSensorBadge()
+ : nothing}
+ ${icon
+ ? html``
+ : html`
+
+ `}
+
+
+
+ ${features.length > 0
+ ? html`
+
`
: nothing}
-
-
-
- ${ALERT_DOMAINS.map((domain) => {
- if (!(domain in entitiesByDomain)) {
- return nothing;
- }
- return this._deviceClasses[domain].map((deviceClass) => {
- const entity = this._isOn(domain, deviceClass);
- return entity
- ? html`
-
- `
- : nothing;
- });
- })}
-
-
-
-
${area.name}
- ${sensors.length
- ? html`
${sensors}
`
- : ""}
-
-
- ${TOGGLE_DOMAINS.map((domain) => {
- if (!(domain in entitiesByDomain)) {
- return "";
- }
-
- const on = this._isOn(domain)!;
- return TOGGLE_DOMAINS.includes(domain)
- ? html`
-
-
- `
- : "";
- })}
-
-
`;
}
- protected updated(changedProps: PropertyValues): void {
- super.updated(changedProps);
- if (!this._config || !this.hass) {
- return;
- }
- const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
- const oldConfig = changedProps.get("_config") as AreaCardConfig | undefined;
-
- if (
- (changedProps.has("hass") &&
- (!oldHass || oldHass.themes !== this.hass.themes)) ||
- (changedProps.has("_config") &&
- (!oldConfig || oldConfig.theme !== this._config.theme))
- ) {
- applyThemesOnElement(this, this.hass.themes, this._config.theme);
- }
- }
-
- private _handleNavigation() {
- if (this._config!.navigation_path) {
- navigate(this._config!.navigation_path);
- }
- }
-
- private _toggle(ev: Event) {
- ev.stopPropagation();
- const domain = (ev.currentTarget as any).domain as string;
- if (TOGGLE_DOMAINS.includes(domain)) {
- this.hass.callService(
- domain,
- this._isOn(domain) ? "turn_off" : "turn_on",
- undefined,
- {
- area_id: this._config!.area,
- }
- );
- }
- forwardHaptic("light");
- }
-
- getGridOptions(): LovelaceGridOptions {
- return {
- columns: 12,
- rows: 3,
- min_columns: 3,
- };
- }
-
static styles = css`
- ha-card {
- overflow: hidden;
- position: relative;
- background-size: cover;
- height: 100%;
+ :host {
+ --tile-color: var(--state-icon-color);
+ -webkit-tap-highlight-color: transparent;
}
-
- .container {
+ ha-card:has(.background:focus-visible) {
+ --shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
+ --shadow-focus: 0 0 0 1px var(--tile-color);
+ border-color: var(--tile-color);
+ box-shadow: var(--shadow-default), var(--shadow-focus);
+ }
+ ha-card {
+ --ha-ripple-color: var(--tile-color);
+ --ha-ripple-hover-opacity: 0.04;
+ --ha-ripple-pressed-opacity: 0.12;
+ height: 100%;
+ transition:
+ box-shadow 180ms ease-in-out,
+ border-color 180ms ease-in-out;
display: flex;
flex-direction: column;
justify-content: space-between;
+ }
+ [role="button"] {
+ cursor: pointer;
+ pointer-events: auto;
+ }
+ [role="button"]:focus {
+ outline: none;
+ }
+ .background {
position: absolute;
top: 0;
- bottom: 0;
left: 0;
+ bottom: 0;
right: 0;
- background: linear-gradient(
- 0,
- rgba(33, 33, 33, 0.9) 0%,
- rgba(33, 33, 33, 0) 45%
- );
+ border-radius: var(--ha-card-border-radius, 12px);
+ margin: calc(-1 * var(--ha-card-border-width, 1px));
+ overflow: hidden;
}
-
- ha-card:not(.image) .container::before {
+ .header {
+ flex: 1;
+ overflow: hidden;
+ border-radius: var(--ha-card-border-radius, 12px);
+ border-end-end-radius: 0;
+ border-end-start-radius: 0;
+ pointer-events: none;
+ }
+ .picture {
+ height: 100%;
+ width: 100%;
+ background-size: cover;
+ background-position: center;
+ position: relative;
+ }
+ .picture hui-image {
+ height: 100%;
+ }
+ .picture .icon-container {
+ height: 100%;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ --mdc-icon-size: 48px;
+ color: var(--tile-color);
+ }
+ .picture .icon-container::before {
position: absolute;
content: "";
width: 100%;
height: 100%;
- background-color: var(--sidebar-selected-icon-color);
+ background-color: var(--tile-color);
opacity: 0.12;
}
-
- .image hui-image {
- height: 100%;
+ .container {
+ margin: calc(-1 * var(--ha-card-border-width, 1px));
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ }
+ .header + .container {
+ height: auto;
+ flex: none;
+ }
+ .container.horizontal {
+ flex-direction: row;
}
- .icon-container {
+ .content {
+ position: relative;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ padding: 10px;
+ flex: 1;
+ min-width: 0;
+ box-sizing: border-box;
+ pointer-events: none;
+ gap: 10px;
+ }
+
+ ha-tile-icon {
+ --tile-icon-color: var(--tile-color);
+ position: relative;
+ padding: 6px;
+ margin: -6px;
+ }
+ ha-tile-badge {
+ position: absolute;
+ top: 3px;
+ right: 3px;
+ inset-inline-end: 3px;
+ inset-inline-start: initial;
+ }
+ ha-tile-info {
+ position: relative;
+ min-width: 0;
+ transition: background-color 180ms ease-in-out;
+ box-sizing: border-box;
+ }
+ hui-card-features {
+ --feature-color: var(--tile-color);
+ padding: 0 12px 12px 12px;
+ }
+ .container.horizontal hui-card-features {
+ width: calc(50% - var(--column-gap, 0px) / 2 - 12px);
+ flex: none;
+ --feature-height: 36px;
+ padding: 0 12px;
+ padding-inline-start: 0;
+ }
+ .alert-badge {
+ --tile-badge-background-color: var(--orange-color);
+ }
+ .alerts {
position: absolute;
top: 0;
left: 0;
- right: 0;
- bottom: 0;
+ display: flex;
+ flex-direction: row;
+ gap: 4px;
+ padding: 4px;
+ pointer-events: none;
+ z-index: 1;
+ }
+ .alert {
+ background-color: var(--orange-color);
+ border-radius: 12px;
+ width: 24px;
+ height: 24px;
+ padding: 2px;
+ box-sizing: border-box;
+ --mdc-icon-size: 16px;
display: flex;
align-items: center;
justify-content: center;
- }
-
- .icon-container ha-icon {
- --mdc-icon-size: 60px;
- color: var(--sidebar-selected-icon-color);
- }
-
- .sensors {
- color: #e3e3e3;
- font-size: var(--ha-font-size-l);
- --mdc-icon-size: 24px;
- opacity: 0.6;
- margin-top: 8px;
- }
-
- .sensor {
- white-space: nowrap;
- float: left;
- margin-right: 4px;
- margin-inline-end: 4px;
- margin-inline-start: initial;
- }
-
- .alerts {
- padding: 16px;
- }
-
- ha-state-icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- position: relative;
- }
-
- .alerts ha-state-icon {
- background: var(--accent-color);
- color: var(--text-accent-color, var(--text-primary-color));
- padding: 8px;
- margin-right: 8px;
- margin-inline-end: 8px;
- margin-inline-start: initial;
- border-radius: 50%;
- }
-
- .name {
color: white;
- font-size: var(--ha-font-size-2xl);
- }
-
- .bottom {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 16px;
- }
-
- .navigate {
- cursor: pointer;
- }
-
- ha-icon-button {
- color: white;
- background-color: var(--area-button-color, #727272b2);
- border-radius: 50%;
- margin-left: 8px;
- margin-inline-start: 8px;
- margin-inline-end: initial;
- --mdc-icon-button-size: 44px;
- }
- .on {
- color: var(--state-light-active-color);
}
`;
}
diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts
index 52466b2de8..0957c9289a 100644
--- a/src/panels/lovelace/cards/types.ts
+++ b/src/panels/lovelace/cards/types.ts
@@ -101,11 +101,18 @@ export interface EntitiesCardConfig extends LovelaceCardConfig {
}
export interface AreaCardConfig extends LovelaceCardConfig {
- area: string;
+ area?: string;
+ name?: string;
navigation_path?: string;
+ display_type?: "compact" | "icon" | "picture" | "camera";
+ /** @deprecated Use `display_type` instead */
show_camera?: boolean;
camera_view?: HuiImage["cameraView"];
aspect_ratio?: string;
+ sensor_classes?: string[];
+ alert_classes?: string[];
+ features?: LovelaceCardFeatureConfig[];
+ features_position?: "bottom" | "inline";
}
export interface ButtonCardConfig extends LovelaceCardConfig {
diff --git a/src/panels/lovelace/components/hui-image.ts b/src/panels/lovelace/components/hui-image.ts
index 08c54b61bf..35ba02cd77 100644
--- a/src/panels/lovelace/components/hui-image.ts
+++ b/src/panels/lovelace/components/hui-image.ts
@@ -54,7 +54,10 @@ export class HuiImage extends LitElement {
@property({ attribute: false }) public darkModeFilter?: string;
- @property({ attribute: false }) public fitMode?: "cover" | "contain" | "fill";
+ @property({ attribute: "fit-mode", type: String }) public fitMode?:
+ | "cover"
+ | "contain"
+ | "fill";
@state() private _imageVisible? = false;
diff --git a/src/panels/lovelace/create-element/create-card-feature-element.ts b/src/panels/lovelace/create-element/create-card-feature-element.ts
index 31ce2eccd5..fc003da1f9 100644
--- a/src/panels/lovelace/create-element/create-card-feature-element.ts
+++ b/src/panels/lovelace/create-element/create-card-feature-element.ts
@@ -1,9 +1,9 @@
import "../card-features/hui-alarm-modes-card-feature";
import "../card-features/hui-climate-fan-modes-card-feature";
-import "../card-features/hui-climate-swing-modes-card-feature";
-import "../card-features/hui-climate-swing-horizontal-modes-card-feature";
import "../card-features/hui-climate-hvac-modes-card-feature";
import "../card-features/hui-climate-preset-modes-card-feature";
+import "../card-features/hui-climate-swing-horizontal-modes-card-feature";
+import "../card-features/hui-climate-swing-modes-card-feature";
import "../card-features/hui-counter-actions-card-feature";
import "../card-features/hui-cover-open-close-card-feature";
import "../card-features/hui-cover-position-card-feature";
@@ -21,12 +21,13 @@ import "../card-features/hui-lock-open-door-card-feature";
import "../card-features/hui-media-player-volume-slider-card-feature";
import "../card-features/hui-numeric-input-card-feature";
import "../card-features/hui-select-options-card-feature";
-import "../card-features/hui-target-temperature-card-feature";
import "../card-features/hui-target-humidity-card-feature";
+import "../card-features/hui-target-temperature-card-feature";
import "../card-features/hui-toggle-card-feature";
import "../card-features/hui-update-actions-card-feature";
import "../card-features/hui-vacuum-commands-card-feature";
import "../card-features/hui-water-heater-operation-modes-card-feature";
+import "../card-features/hui-area-controls-card-feature";
import type { LovelaceCardFeatureConfig } from "../card-features/types";
import {
@@ -36,6 +37,7 @@ import {
const TYPES = new Set
([
"alarm-modes",
+ "area-controls",
"climate-fan-modes",
"climate-swing-modes",
"climate-swing-horizontal-modes",
diff --git a/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts
index 7248d9fdc3..e9c15e329c 100644
--- a/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts
+++ b/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts
@@ -1,43 +1,59 @@
-import { html, LitElement, nothing } from "lit";
+import { mdiGestureTap, mdiListBox, mdiTextShort } from "@mdi/js";
+import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
- assert,
+ any,
array,
+ assert,
assign,
boolean,
+ enums,
object,
optional,
string,
} from "superstruct";
-import { fireEvent } from "../../../../common/dom/fire_event";
-import "../../../../components/ha-form/ha-form";
import {
- DEFAULT_ASPECT_RATIO,
- DEVICE_CLASSES,
-} from "../../cards/hui-area-card";
-import type { SchemaUnion } from "../../../../components/ha-form/types";
+ fireEvent,
+ type HASSDomEvent,
+} from "../../../../common/dom/fire_event";
+import { generateEntityFilter } from "../../../../common/entity/entity_filter";
+import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
+import type { LocalizeFunc } from "../../../../common/translations/localize";
+import "../../../../components/ha-form/ha-form";
+import type {
+ HaFormSchema,
+ SchemaUnion,
+} from "../../../../components/ha-form/types";
+import type { SelectOption } from "../../../../data/selector";
+import { getSensorNumericDeviceClasses } from "../../../../data/sensor";
import type { HomeAssistant } from "../../../../types";
+import type {
+ LovelaceCardFeatureConfig,
+ LovelaceCardFeatureContext,
+} from "../../card-features/types";
+import { DEVICE_CLASSES } from "../../cards/hui-area-card";
import type { AreaCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
-import { computeDomain } from "../../../../common/entity/compute_domain";
-import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
-import type { SelectOption } from "../../../../data/selector";
-import { getSensorNumericDeviceClasses } from "../../../../data/sensor";
-import type { LocalizeFunc } from "../../../../common/translations/localize";
+import type { EditDetailElementEvent, EditSubElementEvent } from "../types";
+import { configElementStyle } from "./config-elements-style";
+import { getSupportedFeaturesType } from "./hui-card-features-editor";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
area: optional(string()),
+ name: optional(string()),
navigation_path: optional(string()),
- theme: optional(string()),
show_camera: optional(boolean()),
+ display_type: optional(enums(["compact", "icon", "picture", "camera"])),
camera_view: optional(string()),
- aspect_ratio: optional(string()),
alert_classes: optional(array(string())),
sensor_classes: optional(array(string())),
+ features: optional(array(any())),
+ features_position: optional(enums(["bottom", "inline"])),
+ aspect_ratio: optional(string()),
})
);
@@ -52,6 +68,12 @@ export class HuiAreaCardEditor
@state() private _numericDeviceClasses?: string[];
+ private _featureContext = memoizeOne(
+ (areaId?: string): LovelaceCardFeatureContext => ({
+ area_id: areaId,
+ })
+ );
+
private _schema = memoizeOne(
(
localize: LocalizeFunc,
@@ -61,103 +83,143 @@ export class HuiAreaCardEditor
) =>
[
{ name: "area", selector: { area: {} } },
- { name: "show_camera", required: false, selector: { boolean: {} } },
- ...(showCamera
- ? ([
- {
- name: "camera_view",
- selector: {
- select: {
- options: ["auto", "live"].map((value) => ({
- value,
- label: localize(
- `ui.panel.lovelace.editor.card.generic.camera_view_options.${value}`
+ {
+ name: "content",
+ flatten: true,
+ type: "expandable",
+ iconPath: mdiTextShort,
+ schema: [
+ {
+ name: "",
+ type: "grid",
+ schema: [
+ { name: "name", selector: { text: {} } },
+ {
+ name: "display_type",
+ required: true,
+ selector: {
+ select: {
+ options: ["compact", "icon", "picture", "camera"].map(
+ (value) => ({
+ value,
+ label: localize(
+ `ui.panel.lovelace.editor.card.area.display_type_options.${value}`
+ ),
+ })
),
- })),
- mode: "dropdown",
+ mode: "dropdown",
+ },
},
},
+ ],
+ },
+ {
+ name: "",
+ type: "grid",
+ schema: [
+ ...(showCamera
+ ? ([
+ {
+ name: "camera_view",
+ selector: {
+ select: {
+ options: ["auto", "live"].map((value) => ({
+ value,
+ label: localize(
+ `ui.panel.lovelace.editor.card.generic.camera_view_options.${value}`
+ ),
+ })),
+ mode: "dropdown",
+ },
+ },
+ },
+ ] as const satisfies readonly HaFormSchema[])
+ : []),
+ ],
+ },
+ {
+ name: "alert_classes",
+ selector: {
+ select: {
+ reorder: true,
+ multiple: true,
+ custom_value: true,
+ options: binaryClasses,
+ },
},
- ] as const)
- : []),
+ },
+ {
+ name: "sensor_classes",
+ selector: {
+ select: {
+ reorder: true,
+ multiple: true,
+ custom_value: true,
+ options: sensorClasses,
+ },
+ },
+ },
+ ],
+ },
{
- name: "",
- type: "grid",
+ name: "interactions",
+ type: "expandable",
+ flatten: true,
+ iconPath: mdiGestureTap,
schema: [
{
name: "navigation_path",
required: false,
selector: { navigation: {} },
},
- { name: "theme", required: false, selector: { theme: {} } },
- {
- name: "aspect_ratio",
- default: DEFAULT_ASPECT_RATIO,
- selector: { text: {} },
- },
],
},
- {
- name: "alert_classes",
- selector: {
- select: {
- reorder: true,
- multiple: true,
- custom_value: true,
- options: binaryClasses,
- },
- },
- },
- {
- name: "sensor_classes",
- selector: {
- select: {
- reorder: true,
- multiple: true,
- custom_value: true,
- options: sensorClasses,
- },
- },
- },
- ] as const
+ ] as const satisfies readonly HaFormSchema[]
);
- private _binaryClassesForArea = memoizeOne((area: string): string[] =>
- this._classesForArea(area, "binary_sensor")
+ private _binaryClassesForArea = memoizeOne(
+ (area: string | undefined): string[] => {
+ if (!area) {
+ return [];
+ }
+
+ const binarySensorFilter = generateEntityFilter(this.hass!, {
+ domain: "binary_sensor",
+ area,
+ entity_category: "none",
+ });
+
+ const classes = Object.keys(this.hass!.entities)
+ .filter(binarySensorFilter)
+ .map((id) => this.hass!.states[id]?.attributes.device_class)
+ .filter((c): c is string => Boolean(c));
+
+ return [...new Set(classes)];
+ }
);
private _sensorClassesForArea = memoizeOne(
- (area: string, numericDeviceClasses?: string[]): string[] =>
- this._classesForArea(area, "sensor", numericDeviceClasses)
+ (area: string | undefined, numericDeviceClasses?: string[]): string[] => {
+ if (!area) {
+ return [];
+ }
+
+ const sensorFilter = generateEntityFilter(this.hass!, {
+ domain: "sensor",
+ area,
+ device_class: numericDeviceClasses,
+ entity_category: "none",
+ });
+
+ const classes = Object.keys(this.hass!.entities)
+ .filter(sensorFilter)
+ .map((id) => this.hass!.states[id]?.attributes.device_class)
+ .filter((c): c is string => Boolean(c));
+
+ return [...new Set(classes)];
+ }
);
- private _classesForArea(
- area: string,
- domain: "sensor" | "binary_sensor",
- numericDeviceClasses?: string[] | undefined
- ): string[] {
- const entities = Object.values(this.hass!.entities).filter(
- (e) =>
- computeDomain(e.entity_id) === domain &&
- !e.entity_category &&
- !e.hidden &&
- (e.area_id === area ||
- (e.device_id && this.hass!.devices[e.device_id]?.area_id === area))
- );
-
- const classes = entities
- .map((e) => this.hass!.states[e.entity_id]?.attributes.device_class || "")
- .filter(
- (c) =>
- c &&
- (domain !== "sensor" ||
- !numericDeviceClasses ||
- numericDeviceClasses.includes(c))
- );
-
- return [...new Set(classes)];
- }
-
private _buildBinaryOptions = memoizeOne(
(possibleClasses: string[], currentClasses: string[]): SelectOption[] =>
this._buildOptions("binary_sensor", possibleClasses, currentClasses)
@@ -191,7 +253,14 @@ export class HuiAreaCardEditor
public setConfig(config: AreaCardConfig): void {
assert(config, cardConfigStruct);
- this._config = config;
+
+ const displayType =
+ config.display_type || (config.show_camera ? "camera" : "picture");
+ this._config = {
+ ...config,
+ display_type: displayType,
+ };
+ delete this._config.show_camera;
}
protected async updated() {
@@ -202,16 +271,50 @@ export class HuiAreaCardEditor
}
}
+ private _featuresSchema = memoizeOne(
+ (localize: LocalizeFunc) =>
+ [
+ {
+ name: "features_position",
+ required: true,
+ selector: {
+ select: {
+ mode: "box",
+ options: ["bottom", "inline"].map((value) => ({
+ label: localize(
+ `ui.panel.lovelace.editor.card.tile.features_position_options.${value}`
+ ),
+ description: localize(
+ `ui.panel.lovelace.editor.card.tile.features_position_options.${value}_description`
+ ),
+ value,
+ image: {
+ src: `/static/images/form/tile_features_position_${value}.svg`,
+ src_dark: `/static/images/form/tile_features_position_${value}_dark.svg`,
+ flip_rtl: true,
+ },
+ })),
+ },
+ },
+ },
+ ] as const satisfies readonly HaFormSchema[]
+ );
+
+ private _hasCompatibleFeatures = memoizeOne(
+ (context: LovelaceCardFeatureContext) =>
+ getSupportedFeaturesType(this.hass!, context).length > 0
+ );
+
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
- const possibleBinaryClasses = this._binaryClassesForArea(
- this._config.area || ""
- );
+ const areaId = this._config!.area;
+
+ const possibleBinaryClasses = this._binaryClassesForArea(this._config.area);
const possibleSensorClasses = this._sensorClassesForArea(
- this._config.area || "",
+ this._config.area,
this._numericDeviceClasses
);
const binarySelectOptions = this._buildBinaryOptions(
@@ -223,68 +326,196 @@ export class HuiAreaCardEditor
this._config.sensor_classes || DEVICE_CLASSES.sensor
);
+ const showCamera = this._config.display_type === "camera";
+
+ const displayType =
+ this._config.display_type || this._config.show_camera
+ ? "camera"
+ : "picture";
+
const schema = this._schema(
this.hass.localize,
- this._config.show_camera || false,
+ showCamera,
binarySelectOptions,
sensorSelectOptions
);
+ const featuresSchema = this._featuresSchema(this.hass.localize);
+
const data = {
camera_view: "auto",
alert_classes: DEVICE_CLASSES.binary_sensor,
sensor_classes: DEVICE_CLASSES.sensor,
+ features_position: "bottom",
+ display_type: displayType,
...this._config,
};
+ const featureContext = this._featureContext(areaId);
+ const hasCompatibleFeatures = this._hasCompatibleFeatures(featureContext);
+
return html`
+
+
+
+ ${this.hass!.localize(
+ "ui.panel.lovelace.editor.card.generic.features"
+ )}
+
+
+ ${hasCompatibleFeatures
+ ? html`
+
+ `
+ : nothing}
+
+
+
`;
}
private _valueChanged(ev: CustomEvent): void {
- const config = ev.detail.value;
- if (!config.show_camera) {
+ const newConfig = ev.detail.value as AreaCardConfig;
+
+ const config: AreaCardConfig = {
+ features: this._config!.features,
+ ...newConfig,
+ };
+
+ if (config.display_type !== "camera") {
delete config.camera_view;
}
+
fireEvent(this, "config-changed", { config });
}
+ private _featuresChanged(ev: CustomEvent) {
+ ev.stopPropagation();
+ if (!this._config || !this.hass) {
+ return;
+ }
+
+ const features = ev.detail.features as LovelaceCardFeatureConfig[];
+ const config: AreaCardConfig = {
+ ...this._config,
+ features,
+ };
+
+ if (features.length === 0) {
+ delete config.features;
+ }
+
+ fireEvent(this, "config-changed", { config });
+ }
+
+ private _editDetailElement(ev: HASSDomEvent): void {
+ const index = ev.detail.subElementConfig.index;
+ const config = this._config!.features![index!];
+ const featureContext = this._featureContext(this._config!.area);
+
+ fireEvent(this, "edit-sub-element", {
+ config: config,
+ saveConfig: (newConfig) => this._updateFeature(index!, newConfig),
+ context: featureContext,
+ type: "feature",
+ } as EditSubElementEvent<
+ LovelaceCardFeatureConfig,
+ LovelaceCardFeatureContext
+ >);
+ }
+
+ private _updateFeature(index: number, feature: LovelaceCardFeatureConfig) {
+ const features = this._config!.features!.concat();
+ features[index] = feature;
+ const config = { ...this._config!, features };
+ fireEvent(this, "config-changed", {
+ config: config,
+ });
+ }
+
+ private _computeHelperCallback = (
+ schema:
+ | SchemaUnion>
+ | SchemaUnion>
+ ): string | undefined => {
+ switch (schema.name) {
+ case "alert_classes":
+ if (this._config?.display_type === "compact") {
+ return this.hass!.localize(
+ `ui.panel.lovelace.editor.card.area.alert_classes_helper`
+ );
+ }
+ return undefined;
+ default:
+ return undefined;
+ }
+ };
+
private _computeLabelCallback = (
- schema: SchemaUnion>
+ schema:
+ | SchemaUnion>
+ | SchemaUnion>
) => {
switch (schema.name) {
- case "theme":
- return `${this.hass!.localize(
- "ui.panel.lovelace.editor.card.generic.theme"
- )} (${this.hass!.localize(
- "ui.panel.lovelace.editor.card.config.optional"
- )})`;
case "area":
return this.hass!.localize("ui.panel.lovelace.editor.card.area.name");
+
+ case "name":
+ case "camera_view":
+ case "content":
+ return this.hass!.localize(
+ `ui.panel.lovelace.editor.card.generic.${schema.name}`
+ );
case "navigation_path":
return this.hass!.localize(
"ui.panel.lovelace.editor.action-editor.navigation_path"
);
- case "aspect_ratio":
+ case "interactions":
+ case "features_position":
return this.hass!.localize(
- "ui.panel.lovelace.editor.card.generic.aspect_ratio"
- );
- case "camera_view":
- return this.hass!.localize(
- "ui.panel.lovelace.editor.card.generic.camera_view"
+ `ui.panel.lovelace.editor.card.tile.${schema.name}`
);
}
return this.hass!.localize(
`ui.panel.lovelace.editor.card.area.${schema.name}`
);
};
+
+ static get styles() {
+ return [
+ configElementStyle,
+ css`
+ ha-form {
+ display: block;
+ margin-bottom: 24px;
+ }
+ .features-form {
+ margin-bottom: 8px;
+ }
+ `,
+ ];
+ }
}
declare global {
diff --git a/src/panels/lovelace/editor/config-elements/hui-area-controls-card-feature-editor.ts b/src/panels/lovelace/editor/config-elements/hui-area-controls-card-feature-editor.ts
new file mode 100644
index 0000000000..ac79793497
--- /dev/null
+++ b/src/panels/lovelace/editor/config-elements/hui-area-controls-card-feature-editor.ts
@@ -0,0 +1,180 @@
+import { html, LitElement, nothing } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import memoizeOne from "memoize-one";
+import { fireEvent } from "../../../../common/dom/fire_event";
+import type { LocalizeFunc } from "../../../../common/translations/localize";
+import "../../../../components/ha-form/ha-form";
+import type {
+ HaFormSchema,
+ SchemaUnion,
+} from "../../../../components/ha-form/types";
+import type { HomeAssistant } from "../../../../types";
+import { getAreaControlEntities } from "../../card-features/hui-area-controls-card-feature";
+import {
+ AREA_CONTROLS,
+ type AreaControl,
+ type AreaControlsCardFeatureConfig,
+ type LovelaceCardFeatureContext,
+} from "../../card-features/types";
+import type { LovelaceCardFeatureEditor } from "../../types";
+
+type AreaControlsCardFeatureData = AreaControlsCardFeatureConfig & {
+ customize_controls: boolean;
+};
+
+@customElement("hui-area-controls-card-feature-editor")
+export class HuiAreaControlsCardFeatureEditor
+ extends LitElement
+ implements LovelaceCardFeatureEditor
+{
+ @property({ attribute: false }) public hass?: HomeAssistant;
+
+ @property({ attribute: false }) public context?: LovelaceCardFeatureContext;
+
+ @state() private _config?: AreaControlsCardFeatureConfig;
+
+ public setConfig(config: AreaControlsCardFeatureConfig): void {
+ this._config = config;
+ }
+
+ private _schema = memoizeOne(
+ (
+ localize: LocalizeFunc,
+ customizeControls: boolean,
+ compatibleControls: AreaControl[]
+ ) =>
+ [
+ {
+ name: "customize_controls",
+ selector: {
+ boolean: {},
+ },
+ },
+ ...(customizeControls
+ ? ([
+ {
+ name: "controls",
+ selector: {
+ select: {
+ reorder: true,
+ multiple: true,
+ options: compatibleControls.map((control) => ({
+ value: control,
+ label: localize(
+ `ui.panel.lovelace.editor.features.types.area-controls.controls_options.${control}`
+ ),
+ })),
+ },
+ },
+ },
+ ] as const satisfies readonly HaFormSchema[])
+ : []),
+ ] as const satisfies readonly HaFormSchema[]
+ );
+
+ private _compatibleControls = memoizeOne(
+ (
+ areaId: string,
+ // needed to update memoized function when entities, devices or areas change
+ _entities: HomeAssistant["entities"],
+ _devices: HomeAssistant["devices"],
+ _areas: HomeAssistant["areas"]
+ ) => {
+ if (!this.hass) {
+ return [];
+ }
+ const controlEntities = getAreaControlEntities(
+ AREA_CONTROLS as unknown as AreaControl[],
+ areaId,
+ this.hass!
+ );
+ return (
+ Object.keys(controlEntities) as (keyof typeof controlEntities)[]
+ ).filter((control) => controlEntities[control].length > 0);
+ }
+ );
+
+ protected render() {
+ if (!this.hass || !this._config || !this.context?.area_id) {
+ return nothing;
+ }
+
+ const compatibleControls = this._compatibleControls(
+ this.context.area_id,
+ this.hass.entities,
+ this.hass.devices,
+ this.hass.areas
+ );
+
+ if (compatibleControls.length === 0) {
+ return html`
+
+ ${this.hass.localize(
+ "ui.panel.lovelace.editor.features.types.area-controls.no_compatible_controls"
+ )}
+
+ `;
+ }
+
+ const data: AreaControlsCardFeatureData = {
+ ...this._config,
+ customize_controls: this._config.controls !== undefined,
+ };
+
+ const schema = this._schema(
+ this.hass.localize,
+ data.customize_controls,
+ compatibleControls
+ );
+
+ return html`
+
+ `;
+ }
+
+ private _valueChanged(ev: CustomEvent): void {
+ const { customize_controls, ...config } = ev.detail
+ .value as AreaControlsCardFeatureData;
+
+ if (customize_controls && !config.controls) {
+ config.controls = this._compatibleControls(
+ this.context!.area_id!,
+ this.hass!.entities,
+ this.hass!.devices,
+ this.hass!.areas
+ ).concat();
+ }
+
+ if (!customize_controls && config.controls) {
+ delete config.controls;
+ }
+
+ fireEvent(this, "config-changed", { config: config });
+ }
+
+ private _computeLabelCallback = (
+ schema: SchemaUnion>
+ ) => {
+ switch (schema.name) {
+ case "controls":
+ case "customize_controls":
+ return this.hass!.localize(
+ `ui.panel.lovelace.editor.features.types.area-controls.${schema.name}`
+ );
+ default:
+ return "";
+ }
+ };
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "hui-area-controls-card-feature-editor": HuiAreaControlsCardFeatureEditor;
+ }
+}
diff --git a/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts b/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts
index b92723ecc2..befbb73c7b 100644
--- a/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts
+++ b/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts
@@ -18,6 +18,7 @@ import {
} from "../../../../data/lovelace_custom_cards";
import type { HomeAssistant } from "../../../../types";
import { supportsAlarmModesCardFeature } from "../../card-features/hui-alarm-modes-card-feature";
+import { supportsAreaControlsCardFeature } from "../../card-features/hui-area-controls-card-feature";
import { supportsClimateFanModesCardFeature } from "../../card-features/hui-climate-fan-modes-card-feature";
import { supportsClimateHvacModesCardFeature } from "../../card-features/hui-climate-hvac-modes-card-feature";
import { supportsClimatePresetModesCardFeature } from "../../card-features/hui-climate-preset-modes-card-feature";
@@ -61,6 +62,7 @@ type SupportsFeature = (
const UI_FEATURE_TYPES = [
"alarm-modes",
+ "area-controls",
"climate-fan-modes",
"climate-hvac-modes",
"climate-preset-modes",
@@ -95,6 +97,7 @@ type UiFeatureTypes = (typeof UI_FEATURE_TYPES)[number];
const EDITABLES_FEATURE_TYPES = new Set([
"alarm-modes",
+ "area-controls",
"climate-fan-modes",
"climate-hvac-modes",
"climate-preset-modes",
@@ -116,6 +119,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
SupportsFeature | undefined
> = {
"alarm-modes": supportsAlarmModesCardFeature,
+ "area-controls": supportsAreaControlsCardFeature,
"climate-fan-modes": supportsClimateFanModesCardFeature,
"climate-swing-modes": supportsClimateSwingModesCardFeature,
"climate-swing-horizontal-modes":
diff --git a/src/translations/en.json b/src/translations/en.json
index d64c5f2ce5..895ac9fbc8 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -7172,10 +7172,17 @@
},
"area": {
"name": "Area",
- "alert_classes": "Alert Classes",
- "sensor_classes": "Sensor Classes",
+ "alert_classes": "Alert classes",
+ "alert_classes_helper": "In compact style, only the first one will be shown. Order alerts by priority.",
+ "sensor_classes": "Sensor classes",
"description": "The Area card automatically displays entities of a specific area.",
- "show_camera": "Show camera feed instead of area picture"
+ "display_type": "Display type",
+ "display_type_options": {
+ "compact": "Compact",
+ "icon": "Area icon",
+ "picture": "Area picture",
+ "camera": "Camera feed"
+ }
},
"calendar": {
"name": "Calendar",
@@ -7832,6 +7839,17 @@
"ask": "Ask"
},
"backup_not_supported": "Backup is not supported."
+ },
+ "area-controls": {
+ "label": "Area controls",
+ "customize_controls": "Customize controls",
+ "controls": "Controls",
+ "controls_options": {
+ "light": "Lights",
+ "fan": "Fans",
+ "switch": "Switches"
+ },
+ "no_compatible_controls": "No compatible controls available for this area"
}
}
},