Allow to re-order floors in areas dashboard (#26002)

* Allow to re-order floors in areas dashboard

* Move drag handle to right

* Improve typings

* Only show drag handle if there is at least 2 floors
This commit is contained in:
Paul Bottein 2025-06-30 18:09:42 +02:00 committed by GitHub
parent 8cc762d839
commit 0fbd430594
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 199 additions and 117 deletions

View File

@ -1,14 +1,15 @@
import { mdiTextureBox } from "@mdi/js"; import { mdiDrag, mdiTextureBox } from "@mdi/js";
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { computeFloorName } from "../common/entity/compute_floor_name"; import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context"; import { getAreaContext } from "../common/entity/context/get_area_context";
import { stringCompare } from "../common/string/compare";
import { areaCompare } from "../data/area_registry"; import { areaCompare } from "../data/area_registry";
import type { FloorRegistryEntry } from "../data/floor_registry"; import type { FloorRegistryEntry } from "../data/floor_registry";
import { getFloors } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import "./ha-expansion-panel"; import "./ha-expansion-panel";
import "./ha-floor-icon"; import "./ha-floor-icon";
@ -17,9 +18,14 @@ import type { DisplayItem, DisplayValue } from "./ha-items-display-editor";
import "./ha-svg-icon"; import "./ha-svg-icon";
import "./ha-textfield"; import "./ha-textfield";
export interface AreasDisplayValue { export interface AreasFloorsDisplayValue {
areas_display?: {
hidden?: string[]; hidden?: string[];
order?: string[]; order?: string[];
};
floors_display?: {
order?: string[];
};
} }
const UNASSIGNED_FLOOR = "__unassigned__"; const UNASSIGNED_FLOOR = "__unassigned__";
@ -30,12 +36,10 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
@property() public label?: string; @property() public label?: string;
@property({ attribute: false }) public value?: AreasDisplayValue; @property({ attribute: false }) public value?: AreasFloorsDisplayValue;
@property() public helper?: string; @property() public helper?: string;
@property({ type: Boolean }) public expanded = false;
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@ -44,55 +48,78 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
public showNavigationButton = false; public showNavigationButton = false;
protected render(): TemplateResult { protected render(): TemplateResult {
const groupedItems = this._groupedItems(this.hass.areas, this.hass.floors); const groupedAreasItems = this._groupedAreasItems(
this.hass.areas,
this.hass.floors
);
const filteredFloors = this._sortedFloors(this.hass.floors).filter( const filteredFloors = this._sortedFloors(
this.hass.floors,
this.value?.floors_display?.order
).filter(
(floor) => (floor) =>
// Only include floors that have areas assigned to them // Only include floors that have areas assigned to them
groupedItems[floor.floor_id]?.length > 0 groupedAreasItems[floor.floor_id]?.length > 0
); );
const value: DisplayValue = { const value: DisplayValue = {
order: this.value?.order ?? [], order: this.value?.areas_display?.order ?? [],
hidden: this.value?.hidden ?? [], hidden: this.value?.areas_display?.hidden ?? [],
}; };
const canReorderFloors =
filteredFloors.filter((floor) => floor.floor_id !== UNASSIGNED_FLOOR)
.length > 1;
return html` return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-sortable
draggable-selector=".draggable"
handle-selector=".handle"
@item-moved=${this._floorMoved}
.disabled=${this.disabled || !canReorderFloors}
>
<div>
${repeat(
filteredFloors,
(floor) => floor.floor_id,
(floor: FloorRegistryEntry) => html`
<ha-expansion-panel <ha-expansion-panel
outlined outlined
.header=${this.label} .header=${computeFloorName(floor)}
.expanded=${this.expanded} left-chevron
class=${floor.floor_id === UNASSIGNED_FLOOR ? "" : "draggable"}
> >
<ha-svg-icon slot="leading-icon" .path=${mdiTextureBox}></ha-svg-icon> <ha-floor-icon
${filteredFloors.map((floor, _, array) => { slot="leading-icon"
const noFloors = .floor=${floor}
array.length === 1 && floor.floor_id === UNASSIGNED_FLOOR; ></ha-floor-icon>
return html` ${floor.floor_id === UNASSIGNED_FLOOR || !canReorderFloors
<div class="floor">
${noFloors
? nothing ? nothing
: html`<div class="header"> : html`
<ha-floor-icon .floor=${floor}></ha-floor-icon> <ha-svg-icon
<p>${computeFloorName(floor)}</p> class="handle"
</div>`} slot="icons"
<div class="areas"> .path=${mdiDrag}
></ha-svg-icon>
`}
<ha-items-display-editor <ha-items-display-editor
.hass=${this.hass} .hass=${this.hass}
.items=${groupedItems[floor.floor_id] || []} .items=${groupedAreasItems[floor.floor_id]}
.value=${value} .value=${value}
.floorId=${floor.floor_id ?? UNASSIGNED_FLOOR} .floorId=${floor.floor_id}
@value-changed=${this._areaDisplayChanged} @value-changed=${this._areaDisplayChanged}
.showNavigationButton=${this.showNavigationButton} .showNavigationButton=${this.showNavigationButton}
></ha-items-display-editor> ></ha-items-display-editor>
</div>
</div>
`;
})}
</ha-expansion-panel> </ha-expansion-panel>
`
)}
</div>
</ha-sortable>
`; `;
} }
private _groupedItems = memoizeOne( private _groupedAreasItems = memoizeOne(
( (
hassAreas: HomeAssistant["areas"], hassAreas: HomeAssistant["areas"],
// update items if floors change // update items if floors change
@ -116,7 +143,6 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
label: area.name, label: area.name,
icon: area.icon ?? undefined, icon: area.icon ?? undefined,
iconPath: mdiTextureBox, iconPath: mdiTextureBox,
description: floor?.name,
}); });
return acc; return acc;
@ -128,18 +154,17 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
); );
private _sortedFloors = memoizeOne( private _sortedFloors = memoizeOne(
(hassFloors: HomeAssistant["floors"]): FloorRegistryEntry[] => { (
const floors = Object.values(hassFloors).sort((floorA, floorB) => { hassFloors: HomeAssistant["floors"],
if (floorA.level !== floorB.level) { order: string[] | undefined
return (floorA.level ?? 0) - (floorB.level ?? 0); ): FloorRegistryEntry[] => {
} const floors = getFloors(hassFloors, order);
return stringCompare(floorA.name, floorB.name); const noFloors = floors.length === 0;
});
floors.push({ floors.push({
floor_id: UNASSIGNED_FLOOR, floor_id: UNASSIGNED_FLOOR,
name: this.hass.localize( name: noFloors
"ui.panel.lovelace.strategy.areas.others_areas" ? this.hass.localize("ui.panel.lovelace.strategy.areas.areas")
), : this.hass.localize("ui.panel.lovelace.strategy.areas.other_areas"),
icon: null, icon: null,
level: null, level: null,
aliases: [], aliases: [],
@ -150,17 +175,43 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
} }
); );
private async _areaDisplayChanged(ev) { private _floorMoved(ev: CustomEvent<HASSDomEvents["item-moved"]>) {
ev.stopPropagation(); ev.stopPropagation();
const value = ev.detail.value as DisplayValue; const newIndex = ev.detail.newIndex;
const currentFloorId = ev.currentTarget.floorId; const oldIndex = ev.detail.oldIndex;
const floorIds = this._sortedFloors(
this.hass.floors,
this.value?.floors_display?.order
).map((floor) => floor.floor_id);
const newOrder = [...floorIds];
const movedFloorId = newOrder.splice(oldIndex, 1)[0];
newOrder.splice(newIndex, 0, movedFloorId);
const newValue: AreasFloorsDisplayValue = {
areas_display: this.value?.areas_display,
floors_display: {
order: newOrder,
},
};
if (newValue.floors_display?.order?.length === 0) {
delete newValue.floors_display.order;
}
fireEvent(this, "value-changed", { value: newValue });
}
const floorIds = this._sortedFloors(this.hass.floors).map( private async _areaDisplayChanged(ev: CustomEvent<{ value: DisplayValue }>) {
(floor) => floor.floor_id ev.stopPropagation();
); const value = ev.detail.value;
const currentFloorId = (ev.currentTarget as any).floorId;
const oldHidden = this.value?.hidden ?? []; const floorIds = this._sortedFloors(
const oldOrder = this.value?.order ?? []; this.hass.floors,
this.value?.floors_display?.order
).map((floor) => floor.floor_id);
const oldAreaDisplay = this.value?.areas_display ?? {};
const oldHidden = oldAreaDisplay?.hidden ?? [];
const oldOrder = oldAreaDisplay?.order ?? [];
const newHidden: string[] = []; const newHidden: string[] = [];
const newOrder: string[] = []; const newOrder: string[] = [];
@ -187,37 +238,27 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
} }
} }
const newValue: AreasDisplayValue = { const newValue: AreasFloorsDisplayValue = {
areas_display: {
hidden: newHidden, hidden: newHidden,
order: newOrder, order: newOrder,
},
floors_display: this.value?.floors_display,
}; };
if (newValue.hidden?.length === 0) { if (newValue.areas_display?.hidden?.length === 0) {
delete newValue.hidden; delete newValue.areas_display.hidden;
} }
if (newValue.order?.length === 0) { if (newValue.areas_display?.order?.length === 0) {
delete newValue.order; delete newValue.areas_display.order;
} }
this.value = newValue; if (newValue.floors_display?.order?.length === 0) {
delete newValue.floors_display.order;
}
fireEvent(this, "value-changed", { value: newValue }); fireEvent(this, "value-changed", { value: newValue });
} }
static styles = css` static styles = css`
.floor .header p {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
flex: 1;
}
.floor .header {
margin: 16px 0 8px 0;
padding: 0 8px;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
ha-expansion-panel { ha-expansion-panel {
margin-bottom: 8px; margin-bottom: 8px;
--expansion-panel-summary-padding: 0 16px; --expansion-panel-summary-padding: 0 16px;
@ -225,6 +266,11 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
ha-expansion-panel [slot="leading-icon"] { ha-expansion-panel [slot="leading-icon"] {
margin-inline-end: 16px; margin-inline-end: 16px;
} }
label {
display: block;
font-weight: var(--ha-font-weight-bold);
margin-bottom: 8px;
}
`; `;
} }

View File

@ -122,22 +122,6 @@ export class HaItemDisplayEditor extends LitElement {
${description ${description
? html`<span slot="supporting-text">${description}</span>` ? html`<span slot="supporting-text">${description}</span>`
: nothing} : nothing}
${isVisible && !disableSorting
? html`
<ha-svg-icon
tabindex=${ifDefined(
this.showNavigationButton ? "0" : undefined
)}
.idx=${idx}
@keydown=${this.showNavigationButton
? this._dragHandleKeydown
: undefined}
class="handle"
.path=${mdiDrag}
slot="start"
></ha-svg-icon>
`
: html`<ha-svg-icon slot="start"></ha-svg-icon>`}
${!showIcon ${!showIcon
? nothing ? nothing
: icon : icon
@ -162,6 +146,9 @@ export class HaItemDisplayEditor extends LitElement {
<span slot="end"> ${this.actionsRenderer(item)} </span> <span slot="end"> ${this.actionsRenderer(item)} </span>
` `
: nothing} : nothing}
${this.showNavigationButton
? html`<ha-icon-next slot="end"></ha-icon-next>`
: nothing}
<ha-icon-button <ha-icon-button
.path=${isVisible ? mdiEye : mdiEyeOff} .path=${isVisible ? mdiEye : mdiEyeOff}
slot="end" slot="end"
@ -174,9 +161,22 @@ export class HaItemDisplayEditor extends LitElement {
.value=${value} .value=${value}
@click=${this._toggle} @click=${this._toggle}
></ha-icon-button> ></ha-icon-button>
${this.showNavigationButton ${isVisible && !disableSorting
? html` <ha-icon-next slot="end"></ha-icon-next> ` ? html`
: nothing} <ha-svg-icon
tabindex=${ifDefined(
this.showNavigationButton ? "0" : undefined
)}
.idx=${idx}
@keydown=${this.showNavigationButton
? this._dragHandleKeydown
: undefined}
class="handle"
.path=${mdiDrag}
slot="end"
></ha-svg-icon>
`
: html`<ha-svg-icon slot="end"></ha-svg-icon>`}
</ha-md-list-item> </ha-md-list-item>
`; `;
} }

View File

@ -22,6 +22,9 @@ export interface AreasDashboardStrategyConfig {
hidden?: string[]; hidden?: string[];
order?: string[]; order?: string[];
}; };
floors_display?: {
order?: string[];
};
areas_options?: Record<string, AreaOptions>; areas_options?: Record<string, AreaOptions>;
} }
@ -84,6 +87,7 @@ export class AreasDashboardStrategy extends ReactiveElement {
type: "areas-overview", type: "areas-overview",
areas_display: config.areas_display, areas_display: config.areas_display,
areas_options: config.areas_options, areas_options: config.areas_options,
floors_display: config.floors_display,
} satisfies AreasViewStrategyConfig, } satisfies AreasViewStrategyConfig,
}, },
...areaViews, ...areaViews,

View File

@ -1,6 +1,5 @@
import { ReactiveElement } from "lit"; import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import { stringCompare } from "../../../../common/string/compare";
import { floorDefaultIcon } from "../../../../components/ha-floor-icon"; import { floorDefaultIcon } from "../../../../components/ha-floor-icon";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section"; import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view"; import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
@ -9,7 +8,11 @@ import { getAreaControlEntities } from "../../card-features/hui-area-controls-ca
import { AREA_CONTROLS, type AreaControl } from "../../card-features/types"; import { AREA_CONTROLS, type AreaControl } from "../../card-features/types";
import type { AreaCardConfig, HeadingCardConfig } from "../../cards/types"; import type { AreaCardConfig, HeadingCardConfig } from "../../cards/types";
import type { EntitiesDisplay } from "./area-view-strategy"; import type { EntitiesDisplay } from "./area-view-strategy";
import { computeAreaPath, getAreas } from "./helpers/areas-strategy-helper"; import {
computeAreaPath,
getAreas,
getFloors,
} from "./helpers/areas-strategy-helper";
const UNASSIGNED_FLOOR = "__unassigned__"; const UNASSIGNED_FLOOR = "__unassigned__";
@ -23,6 +26,9 @@ export interface AreasViewStrategyConfig {
hidden?: string[]; hidden?: string[];
order?: string[]; order?: string[];
}; };
floors_display?: {
order?: string[];
};
areas_options?: Record<string, AreaOptions>; areas_options?: Record<string, AreaOptions>;
} }
@ -38,19 +44,13 @@ export class AreasOverviewViewStrategy extends ReactiveElement {
config.areas_display?.order config.areas_display?.order
); );
const floors = Object.values(hass.floors); const floors = getFloors(hass.floors, config.floors_display?.order);
floors.sort((floorA, floorB) => {
if (floorA.level !== floorB.level) {
return (floorA.level ?? 0) - (floorB.level ?? 0);
}
return stringCompare(floorA.name, floorB.name);
});
const floorSections = [ const floorSections = [
...floors, ...floors,
{ {
floor_id: UNASSIGNED_FLOOR, floor_id: UNASSIGNED_FLOOR,
name: hass.localize("ui.panel.lovelace.strategy.areas.others_areas"), name: hass.localize("ui.panel.lovelace.strategy.areas.other_areas"),
level: null, level: null,
icon: null, icon: null,
}, },

View File

@ -1,10 +1,12 @@
import { mdiThermometerWater } from "@mdi/js"; import { mdiThermometerWater } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event"; import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-areas-display-editor"; import "../../../../../components/ha-areas-display-editor";
import type { AreasDisplayValue } from "../../../../../components/ha-areas-display-editor"; import type { AreasDisplayValue } from "../../../../../components/ha-areas-display-editor";
import "../../../../../components/ha-areas-floors-display-editor"; import "../../../../../components/ha-areas-floors-display-editor";
import type { AreasFloorsDisplayValue } from "../../../../../components/ha-areas-floors-display-editor";
import "../../../../../components/ha-entities-display-editor"; import "../../../../../components/ha-entities-display-editor";
import "../../../../../components/ha-icon"; import "../../../../../components/ha-icon";
import "../../../../../components/ha-icon-button"; import "../../../../../components/ha-icon-button";
@ -126,7 +128,7 @@ export class HuiAreasDashboardStrategyEditor
`; `;
} }
const value = this._config.areas_display; const value = this._areasFloorsDisplayValue(this._config);
return html` return html`
<ha-areas-floors-display-editor <ha-areas-floors-display-editor
@ -135,7 +137,7 @@ export class HuiAreasDashboardStrategyEditor
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.areas.areas_display" "ui.panel.lovelace.editor.strategy.areas.areas_display"
)} )}
@value-changed=${this._areasDisplayChanged} @value-changed=${this._areasFloorsDisplayChanged}
expanded expanded
show-navigation-button show-navigation-button
@item-display-navigate-clicked=${this._handleAreaNavigate} @item-display-navigate-clicked=${this._handleAreaNavigate}
@ -149,6 +151,13 @@ export class HuiAreasDashboardStrategyEditor
} }
} }
private _areasFloorsDisplayValue = memoizeOne(
(config: AreasDashboardStrategyConfig): AreasFloorsDisplayValue => ({
areas_display: config.areas_display,
floors_display: config.floors_display,
})
);
private _editArea(ev: Event): void { private _editArea(ev: Event): void {
ev.stopPropagation(); ev.stopPropagation();
const area = (ev.currentTarget! as any).area as AreaRegistryEntry; const area = (ev.currentTarget! as any).area as AreaRegistryEntry;
@ -163,11 +172,11 @@ export class HuiAreasDashboardStrategyEditor
this._area = ev.detail.value; this._area = ev.detail.value;
} }
private _areasDisplayChanged(ev: CustomEvent): void { private _areasFloorsDisplayChanged(ev: CustomEvent): void {
const value = ev.detail.value as AreasDisplayValue; const value = ev.detail.value as AreasFloorsDisplayValue;
const newConfig: AreasDashboardStrategyConfig = { const newConfig: AreasDashboardStrategyConfig = {
...this._config!, ...this._config!,
areas_display: value, ...value,
}; };
fireEvent(this, "config-changed", { config: newConfig }); fireEvent(this, "config-changed", { config: newConfig });

View File

@ -3,9 +3,13 @@ import { computeStateName } from "../../../../../common/entity/compute_state_nam
import type { EntityFilterFunc } from "../../../../../common/entity/entity_filter"; import type { EntityFilterFunc } from "../../../../../common/entity/entity_filter";
import { generateEntityFilter } from "../../../../../common/entity/entity_filter"; import { generateEntityFilter } from "../../../../../common/entity/entity_filter";
import { stripPrefixFromEntityName } from "../../../../../common/entity/strip_prefix_from_entity_name"; import { stripPrefixFromEntityName } from "../../../../../common/entity/strip_prefix_from_entity_name";
import { orderCompare } from "../../../../../common/string/compare"; import {
orderCompare,
stringCompare,
} from "../../../../../common/string/compare";
import type { AreaRegistryEntry } from "../../../../../data/area_registry"; import type { AreaRegistryEntry } from "../../../../../data/area_registry";
import { areaCompare } from "../../../../../data/area_registry"; import { areaCompare } from "../../../../../data/area_registry";
import type { FloorRegistryEntry } from "../../../../../data/floor_registry";
import type { LovelaceCardConfig } from "../../../../../data/lovelace/config/card"; import type { LovelaceCardConfig } from "../../../../../data/lovelace/config/card";
import type { HomeAssistant } from "../../../../../types"; import type { HomeAssistant } from "../../../../../types";
import { supportsAlarmModesCardFeature } from "../../../card-features/hui-alarm-modes-card-feature"; import { supportsAlarmModesCardFeature } from "../../../card-features/hui-alarm-modes-card-feature";
@ -290,4 +294,23 @@ export const getAreas = (
return sortedAreas; return sortedAreas;
}; };
export const getFloors = (
entries: HomeAssistant["floors"],
floorsOrder?: string[]
): FloorRegistryEntry[] => {
const floors = Object.values(entries);
const compare = orderCompare(floorsOrder || []);
return floors.sort((floorA, floorB) => {
const order = compare(floorA.floor_id, floorB.floor_id);
if (order !== 0) {
return order;
}
if (floorA.level !== floorB.level) {
return (floorA.level ?? 0) - (floorB.level ?? 0);
}
return stringCompare(floorA.name, floorB.name);
});
};
export const computeAreaPath = (areaId: string): string => `areas-${areaId}`; export const computeAreaPath = (areaId: string): string => `areas-${areaId}`;

View File

@ -6686,7 +6686,7 @@
"actions": "Actions", "actions": "Actions",
"others": "Others" "others": "Others"
}, },
"others_areas": "Other areas", "other_areas": "Other areas",
"areas": "Areas" "areas": "Areas"
} }
}, },