diff --git a/src/components/ha-area-filter.ts b/src/components/ha-area-filter.ts
new file mode 100644
index 0000000000..a5ca967190
--- /dev/null
+++ b/src/components/ha-area-filter.ts
@@ -0,0 +1,96 @@
+import { mdiChevronRight, mdiSofa } from "@mdi/js";
+import { CSSResultGroup, LitElement, TemplateResult, css, html } from "lit";
+import { customElement, property } from "lit/decorators";
+import { fireEvent } from "../common/dom/fire_event";
+import { showAreaFilterDialog } from "../dialogs/area-filter/show-area-filter-dialog";
+import { HomeAssistant } from "../types";
+import "./ha-svg-icon";
+import "./ha-textfield";
+
+export type AreaFilterValue = {
+ hidden?: string[];
+ order?: string[];
+};
+
+@customElement("ha-area-filter")
+export class HaAreaPicker extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property() public label?: string;
+
+ @property({ attribute: false }) public value?: AreaFilterValue;
+
+ @property() public helper?: string;
+
+ @property({ type: Boolean }) public disabled = false;
+
+ @property({ type: Boolean }) public required = false;
+
+ protected render(): TemplateResult {
+ const allAreasCount = Object.keys(this.hass.areas).length;
+ const hiddenAreasCount = this.value?.hidden?.length ?? 0;
+
+ const description =
+ hiddenAreasCount === 0
+ ? this.hass.localize("ui.components.area-filter.all_areas")
+ : allAreasCount === hiddenAreasCount
+ ? this.hass.localize("ui.components.area-filter.no_areas")
+ : this.hass.localize("ui.components.area-filter.area_count", {
+ count: allAreasCount - hiddenAreasCount,
+ });
+
+ return html`
+
+
+ ${this.label}
+ ${description}
+
+
+ `;
+ }
+
+ private async _edit(ev) {
+ if (ev.defaultPrevented) {
+ return;
+ }
+ if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") {
+ return;
+ }
+ ev.preventDefault();
+ ev.stopPropagation();
+ const value = await showAreaFilterDialog(this, {
+ title: this.label,
+ initialValue: this.value,
+ });
+ if (!value) return;
+ fireEvent(this, "value-changed", { value });
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ ha-list-item {
+ --mdc-list-side-padding-left: 8px;
+ --mdc-list-side-padding-right: 8px;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-area-filter": HaAreaPicker;
+ }
+}
diff --git a/src/components/ha-selector/ha-selector-area-filter.ts b/src/components/ha-selector/ha-selector-area-filter.ts
new file mode 100644
index 0000000000..7efd243a8c
--- /dev/null
+++ b/src/components/ha-selector/ha-selector-area-filter.ts
@@ -0,0 +1,41 @@
+import { LitElement, html } from "lit";
+import { customElement, property } from "lit/decorators";
+import type { AreaFilterSelector } from "../../data/selector";
+import { HomeAssistant } from "../../types";
+import "../ha-area-filter";
+
+@customElement("ha-selector-area_filter")
+export class HaAreaFilterSelector extends LitElement {
+ @property() public hass!: HomeAssistant;
+
+ @property() public selector!: AreaFilterSelector;
+
+ @property() public value?: any;
+
+ @property() public label?: string;
+
+ @property() public helper?: string;
+
+ @property({ type: Boolean }) public disabled = false;
+
+ @property({ type: Boolean }) public required = true;
+
+ protected render() {
+ return html`
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-selector-area_filter": HaAreaFilterSelector;
+ }
+}
diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts
index 3bfbb7741e..74d129d901 100644
--- a/src/components/ha-selector/ha-selector.ts
+++ b/src/components/ha-selector/ha-selector.ts
@@ -13,6 +13,7 @@ const LOAD_ELEMENTS = {
action: () => import("./ha-selector-action"),
addon: () => import("./ha-selector-addon"),
area: () => import("./ha-selector-area"),
+ area_filter: () => import("./ha-selector-area-filter"),
attribute: () => import("./ha-selector-attribute"),
assist_pipeline: () => import("./ha-selector-assist-pipeline"),
boolean: () => import("./ha-selector-boolean"),
diff --git a/src/data/area_registry.ts b/src/data/area_registry.ts
index 4c53f218eb..b3c391b56a 100644
--- a/src/data/area_registry.ts
+++ b/src/data/area_registry.ts
@@ -123,3 +123,22 @@ export const getAreaDeviceLookup = (
}
return areaDeviceLookup;
};
+
+export const areaCompare =
+ (entries?: HomeAssistant["areas"], order?: string[]) =>
+ (a: string, b: string) => {
+ const indexA = order ? order.indexOf(a) : -1;
+ const indexB = order ? order.indexOf(b) : 1;
+ if (indexA === -1 && indexB === -1) {
+ const nameA = entries?.[a].name ?? a;
+ const nameB = entries?.[b].name ?? b;
+ return stringCompare(nameA, nameB);
+ }
+ if (indexA === -1) {
+ return 1;
+ }
+ if (indexB === -1) {
+ return -1;
+ }
+ return indexA - indexB;
+ };
diff --git a/src/data/selector.ts b/src/data/selector.ts
index 3739e1c82e..641ef114a1 100644
--- a/src/data/selector.ts
+++ b/src/data/selector.ts
@@ -15,6 +15,7 @@ export type Selector =
| ActionSelector
| AddonSelector
| AreaSelector
+ | AreaFilterSelector
| AttributeSelector
| BooleanSelector
| ColorRGBSelector
@@ -77,6 +78,11 @@ export interface AreaSelector {
} | null;
}
+export interface AreaFilterSelector {
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ area_filter: {} | null;
+}
+
export interface AttributeSelector {
attribute: {
entity_id?: string;
diff --git a/src/dialogs/area-filter/area-filter-dialog.ts b/src/dialogs/area-filter/area-filter-dialog.ts
new file mode 100644
index 0000000000..b2b813b3eb
--- /dev/null
+++ b/src/dialogs/area-filter/area-filter-dialog.ts
@@ -0,0 +1,218 @@
+import "@material/mwc-list/mwc-list";
+import { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js";
+import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { classMap } from "lit/directives/class-map";
+import { repeat } from "lit/directives/repeat";
+import type { SortableEvent } from "sortablejs";
+import { fireEvent } from "../../common/dom/fire_event";
+import type { AreaFilterValue } from "../../components/ha-area-filter";
+import "../../components/ha-button";
+import "../../components/ha-icon-button";
+import "../../components/ha-list-item";
+import { areaCompare } from "../../data/area_registry";
+import { sortableStyles } from "../../resources/ha-sortable-style";
+import type { SortableInstance } from "../../resources/sortable";
+import { haStyleDialog } from "../../resources/styles";
+import { HomeAssistant } from "../../types";
+import { HassDialog } from "../make-dialog-manager";
+import { AreaFilterDialogParams } from "./show-area-filter-dialog";
+
+@customElement("dialog-area-filter")
+export class DialogAreaFilter
+ extends LitElement
+ implements HassDialog
+{
+ @property({ attribute: false }) public hass?: HomeAssistant;
+
+ @state() private _dialogParams?: AreaFilterDialogParams;
+
+ @state() private _hidden: string[] = [];
+
+ @state() private _areas: string[] = [];
+
+ private _sortable?: SortableInstance;
+
+ public async showDialog(dialogParams: AreaFilterDialogParams): Promise {
+ this._dialogParams = dialogParams;
+ this._hidden = dialogParams.initialValue?.hidden ?? [];
+ const order = dialogParams.initialValue?.order ?? [];
+ const allAreas = Object.keys(this.hass!.areas);
+ this._areas = allAreas.concat().sort(areaCompare(this.hass!.areas, order));
+ await this.updateComplete;
+ this._createSortable();
+ }
+
+ public closeDialog(): void {
+ this._dialogParams = undefined;
+ this._hidden = [];
+ this._areas = [];
+ this._destroySortable();
+ fireEvent(this, "dialog-closed", { dialog: this.localName });
+ }
+
+ private _submit(): void {
+ const order = this._areas.filter((area) => !this._hidden.includes(area));
+ const value: AreaFilterValue = {
+ hidden: this._hidden,
+ order,
+ };
+ this._dialogParams?.submit?.(value);
+ this.closeDialog();
+ }
+
+ private _cancel(): void {
+ this._dialogParams?.cancel?.();
+ this.closeDialog();
+ }
+
+ private async _createSortable() {
+ const Sortable = (await import("../../resources/sortable")).default;
+ if (this._sortable) return;
+ this._sortable = new Sortable(this.shadowRoot!.querySelector(".areas")!, {
+ animation: 150,
+ fallbackClass: "sortable-fallback",
+ handle: ".handle",
+ onChoose: (evt: SortableEvent) => {
+ (evt.item as any).placeholder =
+ document.createComment("sort-placeholder");
+ evt.item.after((evt.item as any).placeholder);
+ },
+ onEnd: (evt: SortableEvent) => {
+ // put back in original location
+ if ((evt.item as any).placeholder) {
+ (evt.item as any).placeholder.replaceWith(evt.item);
+ delete (evt.item as any).placeholder;
+ }
+ this._dragged(evt);
+ },
+ });
+ }
+
+ private _destroySortable() {
+ this._sortable?.destroy();
+ this._sortable = undefined;
+ }
+
+ private _dragged(ev: SortableEvent): void {
+ if (ev.oldIndex === ev.newIndex) return;
+
+ const areas = this._areas.concat();
+
+ const option = areas.splice(ev.oldIndex!, 1)[0];
+ areas.splice(ev.newIndex!, 0, option);
+
+ this._areas = areas;
+ }
+
+ protected render() {
+ if (!this._dialogParams || !this.hass) {
+ return nothing;
+ }
+
+ const allAreas = this._areas;
+
+ return html`
+
+
+ ${repeat(
+ allAreas,
+ (area) => area,
+ (area, _idx) => {
+ const isVisible = !this._hidden.includes(area);
+ const name = this.hass!.areas[area]?.name || area;
+ return html`
+
+
+ ${name}
+
+
+ `;
+ }
+ )}
+
+
+ ${this.hass.localize("ui.common.cancel")}
+
+
+ ${this.hass.localize("ui.common.submit")}
+
+
+ `;
+ }
+
+ _toggle(ev) {
+ const area = ev.target.area;
+ const hidden = [...(this._hidden ?? [])];
+ if (hidden.includes(area)) {
+ hidden.splice(hidden.indexOf(area), 1);
+ } else {
+ hidden.push(area);
+ }
+ this._hidden = hidden;
+ }
+
+ static get styles(): CSSResultGroup {
+ return [
+ sortableStyles,
+ haStyleDialog,
+ css`
+ ha-dialog {
+ /* Place above other dialogs */
+ --dialog-z-index: 104;
+ --dialog-content-padding: 0;
+ }
+ ha-list-item {
+ overflow: visible;
+ }
+ .hidden {
+ opacity: 0.3;
+ }
+ .handle {
+ cursor: grab;
+ }
+ .actions {
+ display: flex;
+ flex-direction: row;
+ }
+ ha-icon-button {
+ display: block;
+ margin: -12px;
+ }
+ `,
+ ];
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "dialog-area-filter": DialogAreaFilter;
+ }
+}
diff --git a/src/dialogs/area-filter/show-area-filter-dialog.ts b/src/dialogs/area-filter/show-area-filter-dialog.ts
new file mode 100644
index 0000000000..148db2d8bc
--- /dev/null
+++ b/src/dialogs/area-filter/show-area-filter-dialog.ts
@@ -0,0 +1,38 @@
+import { fireEvent } from "../../common/dom/fire_event";
+import type { AreaFilterValue } from "../../components/ha-area-filter";
+
+export interface AreaFilterDialogParams {
+ title?: string;
+ initialValue?: AreaFilterValue;
+ submit?: (value?: AreaFilterValue) => void;
+ cancel?: () => void;
+}
+
+export const showAreaFilterDialog = (
+ element: HTMLElement,
+ dialogParams: AreaFilterDialogParams
+) =>
+ new Promise((resolve) => {
+ const origCancel = dialogParams.cancel;
+ const origSubmit = dialogParams.submit;
+
+ fireEvent(element, "show-dialog", {
+ dialogTag: "dialog-area-filter",
+ dialogImport: () => import("./area-filter-dialog"),
+ dialogParams: {
+ ...dialogParams,
+ cancel: () => {
+ resolve(null);
+ if (origCancel) {
+ origCancel();
+ }
+ },
+ submit: (code: AreaFilterValue) => {
+ resolve(code);
+ if (origSubmit) {
+ origSubmit(code);
+ }
+ },
+ },
+ });
+ });
diff --git a/src/panels/lovelace/common/generate-lovelace-config.ts b/src/panels/lovelace/common/generate-lovelace-config.ts
index bb3e08da62..56c7b3ced8 100644
--- a/src/panels/lovelace/common/generate-lovelace-config.ts
+++ b/src/panels/lovelace/common/generate-lovelace-config.ts
@@ -7,6 +7,7 @@ import { splitByGroups } from "../../../common/entity/split_by_groups";
import { stripPrefixFromEntityName } from "../../../common/entity/strip_prefix_from_entity_name";
import { stringCompare } from "../../../common/string/compare";
import { LocalizeFunc } from "../../../common/translations/localize";
+import type { AreaFilterValue } from "../../../components/ha-area-filter";
import {
EnergyPreferences,
GridSourceTypeEnergyPreference,
@@ -27,6 +28,7 @@ import {
} from "../cards/types";
import { EntityConfig } from "../entity-rows/types";
import { ButtonsHeaderFooterConfig } from "../header-footer/types";
+import { areaCompare } from "../../../data/area_registry";
const HIDE_DOMAIN = new Set([
"automation",
@@ -447,9 +449,7 @@ export const generateDefaultViewConfig = (
entities: HassEntities,
localize: LocalizeFunc,
energyPrefs?: EnergyPreferences,
- areasPrefs?: {
- hidden?: string[];
- },
+ areasPrefs?: AreaFilterValue,
hideEntitiesWithoutAreas?: boolean,
hideEnergy?: boolean
): LovelaceViewConfig => {
@@ -511,15 +511,12 @@ export const generateDefaultViewConfig = (
const areaCards: LovelaceCardConfig[] = [];
- const sortedAreas = Object.entries(
- splittedByAreaDevice.areasWithEntities
- ).sort((a, b) => {
- const areaA = areaEntries[a[0]];
- const areaB = areaEntries[b[0]];
- return stringCompare(areaA.name, areaB.name);
- });
+ const sortedAreas = Object.keys(splittedByAreaDevice.areasWithEntities).sort(
+ areaCompare(areaEntries, areasPrefs?.order)
+ );
- for (const [areaId, areaEntities] of sortedAreas) {
+ for (const areaId of sortedAreas) {
+ const areaEntities = splittedByAreaDevice.areasWithEntities[areaId];
const area = areaEntries[areaId];
areaCards.push(
...computeCards(
diff --git a/src/panels/lovelace/editor/dashboard-strategy-editor/hui-original-states-dashboard-strategy-editor.ts b/src/panels/lovelace/editor/dashboard-strategy-editor/hui-original-states-dashboard-strategy-editor.ts
index 77e5c58034..48c18f8424 100644
--- a/src/panels/lovelace/editor/dashboard-strategy-editor/hui-original-states-dashboard-strategy-editor.ts
+++ b/src/panels/lovelace/editor/dashboard-strategy-editor/hui-original-states-dashboard-strategy-editor.ts
@@ -1,6 +1,5 @@
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 "../../../../components/ha-form/ha-form";
import type {
@@ -13,11 +12,9 @@ import { LovelaceStrategyEditor } from "../../strategies/types";
const SCHEMA = [
{
- name: "hidden_areas",
+ name: "areas",
selector: {
- area: {
- multiple: true,
- },
+ area_filter: {},
},
},
{
@@ -40,12 +37,6 @@ const SCHEMA = [
},
] as const satisfies readonly HaFormSchema[];
-type FormData = {
- hidden_areas: string[];
- hide_energy?: boolean;
- hide_entities_without_area?: boolean;
-};
-
@customElement("hui-original-states-dashboard-strategy-editor")
export class HuiOriginalStatesDashboarStrategyEditor
extends LitElement
@@ -60,44 +51,15 @@ export class HuiOriginalStatesDashboarStrategyEditor
this._config = config;
}
- private _configToFormData = memoizeOne(
- (config: OriginalStatesDashboardStrategyConfig): FormData => {
- const { areas, ...rest } = config;
- return {
- ...rest,
- hidden_areas: areas?.hidden || [],
- };
- }
- );
-
- private _formDataToConfig = memoizeOne(
- (data: FormData): OriginalStatesDashboardStrategyConfig => {
- const { hidden_areas, ...rest } = data;
- const areas =
- hidden_areas.length > 0
- ? {
- hidden: hidden_areas,
- }
- : undefined;
- return {
- type: "original-states",
- ...rest,
- areas,
- };
- }
- );
-
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
- const data = this._configToFormData(this._config);
-
return html`
) => {
switch (schema.name) {
- case "hidden_areas":
+ case "areas":
case "hide_energy":
case "hide_entities_without_area":
return this.hass?.localize(
diff --git a/src/panels/lovelace/strategies/original-states-view-strategy.ts b/src/panels/lovelace/strategies/original-states-view-strategy.ts
index 86f3017315..9fcf20e974 100644
--- a/src/panels/lovelace/strategies/original-states-view-strategy.ts
+++ b/src/panels/lovelace/strategies/original-states-view-strategy.ts
@@ -2,6 +2,7 @@ import { STATE_NOT_RUNNING } from "home-assistant-js-websocket";
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
+import type { AreaFilterValue } from "../../../components/ha-area-filter";
import { getEnergyPreferences } from "../../../data/energy";
import { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import { HomeAssistant } from "../../../types";
@@ -9,9 +10,7 @@ import { generateDefaultViewConfig } from "../common/generate-lovelace-config";
export type OriginalStatesViewStrategyConfig = {
type: "original-states";
- areas?: {
- hidden?: string[];
- };
+ areas?: AreaFilterValue;
hide_entities_without_area?: boolean;
hide_energy?: boolean;
};
diff --git a/src/translations/en.json b/src/translations/en.json
index 95601f7fc2..268ad8ff84 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -512,6 +512,14 @@
"failed_create_area": "Failed to create area."
}
},
+ "area-filter": {
+ "title": "Areas",
+ "no_areas": "No areas",
+ "area_count": "{count} {count, plural,\n one {area}\n other {areas}\n}",
+ "all_areas": "All areas",
+ "show": "Show {area}",
+ "hide": "Hide {area}"
+ },
"statistic-picker": {
"statistic": "Statistic",
"no_statistics": "You don't have any statistics",
@@ -5269,7 +5277,7 @@
},
"strategy": {
"original-states": {
- "hidden_areas": "Hidden Areas",
+ "areas": "Areas",
"hide_entities_without_area": "Hide entities without area",
"hide_energy": "Hide energy"
}