1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-07-11 16:26:39 +01:00

Merge branch 'rc'

This commit is contained in:
Bram Kragten
2026-06-07 20:19:56 +02:00
14 changed files with 1009 additions and 582 deletions
@@ -0,0 +1,86 @@
diff --git a/dist/tinykeys.cjs b/dist/tinykeys.cjs
index 08c98b6eff3b8fb4b727fe8e6b096951d6ef6347..9c44f14862f582766ea1733b6dc0e97f962800d8 100644
--- a/dist/tinykeys.cjs
+++ b/dist/tinykeys.cjs
@@ -61,6 +61,18 @@ function defaultKeybindingsHandlerIgnore(event) {
function getModifierState(event, mod) {
return typeof event.getModifierState === "function" ? event.getModifierState(mod) || ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState("AltGraph") : false;
}
+function splitKeybindingPress(press) {
+ let parts = [];
+ let start = 0;
+ for (let index = 0; index < press.length; index++) {
+ if (press[index] === "+" && /[\w\]]/.test(press[index - 1] || "")) {
+ parts.push(press.slice(start, index));
+ start = index + 1;
+ }
+ }
+ parts.push(press.slice(start));
+ return parts;
+}
/**
* Parses a keybinding string into its parts.
*
@@ -76,10 +88,10 @@ function getModifierState(event, mod) {
*/
function parseKeybinding(str) {
return str.trim().split(" ").map((press) => {
- let parts = press.split(/(?<=\w|\])\+/);
+ let parts = splitKeybindingPress(press);
let last = parts.pop();
let regex = last.match(/^\((.+)\)$/);
- let key = regex ? new RegExp(`^(?:${regex[1]})$`, "iv") : last;
+ let key = regex ? new RegExp(`^(?:${regex[1]})$`, "i") : last;
let requiredModifiers = [];
let optionalModifiers = [];
for (const part of parts) {
@@ -201,5 +213,3 @@ exports.defaultKeybindingsHandlerIgnore = defaultKeybindingsHandlerIgnore;
exports.matchKeybindingPress = matchKeybindingPress;
exports.parseKeybinding = parseKeybinding;
exports.tinykeys = tinykeys;
-
-//# sourceMappingURL=tinykeys.cjs.map
\ No newline at end of file
diff --git a/dist/tinykeys.mjs b/dist/tinykeys.mjs
index c289972d2728e03d9b272268c38fd3392e8845bf..e22897b00aae6cdb0dbbb971445227c07be52918 100644
--- a/dist/tinykeys.mjs
+++ b/dist/tinykeys.mjs
@@ -60,6 +60,18 @@ function defaultKeybindingsHandlerIgnore(event) {
function getModifierState(event, mod) {
return typeof event.getModifierState === "function" ? event.getModifierState(mod) || ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState("AltGraph") : false;
}
+function splitKeybindingPress(press) {
+ let parts = [];
+ let start = 0;
+ for (let index = 0; index < press.length; index++) {
+ if (press[index] === "+" && /[\w\]]/.test(press[index - 1] || "")) {
+ parts.push(press.slice(start, index));
+ start = index + 1;
+ }
+ }
+ parts.push(press.slice(start));
+ return parts;
+}
/**
* Parses a keybinding string into its parts.
*
@@ -75,10 +87,10 @@ function getModifierState(event, mod) {
*/
function parseKeybinding(str) {
return str.trim().split(" ").map((press) => {
- let parts = press.split(/(?<=\w|\])\+/);
+ let parts = splitKeybindingPress(press);
let last = parts.pop();
let regex = last.match(/^\((.+)\)$/);
- let key = regex ? new RegExp(`^(?:${regex[1]})$`, "iv") : last;
+ let key = regex ? new RegExp(`^(?:${regex[1]})$`, "i") : last;
let requiredModifiers = [];
let optionalModifiers = [];
for (const part of parts) {
@@ -196,5 +208,3 @@ function tinykeys(target, keybindingMap, options = {}) {
}
//#endregion
export { createKeybindingsHandler, defaultKeybindingsHandlerIgnore, matchKeybindingPress, parseKeybinding, tinykeys };
-
-//# sourceMappingURL=tinykeys.mjs.map
\ No newline at end of file
+1 -1
View File
@@ -115,7 +115,7 @@
"sortablejs": "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch",
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "4.0.0",
"tinykeys": "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260527.4"
version = "20260527.5"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+34 -2
View File
@@ -1,11 +1,16 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiChartLine, mdiHelpCircleOutline, mdiShape } from "@mdi/js";
import {
mdiChartLine,
mdiHelpCircleOutline,
mdiPencil,
mdiShape,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl";
@@ -53,6 +58,16 @@ const SEARCH_KEYS = [
{ name: "id", weight: 2 },
];
export interface StatisticElementChangedEvent {
statisticId: string;
}
declare global {
interface HASSDomEvents {
"edit-statistics-element": StatisticElementChangedEvent;
}
}
@customElement("ha-statistic-picker")
export class HaStatisticPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -130,6 +145,8 @@ export class HaStatisticPicker extends LitElement {
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@property({ attribute: "can-edit", type: Boolean }) public canEdit?: boolean;
public willUpdate(changedProps: PropertyValues<this>) {
if (
(!this.hasUpdated && !this.statisticIds) ||
@@ -341,6 +358,15 @@ export class HaStatisticPicker extends LitElement {
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${this.canEdit
? html`<ha-icon-button
slot="end"
.value=${statisticId}
.label=${this.hass.localize("ui.common.edit")}
.path=${mdiPencil}
@click=${this._editItem}
></ha-icon-button>`
: nothing}
`;
}
@@ -350,6 +376,12 @@ export class HaStatisticPicker extends LitElement {
private _valueRenderer: PickerValueRenderer = this._makeValueRenderer();
private _editItem(ev: HASSDomEvent<StatisticElementChangedEvent>) {
ev.stopPropagation();
const statisticId = (ev.currentTarget as any).value;
fireEvent(this, "edit-statistics-element", { statisticId });
}
private _computeItem(statisticId: string): StatisticComboBoxItem {
const stateObj = this.hass.states[statisticId];
+17 -1
View File
@@ -1,9 +1,10 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
import type { ValueChangedEvent, HomeAssistant } from "../../types";
import "./ha-statistic-picker";
import type { StatisticElementChangedEvent } from "./ha-statistic-picker";
@customElement("ha-statistics-picker")
class HaStatisticsPicker extends LitElement {
@@ -59,6 +60,8 @@ class HaStatisticsPicker extends LitElement {
})
public ignoreRestrictionsOnFirstStatistic = false;
@property({ attribute: "can-edit", type: Boolean }) public canEdit?;
protected render() {
if (!this.hass) {
return nothing;
@@ -99,7 +102,9 @@ class HaStatisticsPicker extends LitElement {
.statisticIds=${this.statisticIds}
.excludeStatistics=${this.value}
.allowCustomEntity=${this.allowCustomEntity}
.canEdit=${this.canEdit}
@value-changed=${this._statisticChanged}
@edit-statistics-element=${this._editItem}
></ha-statistic-picker>
</div>
`
@@ -122,6 +127,17 @@ class HaStatisticsPicker extends LitElement {
`;
}
private _editItem(ev: HASSDomEvent<StatisticElementChangedEvent>) {
const statisticId = ev.detail.statisticId;
const index = this._currentStatistics!.findIndex((e) => e === statisticId);
fireEvent(this, "edit-detail-element", {
subElementConfig: {
index,
type: "row",
},
});
}
private get _currentStatistics() {
return this.value || [];
}
+4 -4
View File
@@ -1600,8 +1600,8 @@ export class HaCodeEditor extends ReactiveElement {
// Filter states based on what's typed
const filteredStates = typedText
? states.filter((entityState) =>
entityState.label
.toLowerCase()
entityState.displayLabel
?.toLowerCase()
.startsWith(typedText.toLowerCase())
)
: states;
@@ -1658,8 +1658,8 @@ export class HaCodeEditor extends ReactiveElement {
// Filter states based on what's typed
const filteredStates = typedText
? states.filter((entityState) =>
entityState.label
.toLowerCase()
entityState.displayLabel
?.toLowerCase()
.startsWith(typedText.toLowerCase())
)
: states;
@@ -169,6 +169,9 @@ export class SupervisorAppsRepositoryEl extends LitElement {
cursor: pointer;
overflow: hidden;
}
ha-card:hover {
background-color: var(--ha-color-fill-neutral-quiet-resting);
}
.card-content.has-footer {
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
}
@@ -11,6 +11,11 @@ import type {
MediaPlayerSoundModeCardFeatureConfig,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
import {
customizableListData,
customizableListSchema,
processCustomizableListValue,
} from "./customizable-list-feature";
@customElement("hui-media-player-sound-mode-card-feature-editor")
export class HuiMediaPlayerSoundModeCardFeatureEditor
@@ -28,28 +33,20 @@ export class HuiMediaPlayerSoundModeCardFeatureEditor
}
private _schema = memoizeOne(
(hass: HomeAssistant, stateObj?: MediaPlayerEntity) =>
[
{
name: "sound_modes",
selector: {
select: {
multiple: true,
mode: "list" as const,
reorder: true,
options:
stateObj?.attributes.sound_mode_list?.map((mode) => ({
value: mode,
label: hass.formatEntityAttributeValue(
stateObj,
"sound_mode",
mode
),
})) ?? [],
},
},
},
] as const
(stateObj: MediaPlayerEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "sound_modes",
customize,
options:
stateObj?.attributes.sound_mode_list?.map((mode) => ({
value: mode,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"sound_mode",
mode
),
})) ?? [],
})
);
protected render() {
@@ -63,12 +60,13 @@ export class HuiMediaPlayerSoundModeCardFeatureEditor
| undefined)
: undefined;
const schema = this._schema(this.hass!, stateObj);
const data = customizableListData(this._config, "sound_modes");
const schema = this._schema(stateObj, data.customize);
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
@@ -79,21 +77,27 @@ export class HuiMediaPlayerSoundModeCardFeatureEditor
private _valueChanged(
ev: ValueChangedEvent<MediaPlayerSoundModeCardFeatureConfig>
): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
const stateObj = this.context?.entity_id
? (this.hass!.states[this.context.entity_id] as
| MediaPlayerEntity
| undefined)
: undefined;
const defaults = stateObj?.attributes.sound_mode_list ?? [];
const config =
processCustomizableListValue<MediaPlayerSoundModeCardFeatureConfig>(
ev.detail.value,
"sound_modes",
defaults
);
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "sound_modes":
return this.hass?.localize(
`ui.panel.lovelace.editor.features.types.media-player-sound-mode.${schema.name}`
);
default:
return "";
}
};
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.media-player-sound-mode.${schema.name}`
);
}
declare global {
@@ -11,6 +11,11 @@ import type {
MediaPlayerSourceCardFeatureConfig,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
import {
customizableListData,
customizableListSchema,
processCustomizableListValue,
} from "./customizable-list-feature";
@customElement("hui-media-player-source-card-feature-editor")
export class HuiMediaPlayerSourceCardFeatureEditor
@@ -28,24 +33,20 @@ export class HuiMediaPlayerSourceCardFeatureEditor
}
private _schema = memoizeOne(
(stateObj?: MediaPlayerEntity) =>
[
{
name: "sources",
selector: {
select: {
multiple: true,
mode: "list" as const,
reorder: true,
options:
stateObj?.attributes.source_list?.map((source) => ({
value: source,
label: source,
})) ?? [],
},
},
},
] as const
(stateObj: MediaPlayerEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "sources",
customize,
options:
stateObj?.attributes.source_list?.map((source) => ({
value: source,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"source",
source
),
})) ?? [],
})
);
protected render() {
@@ -59,12 +60,13 @@ export class HuiMediaPlayerSourceCardFeatureEditor
| undefined)
: undefined;
const schema = this._schema(stateObj);
const data = customizableListData(this._config, "sources");
const schema = this._schema(stateObj, data.customize);
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
@@ -75,21 +77,27 @@ export class HuiMediaPlayerSourceCardFeatureEditor
private _valueChanged(
ev: ValueChangedEvent<MediaPlayerSourceCardFeatureConfig>
): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
const stateObj = this.context?.entity_id
? (this.hass!.states[this.context.entity_id] as
| MediaPlayerEntity
| undefined)
: undefined;
const defaults = stateObj?.attributes.source_list ?? [];
const config =
processCustomizableListValue<MediaPlayerSourceCardFeatureConfig>(
ev.detail.value,
"sources",
defaults
);
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "sources":
return this.hass?.localize(
`ui.panel.lovelace.editor.features.types.media-player-source.${schema.name}`
);
default:
return "";
}
};
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.media-player-source.${schema.name}`
);
}
declare global {
@@ -15,7 +15,10 @@ import {
union,
} from "superstruct";
import { ensureArray } from "../../../../common/array/ensure-array";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
type HASSDomEvent,
fireEvent,
} from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { deepEqual } from "../../../../common/util/deep-equal";
import { supportedStatTypeMap } from "../../../../components/chart/statistics-chart";
@@ -32,13 +35,19 @@ import {
isExternalStatistic,
statisticsMetaHasType,
} from "../../../../data/recorder";
import type { EntityConfig } from "../../entity-rows/types";
import type { HomeAssistant } from "../../../../types";
import { DEFAULT_DAYS_TO_SHOW } from "../../cards/hui-statistics-graph-card";
import type { StatisticsGraphCardConfig } from "../../cards/types";
import type {
GraphEntityConfig,
StatisticsGraphCardConfig,
} from "../../cards/types";
import { processConfigEntities } from "../../common/process-config-entities";
import type { LovelaceCardEditor } from "../../types";
import "../hui-sub-element-editor";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { graphEntitiesConfigStruct } from "../structs/entities-struct";
import type { EditDetailElementEvent, SubElementEditorConfig } from "../types";
import { orderPropertiesGraphCard } from "./order-properties/order-properties-graph";
const statTypeStruct = union([
@@ -114,6 +123,8 @@ export class HuiStatisticsGraphCardEditor
@state() private _metaDatas?: StatisticsMetaData[];
@state() private _subElementEditorConfig?: SubElementEditorConfig;
public setConfig(config: StatisticsGraphCardConfig): void {
assert(config, cardConfigStruct);
this._config = config;
@@ -334,11 +345,54 @@ export class HuiStatisticsGraphCardEditor
}
);
private _subForm = memoizeOne((localize: LocalizeFunc) => ({
schema: [
{ name: "entity", required: true, selector: { statistic: {} } },
{
name: "name",
selector: { entity_name: {} },
context: {
entity: "entity",
},
},
{
name: "color",
selector: { ui_color: {} },
},
] as const,
computeLabel: (item: HaFormSchema) => {
switch (item.name) {
case "entity":
return localize(
"ui.panel.lovelace.editor.card.statistics-graph.picked_statistic"
);
case "name":
case "color":
return localize(`ui.panel.lovelace.editor.card.generic.${item.name}`);
default:
return undefined;
}
},
}));
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
if (this._subElementEditorConfig) {
return html`
<hui-sub-element-editor
.hass=${this.hass}
.config=${this._subElementEditorConfig}
.form=${this._subForm(this.hass.localize)}
@go-back=${this._goBack}
@config-changed=${this._handleSubEntityChanged}
>
</hui-sub-element-editor>
`;
}
const schema = this._schema(
this.hass.localize,
this._configEntities,
@@ -388,11 +442,29 @@ export class HuiStatisticsGraphCardEditor
.ignoreRestrictionsOnFirstStatistic=${true}
.value=${this._configEntities}
.configValue=${"entities"}
can-edit
@value-changed=${this._entitiesChanged}
@edit-detail-element=${this._editDetailElement}
></ha-statistics-picker>
`;
}
private _goBack(): void {
this._subElementEditorConfig = undefined;
}
private _editDetailElement(ev: HASSDomEvent<EditDetailElementEvent>): void {
const index = ev.detail.subElementConfig.index!;
let elementConfig = this._config!.entities[index];
if (typeof elementConfig === "string") {
elementConfig = { entity: elementConfig };
}
this._subElementEditorConfig = {
...ev.detail.subElementConfig,
...{ elementConfig: elementConfig as EntityConfig },
};
}
private _valueChanged(ev: CustomEvent): void {
const config = this._orderProperties(ev.detail.value);
fireEvent(this, "config-changed", { config });
@@ -410,15 +482,66 @@ export class HuiStatisticsGraphCardEditor
});
let config = { ...this._config!, entities: newEntities };
// remove inappropriate stat options dependently on entities
config = await this._cleanConfig(config);
// normalize a generated yaml code
config = this._orderProperties(config);
fireEvent(this, "config-changed", {
config,
});
}
private async _handleSubEntityChanged(ev: CustomEvent): Promise<void> {
ev.stopPropagation();
// get updated entity config
const newEntityConfig = ev.detail.config as GraphEntityConfig;
// update card config with updated entity config
const index = this._subElementEditorConfig!.index!;
const newEntities = [...this._config!.entities];
newEntities[index] = newEntityConfig;
let config = this._config!;
config = { ...config, entities: newEntities };
// remove inappropriate stat options dependently on entities
config = await this._cleanConfig(config);
// normalize a generated yaml code
config = this._orderProperties(config);
this._config = config;
// update sub-element editor config
this._subElementEditorConfig = {
...this._subElementEditorConfig!,
elementConfig: {
...(this._config!.entities[index] as GraphEntityConfig),
},
};
fireEvent(this, "config-changed", { config });
}
// remove inappropriate stat options dependently on entities
private async _cleanConfig(
config: StatisticsGraphCardConfig
): Promise<StatisticsGraphCardConfig> {
const entityIds = config.entities.map((entityConf) => {
if (typeof entityConf === "string") {
return entityConf;
}
return entityConf.entity ?? undefined;
});
if (
newEntityIds?.some((statistic_id) => isExternalStatistic(statistic_id)) &&
entityIds.some((statistic_id) => isExternalStatistic(statistic_id)) &&
config.period === "5minute"
) {
delete config.period;
}
const metadata =
config.stat_types || config.unit
? await getStatisticMetadata(this.hass!, newEntityIds)
? await getStatisticMetadata(this.hass!, entityIds)
: undefined;
if (config.stat_types && config.entities.length) {
config.stat_types = ensureArray(config.stat_types).filter((stat_type) =>
@@ -438,10 +561,8 @@ export class HuiStatisticsGraphCardEditor
) {
delete config.unit;
}
config = this._orderProperties(config);
fireEvent(this, "config-changed", {
config,
});
return config;
}
// normalize a generated yaml code by placing lines in a consistent order
+3
View File
@@ -136,7 +136,10 @@ class LovelaceFullConfigEditor extends LitElement {
}
ha-yaml-editor {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.save-button {
+3
View File
@@ -296,6 +296,9 @@ export const getMyRedirects = (): Redirects => ({
component: "history",
redirect: "/history",
},
maintenance: {
redirect: "/maintenance",
},
overview: {
redirect: "/home/overview",
},
+4 -2
View File
@@ -10136,11 +10136,13 @@
},
"media-player-sound-mode": {
"label": "Media player sound mode",
"sound_modes": "Sound modes"
"sound_modes": "Sound modes",
"customize": "Customize sound modes"
},
"media-player-source": {
"label": "Media player source",
"sources": "Sources"
"sources": "Sources",
"customize": "Customize sources"
},
"media-player-volume-buttons": {
"label": "Media player volume buttons",
+646 -497
View File
File diff suppressed because it is too large Load Diff