20240424.1 (#20609)

This commit is contained in:
Bram Kragten 2024-04-24 14:45:22 +02:00 committed by GitHub
commit 64f54d9aaa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 752 additions and 125 deletions

View File

@ -102,7 +102,7 @@
"color-name": "2.0.0",
"comlink": "4.4.1",
"core-js": "3.37.0",
"cropperjs": "1.6.1",
"cropperjs": "1.6.2",
"date-fns": "3.6.0",
"date-fns-tz": "3.1.3",
"deep-clone-simple": "1.1.1",

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20240424.0"
version = "20240424.1"
license = {text = "Apache-2.0"}
description = "The Home Assistant frontend"
readme = "README.md"

View File

@ -69,7 +69,7 @@ export class HaFilterDevices extends LitElement {
@value-changed=${this._handleSearchChange}
>
</search-input-outlined>
<mwc-list class="ha-scrollbar">
<mwc-list class="ha-scrollbar" multi>
<lit-virtualizer
.items=${this._devices(
this.hass.devices,

View File

@ -0,0 +1,198 @@
import { mdiFilterVariantRemove } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import { domainToName } from "../data/integration";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-domain-icon";
import "./search-input-outlined";
import { computeDomain } from "../common/entity/compute_domain";
@customElement("ha-filter-domains")
export class HaFilterDomains extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize(
"ui.panel.config.entities.picker.headers.domain"
)}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing}
</div>
${this._shouldRender
? html`<search-input-outlined
.hass=${this.hass}
.filter=${this._filter}
@value-changed=${this._handleSearchChange}
>
</search-input-outlined>
<mwc-list
class="ha-scrollbar"
@click=${this._handleItemClick}
multi
>
${repeat(
this._domains(this.hass.states, this._filter),
(i) => i,
(domain) =>
html`<ha-check-list-item
.value=${domain}
.selected=${(this.value || []).includes(domain)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.hass=${this.hass}
.domain=${domain}
brandFallback
></ha-domain-icon>
${domainToName(this.hass.localize, domain)}
</ha-check-list-item>`
)}
</mwc-list> `
: nothing}
</ha-expansion-panel>
`;
}
private _domains = memoizeOne((states, filter) => {
const domains = new Set<string>();
Object.keys(states).forEach((entityId) => {
domains.add(computeDomain(entityId));
});
return Array.from(domains)
.filter((domain) => !filter || domain.toLowerCase().includes(filter))
.sort((a, b) => stringCompare(a, b, this.hass.locale.language));
});
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49 - 32}px`; // 32px is the height of the search input
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
return;
}
if (this.value?.includes(value)) {
this.value = this.value?.filter((val) => val !== value);
} else {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value.includes(value);
fireEvent(this, "data-table-filter-changed", {
value: this.value,
items: undefined,
});
}
private _clearFilter(ev) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value.toLowerCase();
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--primary-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
search-input-outlined {
display: block;
padding: 0 8px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-domains": HaFilterDomains;
}
}

View File

@ -71,7 +71,7 @@ export class HaFilterEntities extends LitElement {
@value-changed=${this._handleSearchChange}
>
</search-input-outlined>
<mwc-list class="ha-scrollbar">
<mwc-list class="ha-scrollbar" multi>
<lit-virtualizer
.items=${this._entities(
this.hass.states,

View File

@ -55,7 +55,11 @@ export class HaFilterIntegrations extends LitElement {
@value-changed=${this._handleSearchChange}
>
</search-input-outlined>
<mwc-list class="ha-scrollbar" @click=${this._handleItemClick}>
<mwc-list
class="ha-scrollbar"
@click=${this._handleItemClick}
multi
>
${repeat(
this._integrations(this._manifests, this._filter, this.value),
(i) => i.domain,

View File

@ -19,7 +19,7 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import type { HomeAssistant } from "../../types";
import type { HomeAssistant, ThemeMode } from "../../types";
import "../ha-input-helper-text";
import "./ha-map";
import type { HaMap } from "./ha-map";
@ -61,7 +61,8 @@ export class HaLocationsEditor extends LitElement {
@property({ type: Number }) public zoom = 16;
@property({ type: Boolean }) public darkMode = false;
@property({ attribute: "theme-mode", type: String })
public themeMode: ThemeMode = "auto";
@state() private _locationMarkers?: Record<string, Marker | Circle>;
@ -133,7 +134,7 @@ export class HaLocationsEditor extends LitElement {
.layers=${this._getLayers(this._circles, this._locationMarkers)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
?darkMode=${this.darkMode}
.themeMode=${this.themeMode}
></ha-map>
${this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`

View File

@ -1,32 +1,32 @@
import { isToday } from "date-fns";
import type {
Circle,
CircleMarker,
LatLngTuple,
LatLngExpression,
LatLngTuple,
Layer,
Map,
Marker,
Polyline,
} from "leaflet";
import { isToday } from "date-fns";
import { css, CSSResultGroup, PropertyValues, ReactiveElement } from "lit";
import { CSSResultGroup, PropertyValues, ReactiveElement, css } from "lit";
import { customElement, property, state } from "lit/decorators";
import { formatDateTime } from "../../common/datetime/format_date_time";
import {
formatTimeWeekday,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
import {
LeafletModuleType,
setupLeafletMap,
} from "../../common/dom/setup-leaflet-map";
import {
formatTimeWithSeconds,
formatTimeWeekday,
} from "../../common/datetime/format_time";
import { formatDateTime } from "../../common/datetime/format_date_time";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { loadPolyfillIfNeeded } from "../../resources/resize-observer.polyfill";
import { HomeAssistant } from "../../types";
import { HomeAssistant, ThemeMode } from "../../types";
import { isTouch } from "../../util/is_touch";
import "../ha-icon-button";
import "./ha-entity-marker";
import { isTouch } from "../../util/is_touch";
const getEntityId = (entity: string | HaMapEntity): string =>
typeof entity === "string" ? entity : entity.entity_id;
@ -69,7 +69,8 @@ export class HaMap extends ReactiveElement {
@property({ type: Boolean }) public fitZones = false;
@property({ type: Boolean }) public darkMode = false;
@property({ attribute: "theme-mode", type: String })
public themeMode: ThemeMode = "auto";
@property({ type: Number }) public zoom = 14;
@ -154,7 +155,7 @@ export class HaMap extends ReactiveElement {
}
if (
!changedProps.has("darkMode") &&
!changedProps.has("themeMode") &&
(!changedProps.has("hass") ||
(oldHass && oldHass.themes?.darkMode === this.hass.themes?.darkMode))
) {
@ -163,12 +164,18 @@ export class HaMap extends ReactiveElement {
this._updateMapStyle();
}
private get _darkMode() {
return (
this.themeMode === "dark" ||
(this.themeMode === "auto" && Boolean(this.hass.themes.darkMode))
);
}
private _updateMapStyle(): void {
const darkMode = this.darkMode || (this.hass.themes.darkMode ?? false);
const forcedDark = this.darkMode;
const map = this.renderRoot.querySelector("#map");
map!.classList.toggle("dark", darkMode);
map!.classList.toggle("forced-dark", forcedDark);
map!.classList.toggle("dark", this._darkMode);
map!.classList.toggle("forced-dark", this.themeMode === "dark");
map!.classList.toggle("forced-light", this.themeMode === "light");
}
private async _loadMap(): Promise<void> {
@ -398,8 +405,7 @@ export class HaMap extends ReactiveElement {
"--dark-primary-color"
);
const className =
this.darkMode || this.hass.themes.darkMode ? "dark" : "light";
const className = this._darkMode ? "dark" : "light";
for (const entity of this.entities) {
const stateObj = hass.states[getEntityId(entity)];
@ -543,27 +549,30 @@ export class HaMap extends ReactiveElement {
background: #090909;
}
#map.forced-dark {
color: #ffffff;
--map-filter: invert(0.9) hue-rotate(170deg) brightness(1.5)
contrast(1.2) saturate(0.3);
}
#map.forced-light {
background: #ffffff;
color: #000000;
--map-filter: invert(0);
}
#map:active {
cursor: grabbing;
cursor: -moz-grabbing;
cursor: -webkit-grabbing;
}
.light {
color: #000000;
}
.dark {
color: #ffffff;
}
.leaflet-tile-pane {
filter: var(--map-filter);
}
.dark .leaflet-bar a {
background-color: var(--card-background-color, #1c1c1c);
background-color: #1c1c1c;
color: #ffffff;
}
.dark .leaflet-bar a:hover {
background-color: #313131;
}
.leaflet-marker-draggable {
cursor: move !important;
}

View File

@ -41,7 +41,7 @@ import type { HomeAssistant } from "../types";
import { onBoardingStyles } from "./styles";
const AMSTERDAM: [number, number] = [52.3731339, 4.8903147];
const mql = matchMedia("(prefers-color-scheme: dark)");
const darkMql = matchMedia("(prefers-color-scheme: dark)");
const LOCATION_MARKER_ID = "location";
@customElement("onboarding-location")
@ -199,7 +199,7 @@ class OnboardingLocation extends LitElement {
this._highlightedMarker
)}
zoom="14"
.darkMode=${mql.matches}
.themeMode=${darkMql.matches ? "dark" : "light"}
.disabled=${this._working}
@location-updated=${this._locationChanged}
@marker-clicked=${this._markerClicked}

View File

@ -15,6 +15,7 @@ import {
mdiPlus,
mdiRobotHappy,
mdiTag,
mdiTextureBox,
mdiToggleSwitch,
mdiToggleSwitchOffOutline,
mdiTransitConnection,
@ -69,6 +70,7 @@ import type { HaMenu } from "../../../components/ha-menu";
import "../../../components/ha-menu-item";
import "../../../components/ha-sub-menu";
import "../../../components/ha-svg-icon";
import { createAreaRegistryEntry } from "../../../data/area_registry";
import {
AutomationEntity,
deleteAutomation,
@ -106,6 +108,7 @@ import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route, ServiceCallResponse } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { turnOnOffEntity } from "../../lovelace/common/entity/turn-on-off-entity";
import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import { configSections } from "../ha-panel-config";
@ -403,6 +406,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
${this.hass.localize("ui.panel.config.category.editor.add")}
</div>
</ha-menu-item>`;
const labelItems = html`${this._labels?.map((label) => {
const color = label.color ? computeCssColor(label.color) : undefined;
const selected = this._selected.every((entityId) =>
@ -440,10 +444,45 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
</div></ha-menu-item
>`;
const labelsInOverflow =
(this._sizeController.value && this._sizeController.value < 700) ||
const areaItems = html`${Object.values(this.hass.areas).map(
(area) =>
html`<ha-menu-item
.value=${area.area_id}
@click=${this._handleBulkArea}
>
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<div slot="headline">${area.name}</div>
</ha-menu-item>`
)}
<ha-menu-item .value=${null} @click=${this._handleBulkArea}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.no_area"
)}
</div>
</ha-menu-item>
<md-divider role="separator" tabindex="-1"></md-divider>
<ha-menu-item @click=${this._bulkCreateArea}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.add_area"
)}
</div>
</ha-menu-item>`;
const areasInOverflow =
(this._sizeController.value && this._sizeController.value < 900) ||
(!this._sizeController.value && this.hass.dockedSidebar === "docked");
const labelsInOverflow =
areasInOverflow &&
(!this._sizeController.value || this._sizeController.value < 700);
const automations = this._automations(
this.automations,
this._entityReg,
@ -598,6 +637,22 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
></ha-svg-icon>
</ha-assist-chip>
${labelItems}
</ha-button-menu-new>`}
${areasInOverflow
? nothing
: html`<ha-button-menu-new slot="selection-bar">
<ha-assist-chip
slot="trigger"
.label=${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.move_area"
)}
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
${areaItems}
</ha-button-menu-new>`}`
: nothing
}
@ -662,6 +717,24 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
</ha-sub-menu>`
: nothing
}
${
this.narrow || areasInOverflow
? html`<ha-sub-menu>
<ha-menu-item slot="item">
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.move_area"
)}
</div>
<ha-svg-icon
slot="end"
.path=${mdiChevronRight}
></ha-svg-icon>
</ha-menu-item>
<ha-menu slot="menu">${areaItems}</ha-menu>
</ha-sub-menu>`
: nothing
}
<ha-menu-item @click=${this._handleBulkEnable}>
<ha-svg-icon slot="start" .path=${mdiToggleSwitch}></ha-svg-icon>
<div slot="headline">
@ -1191,6 +1264,46 @@ ${rejected
}
}
private async _handleBulkArea(ev) {
const area = ev.currentTarget.value;
this._bulkAddArea(area);
}
private async _bulkAddArea(area: string) {
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
this._selected.forEach((entityId) => {
promises.push(
updateEntityRegistryEntry(this.hass, entityId, {
area_id: area,
})
);
});
const result = await Promise.allSettled(promises);
if (hasRejectedItems(result)) {
const rejected = rejectedItems(result);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.common.multiselect.failed", {
number: rejected.length,
}),
text: html`<pre>
${rejected
.map((r) => r.reason.message || r.reason.code || r.reason)
.join("\r\n")}</pre
>`,
});
}
}
private async _bulkCreateArea() {
showAreaRegistryDetailDialog(this, {
createEntry: async (values) => {
const area = await createAreaRegistryEntry(this.hass, values);
this._bulkAddArea(area.area_id);
return area;
},
});
}
private async _handleBulkEnable() {
const promises: Promise<ServiceCallResponse>[] = [];
this._selected.forEach((entityId) => {

View File

@ -53,6 +53,7 @@ import "../../../components/ha-alert";
import "../../../components/ha-button-menu";
import "../../../components/ha-check-list-item";
import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-domains";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-integrations";
import "../../../components/ha-filter-labels";
@ -443,6 +444,10 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
entryIds.includes(entity.config_entry_id))
);
filter.value!.forEach((domain) => filteredDomains.add(domain));
} else if (key === "ha-filter-domains" && filter.value?.length) {
filteredEntities = filteredEntities.filter((entity) =>
filter.value?.includes(computeDomain(entity.entity_id))
);
} else if (key === "ha-filter-labels" && filter.value?.length) {
filteredEntities = filteredEntities.filter((entity) =>
entity.labels.some((lbl) => filter.value!.includes(lbl))
@ -782,6 +787,15 @@ ${
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-devices>
<ha-filter-domains
.hass=${this.hass}
.value=${this._filters["ha-filter-domains"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-domains"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-domains>
<ha-filter-integrations
.hass=${this.hass}
.value=${this._filters["ha-filter-integrations"]?.value}

View File

@ -15,6 +15,7 @@ import {
mdiPlay,
mdiPlus,
mdiTag,
mdiTextureBox,
} from "@mdi/js";
import { differenceInDays } from "date-fns";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
@ -61,6 +62,7 @@ import "../../../components/ha-menu-item";
import "../../../components/ha-state-icon";
import "../../../components/ha-sub-menu";
import "../../../components/ha-svg-icon";
import { createAreaRegistryEntry } from "../../../data/area_registry";
import {
CategoryRegistryEntry,
createCategoryRegistryEntry,
@ -97,6 +99,7 @@ import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import { configSections } from "../ha-panel-config";
@ -406,6 +409,7 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
${this.hass.localize("ui.panel.config.category.editor.add")}
</div>
</ha-menu-item>`;
const labelItems = html` ${this._labels?.map((label) => {
const color = label.color ? computeCssColor(label.color) : undefined;
const selected = this._selected.every((entityId) =>
@ -442,9 +446,46 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
${this.hass.localize("ui.panel.config.labels.add_label")}
</div></ha-menu-item
>`;
const labelsInOverflow =
(this._sizeController.value && this._sizeController.value < 700) ||
const areaItems = html`${Object.values(this.hass.areas).map(
(area) =>
html`<ha-menu-item
.value=${area.area_id}
@click=${this._handleBulkArea}
>
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<div slot="headline">${area.name}</div>
</ha-menu-item>`
)}
<ha-menu-item .value=${null} @click=${this._handleBulkArea}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.no_area"
)}
</div>
</ha-menu-item>
<md-divider role="separator" tabindex="-1"></md-divider>
<ha-menu-item @click=${this._bulkCreateArea}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.add_area"
)}
</div>
</ha-menu-item>`;
const areasInOverflow =
(this._sizeController.value && this._sizeController.value < 900) ||
(!this._sizeController.value && this.hass.dockedSidebar === "docked");
const labelsInOverflow =
areasInOverflow &&
(!this._sizeController.value || this._sizeController.value < 700);
const scenes = this._scenes(
this.scenes,
this._entityReg,
@ -453,6 +494,7 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
this._labels,
this._filteredScenes
);
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
@ -582,9 +624,25 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
></ha-svg-icon>
</ha-assist-chip>
${labelItems}
</ha-button-menu-new>`}
${areasInOverflow
? nothing
: html`<ha-button-menu-new slot="selection-bar">
<ha-assist-chip
slot="trigger"
.label=${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.move_area"
)}
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
${areaItems}
</ha-button-menu-new>`}`
: nothing}
${this.narrow || labelsInOverflow
${this.narrow || areasInOverflow
? html`
<ha-button-menu-new has-overflow slot="selection-bar">
${
@ -630,8 +688,8 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
: nothing
}
${
this.narrow || this.hass.dockedSidebar === "docked"
? html` <ha-sub-menu>
this.narrow || labelsInOverflow
? html`<ha-sub-menu>
<ha-menu-item slot="item">
<div slot="headline">
${this.hass.localize(
@ -647,6 +705,24 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
</ha-sub-menu>`
: nothing
}
${
this.narrow || areasInOverflow
? html`<ha-sub-menu>
<ha-menu-item slot="item">
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.move_area"
)}
</div>
<ha-svg-icon
slot="end"
.path=${mdiChevronRight}
></ha-svg-icon>
</ha-menu-item>
<ha-menu slot="menu">${areaItems}</ha-menu>
</ha-sub-menu>`
: nothing
}
</ha-button-menu-new>`
: nothing}
${!this.scenes.length
@ -875,6 +951,46 @@ ${rejected
}
}
private async _handleBulkArea(ev) {
const area = ev.currentTarget.value;
this._bulkAddArea(area);
}
private async _bulkAddArea(area: string) {
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
this._selected.forEach((entityId) => {
promises.push(
updateEntityRegistryEntry(this.hass, entityId, {
area_id: area,
})
);
});
const result = await Promise.allSettled(promises);
if (hasRejectedItems(result)) {
const rejected = rejectedItems(result);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.common.multiselect.failed", {
number: rejected.length,
}),
text: html`<pre>
${rejected
.map((r) => r.reason.message || r.reason.code || r.reason)
.join("\r\n")}</pre
>`,
});
}
}
private async _bulkCreateArea() {
showAreaRegistryDetailDialog(this, {
createEntry: async (values) => {
const area = await createAreaRegistryEntry(this.hass, values);
this._bulkAddArea(area.area_id);
return area;
},
});
}
private _editCategory(scene: any) {
const entityReg = this._entityReg.find(
(reg) => reg.entity_id === scene.entity_id

View File

@ -13,6 +13,7 @@ import {
mdiPlus,
mdiScriptText,
mdiTag,
mdiTextureBox,
mdiTransitConnection,
} from "@mdi/js";
import { differenceInDays } from "date-fns";
@ -61,6 +62,7 @@ import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-menu-item";
import "../../../components/ha-sub-menu";
import "../../../components/ha-svg-icon";
import { createAreaRegistryEntry } from "../../../data/area_registry";
import {
CategoryRegistryEntry,
createCategoryRegistryEntry,
@ -98,6 +100,7 @@ import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail";
import { showNewAutomationDialog } from "../automation/show-dialog-new-automation";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
@ -418,6 +421,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
${this.hass.localize("ui.panel.config.category.editor.add")}
</div>
</ha-menu-item>`;
const labelItems = html`${this._labels?.map((label) => {
const color = label.color ? computeCssColor(label.color) : undefined;
const selected = this._selected.every((entityId) =>
@ -454,9 +458,46 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
${this.hass.localize("ui.panel.config.labels.add_label")}
</div></ha-menu-item
>`;
const labelsInOverflow =
(this._sizeController.value && this._sizeController.value < 700) ||
const areaItems = html`${Object.values(this.hass.areas).map(
(area) =>
html`<ha-menu-item
.value=${area.area_id}
@click=${this._handleBulkArea}
>
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<div slot="headline">${area.name}</div>
</ha-menu-item>`
)}
<ha-menu-item .value=${null} @click=${this._handleBulkArea}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.no_area"
)}
</div>
</ha-menu-item>
<md-divider role="separator" tabindex="-1"></md-divider>
<ha-menu-item @click=${this._bulkCreateArea}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.add_area"
)}
</div>
</ha-menu-item>`;
const areasInOverflow =
(this._sizeController.value && this._sizeController.value < 900) ||
(!this._sizeController.value && this.hass.dockedSidebar === "docked");
const labelsInOverflow =
areasInOverflow &&
(!this._sizeController.value || this._sizeController.value < 700);
const scripts = this._scripts(
this.scripts,
this._entityReg,
@ -608,9 +649,25 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
></ha-svg-icon>
</ha-assist-chip>
${labelItems}
</ha-button-menu-new>`}
${areasInOverflow
? nothing
: html`<ha-button-menu-new slot="selection-bar">
<ha-assist-chip
slot="trigger"
.label=${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.move_area"
)}
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
${areaItems}
</ha-button-menu-new>`}`
: nothing}
${this.narrow || labelsInOverflow
${this.narrow || areasInOverflow
? html`
<ha-button-menu-new has-overflow slot="selection-bar">
${
@ -656,8 +713,8 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
: nothing
}
${
this.narrow || this.hass.dockedSidebar === "docked"
? html` <ha-sub-menu>
this.narrow || labelsInOverflow
? html`<ha-sub-menu>
<ha-menu-item slot="item">
<div slot="headline">
${this.hass.localize(
@ -673,6 +730,24 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
</ha-sub-menu>`
: nothing
}
${
this.narrow || areasInOverflow
? html`<ha-sub-menu>
<ha-menu-item slot="item">
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.move_area"
)}
</div>
<ha-svg-icon
slot="end"
.path=${mdiChevronRight}
></ha-svg-icon>
</ha-menu-item>
<ha-menu slot="menu">${areaItems}</ha-menu>
</ha-sub-menu>`
: nothing
}
</ha-button-menu-new>`
: nothing}
${!this.scripts.length
@ -1111,6 +1186,46 @@ ${rejected
});
}
private async _handleBulkArea(ev) {
const area = ev.currentTarget.value;
this._bulkAddArea(area);
}
private async _bulkAddArea(area: string) {
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
this._selected.forEach((entityId) => {
promises.push(
updateEntityRegistryEntry(this.hass, entityId, {
area_id: area,
})
);
});
const result = await Promise.allSettled(promises);
if (hasRejectedItems(result)) {
const rejected = rejectedItems(result);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.common.multiselect.failed", {
number: rejected.length,
}),
text: html`<pre>
${rejected
.map((r) => r.reason.message || r.reason.code || r.reason)
.join("\r\n")}</pre
>`,
});
}
}
private async _bulkCreateArea() {
showAreaRegistryDetailDialog(this, {
createEntry: async (values) => {
const area = await createAreaRegistryEntry(this.hass, values);
this._bulkAddArea(area.area_id);
return area;
},
});
}
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}

View File

@ -90,12 +90,10 @@ class HuiLockOpenDoorCardFeature
return html`
${this._buttonState === "success"
? html`
<div class="buttons">
<p class="open-success">
<ha-svg-icon path=${mdiCheck}></ha-svg-icon>
${this.hass.localize("ui.card.lock.open_door_success")}
</p>
</div>
<p class="open-success">
<ha-svg-icon path=${mdiCheck}></ha-svg-icon>
${this.hass.localize("ui.card.lock.open_door_success")}
</p>
`
: html`
<ha-control-button-group>
@ -115,12 +113,6 @@ class HuiLockOpenDoorCardFeature
static get styles(): CSSResultGroup {
return css`
.buttons {
display: flex;
align-items: center;
justify-content: center;
margin-top: 0;
}
ha-control-button {
font-size: 14px;
}
@ -139,10 +131,14 @@ class HuiLockOpenDoorCardFeature
line-height: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
gap: 8px;
font-weight: 500;
color: var(--success-color);
margin: 0 12px 12px 12px;
height: 40px;
text-align: center;
}
ha-control-button-group + ha-attributes:not([empty]) {
margin-top: 16px;

View File

@ -138,7 +138,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
includeDomains
);
return { type: "map", entities: foundEntities };
return { type: "map", entities: foundEntities, theme_mode: "auto" };
}
protected render() {
@ -151,6 +151,17 @@ class HuiMapCard extends LitElement implements LovelaceCard {
(${this._error.code})
</ha-alert>`;
}
const isDarkMode =
this._config.dark_mode || this._config.theme_mode === "dark"
? true
: this._config.theme_mode === "light"
? false
: this.hass.themes.darkMode;
const themeMode =
this._config.theme_mode || (this._config.dark_mode ? "dark" : "auto");
return html`
<ha-card id="card" .header=${this._config.title}>
<div id="root">
@ -161,7 +172,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
.paths=${this._getHistoryPaths(this._config, this._stateHistory)}
.autoFit=${this._config.auto_fit || false}
.fitZones=${this._config.fit_zones}
?darkMode=${this._config.dark_mode}
.themeMode=${themeMode}
interactiveZones
renderPassive
></ha-map>
@ -170,6 +181,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
"ui.panel.lovelace.cards.map.reset_focus"
)}
.path=${mdiImageFilterCenterFocus}
style=${isDarkMode ? "color:#ffffff" : "color:#000000"}
@click=${this._fitMap}
tabindex="0"
></ha-icon-button>

View File

@ -3,7 +3,7 @@ import { ActionConfig } from "../../../data/lovelace/config/action";
import { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { Statistic, StatisticType } from "../../../data/recorder";
import { ForecastType } from "../../../data/weather";
import { FullCalendarView, TranslationDict } from "../../../types";
import { FullCalendarView, ThemeMode, TranslationDict } from "../../../types";
import { LovelaceCardFeatureConfig } from "../card-features/types";
import { LegacyStateFilter } from "../common/evaluate-filter";
import { Condition, LegacyCondition } from "../common/validate-condition";
@ -314,6 +314,7 @@ export interface MapCardConfig extends LovelaceCardConfig {
hours_to_show?: number;
geo_location_sources?: string[];
dark_mode?: boolean;
theme_mode?: ThemeMode;
}
export interface MarkdownCardConfig extends LovelaceCardConfig {

View File

@ -1,3 +1,4 @@
import { mdiPalette } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
@ -11,6 +12,7 @@ import {
string,
union,
} from "superstruct";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import { hasLocation } from "../../../../common/entity/has_location";
import "../../../../components/ha-form/ha-form";
@ -28,6 +30,7 @@ import { processEditorEntities } from "../process-editor-entities";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { EntitiesEditorEvent } from "../types";
import { configElementStyle } from "./config-elements-style";
import { LocalizeFunc } from "../../../../common/translations/localize";
export const mapEntitiesConfigStruct = union([
object({
@ -50,30 +53,11 @@ const cardConfigStruct = assign(
hours_to_show: optional(number()),
geo_location_sources: optional(array(string())),
auto_fit: optional(boolean()),
theme_mode: optional(string()),
})
);
const SCHEMA = [
{ name: "title", selector: { text: {} } },
{
name: "",
type: "grid",
schema: [
{ name: "aspect_ratio", selector: { text: {} } },
{
name: "default_zoom",
default: DEFAULT_ZOOM,
selector: { number: { mode: "box", min: 0 } },
},
{ name: "dark_mode", selector: { boolean: {} } },
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { mode: "box", min: 0 } },
},
],
},
] as const;
const themeModes = ["auto", "light", "dark"] as const;
@customElement("hui-map-card-editor")
export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
@ -83,8 +67,68 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
@state() private _configEntities?: EntityConfig[];
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{ name: "title", selector: { text: {} } },
{
name: "",
type: "expandable",
iconPath: mdiPalette,
title: localize(`ui.panel.lovelace.editor.card.map.appearance`),
schema: [
{
name: "",
type: "grid",
schema: [
{ name: "aspect_ratio", selector: { text: {} } },
{
name: "default_zoom",
default: DEFAULT_ZOOM,
selector: { number: { mode: "box", min: 0 } },
},
{
name: "theme_mode",
default: "auto",
selector: {
select: {
mode: "dropdown",
options: themeModes.map((themeMode) => ({
value: themeMode,
label: localize(
`ui.panel.lovelace.editor.card.map.theme_modes.${themeMode}`
),
})),
},
},
},
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { mode: "box", min: 0 } },
},
],
},
],
},
] as const
);
public setConfig(config: MapCardConfig): void {
assert(config, cardConfigStruct);
// Migrate legacy dark_mode to theme_mode
if (!this._config && !("theme_mode" in config)) {
config = { ...config };
if (config.dark_mode) {
config.theme_mode = "dark";
} else {
config.theme_mode = "auto";
}
delete config.dark_mode;
fireEvent(this, "config-changed", { config: config });
}
this._config = config;
this._configEntities = config.entities
? processEditorEntities(config.entities)
@ -104,33 +148,32 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${SCHEMA}
.schema=${this._schema(this.hass.localize)}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
<div class="card-config">
<hui-entity-editor
.hass=${this.hass}
.entities=${this._configEntities}
.entityFilter=${hasLocation}
@entities-changed=${this._entitiesValueChanged}
></hui-entity-editor>
<h3>
${this.hass.localize(
"ui.panel.lovelace.editor.card.map.geo_location_sources"
)}
</h3>
<div class="geo_location_sources">
<hui-input-list-editor
.inputLabel=${this.hass.localize(
"ui.panel.lovelace.editor.card.map.source"
)}
.hass=${this.hass}
.value=${this._geo_location_sources}
@value-changed=${this._geoSourcesChanged}
></hui-input-list-editor>
</div>
</div>
<hui-entity-editor
.hass=${this.hass}
.entities=${this._configEntities}
.entityFilter=${hasLocation}
@entities-changed=${this._entitiesValueChanged}
></hui-entity-editor>
<h3>
${this.hass.localize(
"ui.panel.lovelace.editor.card.map.geo_location_sources"
)}
</h3>
<hui-input-list-editor
.inputLabel=${this.hass.localize(
"ui.panel.lovelace.editor.card.map.source"
)}
.hass=${this.hass}
.value=${this._geo_location_sources}
@value-changed=${this._geoSourcesChanged}
></hui-input-list-editor>
`;
}
@ -170,9 +213,14 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "dark_mode":
case "theme_mode":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.map.${schema.name}`
);
case "default_zoom":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.map.${schema.name}`
@ -185,16 +233,7 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
};
static get styles(): CSSResultGroup {
return [
configElementStyle,
css`
.geo_location_sources {
padding-left: 20px;
padding-inline-start: 20px;
direction: var(--direction);
}
`,
];
return [configElementStyle, css``];
}
}

View File

@ -5835,7 +5835,14 @@
"name": "Map",
"geo_location_sources": "Geolocation sources",
"dark_mode": "Dark mode?",
"default_zoom": "Default zoom",
"appearance": "Appearance",
"theme_mode": "Theme Mode",
"theme_modes": {
"auto": "Auto",
"light": "Light",
"dark": "Dark"
},
"default_zoom": "Default Zoom",
"source": "Source",
"description": "The Map card that allows you to display entities on a map."
},

View File

@ -139,6 +139,8 @@ export type FullCalendarView =
| "dayGridDay"
| "listWeek";
export type ThemeMode = "auto" | "light" | "dark";
export interface ToggleButton {
label: string;
iconPath?: string;

View File

@ -7028,10 +7028,10 @@ __metadata:
languageName: node
linkType: hard
"cropperjs@npm:1.6.1":
version: 1.6.1
resolution: "cropperjs@npm:1.6.1"
checksum: 10/3ecd895ba8820021d6efd8effda54fb40a418e6940c307d46c62812d7d0ee10aacde473b44b7cfb315ec0fc2e5c5c573f4de06e8c2e8b7cb134f12f65c1d4aa7
"cropperjs@npm:1.6.2":
version: 1.6.2
resolution: "cropperjs@npm:1.6.2"
checksum: 10/4b97ac27b7fd65316a531372dd96ce1073b4620c1b012fbad5acb0fdc28952112466bc333a98c510fe2bc9dcdeee58f5c8619522c045c6166300621673e3d759
languageName: node
linkType: hard
@ -9674,7 +9674,7 @@ __metadata:
color-name: "npm:2.0.0"
comlink: "npm:4.4.1"
core-js: "npm:3.37.0"
cropperjs: "npm:1.6.1"
cropperjs: "npm:1.6.2"
date-fns: "npm:3.6.0"
date-fns-tz: "npm:3.1.3"
deep-clone-simple: "npm:1.1.1"